import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { randomUUID } from "node:crypto";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { createS3Client, createRetrofitDataS3Client, retrofitDataS3Bucket } from "@/app/utils/s3";
import * as XLSX from "xlsx";
import { loadForAddressMatching, saveMultiEntrySummary, triggerAddressMatching, triggerClassifier } from "@/lib/bulkUpload/server";
import { readSessionToken } from "@/lib/session";
import { ADDRESS_FIELDS, classifierMapping } from "@/lib/bulkUpload/columnFields";
import { addressCsvKey, classifierCsvKey, SOURCE_ROW_ID_COLUMN } from "@/lib/bulkUpload/s3Keys";
import { detectMultiEntry } from "@/lib/bulkUpload/multiEntry";

type SheetRow = Record<string, unknown>;

function readRows(buffer: Buffer): SheetRow[] {
  const wb = XLSX.read(buffer, { type: "buffer" });
  const sheet = wb.Sheets[wb.SheetNames[0]];
  return XLSX.utils.sheet_to_json<SheetRow>(sheet, { defval: "" });
}

// Address-matching CSV: address fields only, renamed to canonical headers.
function buildAddressCsv(
  rows: SheetRow[],
  columnMapping: Record<string, string> // field → source header
): { csv: string; error?: never } | { csv?: never; error: string } {
  const outputHeaders: string[] = [];
  const outputToSource: Record<string, string> = {};
  for (const field of ADDRESS_FIELDS) {
    const src = columnMapping[field.value];
    if (!src || !field.outputHeader) continue;
    outputHeaders.push(field.outputHeader);
    outputToSource[field.outputHeader] = src;
  }

  if (!outputHeaders.includes("Address 1"))
    return { error: 'Mapping must include "Address 1"' };
  if (!outputHeaders.includes("postcode"))
    return { error: 'Mapping must include "postcode"' };

  // Carry the synthetic per-row join key through to the combiner output, so the
  // finaliser can re-associate a UPRN-matched row with its classifier
  // descriptions (ADR-0006). It rides `address2uprn` as a preserved input column.
  outputHeaders.push(SOURCE_ROW_ID_COLUMN);

  const outputRows = rows.map((row) => {
    const out: SheetRow = {};
    for (const [outName, src] of Object.entries(outputToSource)) {
      out[outName] = row[src] ?? "";
    }
    out[SOURCE_ROW_ID_COLUMN] = row[SOURCE_ROW_ID_COLUMN] ?? "";
    return out;
  });

  const outSheet = XLSX.utils.json_to_sheet(outputRows, { header: outputHeaders });
  return { csv: XLSX.utils.sheet_to_csv(outSheet) };
}

// Classifier CSV: the mapped classifier source columns only, original headers
// preserved (the lambda resolves them via column_mapping). Converting here means
// the classifier always reads a real CSV even when the upload was .xlsx/.xls —
// see ADR-0003. One source header may feed several categories, so dedupe to
// distinct headers.
function buildClassifierCsv(
  rows: SheetRow[],
  classifierMap: Record<string, string> // category → source header
): string {
  const sourceHeaders = [...new Set(Object.values(classifierMap))];
  // Emit the synthetic join key alongside the classifier columns so the
  // finaliser can join this row's descriptions to its combiner identity by
  // `source_row_id` (ADR-0006). `buildClassifierCsv` projects a fixed column
  // set, so the key must be added explicitly — attaching it to the row is not
  // enough.
  const headers = [...sourceHeaders, SOURCE_ROW_ID_COLUMN];
  const outputRows = rows.map((row) => {
    const out: SheetRow = {};
    for (const h of sourceHeaders) out[h] = row[h] ?? "";
    out[SOURCE_ROW_ID_COLUMN] = row[SOURCE_ROW_ID_COLUMN] ?? "";
    return out;
  });
  const outSheet = XLSX.utils.json_to_sheet(outputRows, { header: headers });
  return XLSX.utils.sheet_to_csv(outSheet);
}

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
  const session = await getServerSession(AuthOptions);
  if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { portfolioId, uploadId } = await params;

  const guarded = await loadForAddressMatching(uploadId);
  switch (guarded.kind) {
    case "not_found":
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    case "wrong_state":
      return NextResponse.json(
        { error: `Upload not ready for onboarding (state: ${guarded.current})` },
        { status: 409 }
      );
    case "missing_mapping":
      return NextResponse.json({ error: "Column mapping missing" }, { status: 422 });
  }
  const upload = guarded.upload;

  const s3 = createS3Client();
  const outputS3 = createRetrofitDataS3Client();
  const outputBucket = retrofitDataS3Bucket();

  let fileBuffer: Buffer;
  try {
    const obj = await s3
      .getObject({ Bucket: upload.s3Bucket, Key: upload.s3Key })
      .promise();
    fileBuffer = Buffer.from(obj.Body as Uint8Array);
  } catch (err) {
    console.error("Failed to read source file from S3:", err);
    return NextResponse.json({ error: "Failed to read source file" }, { status: 500 });
  }

  const parsedRows = readRows(fileBuffer);
  if (parsedRows.length === 0)
    return NextResponse.json({ error: "Empty file" }, { status: 422 });

  // Mint a stable synthetic id per source row, here at the one point both CSVs
  // are built from the same array, and write it into both. It is the finaliser's
  // join key between the combiner output (identity) and the classifier CSV
  // (descriptions) — see ADR-0006. Deterministic ordering is not required: both
  // CSVs are produced together in this handler, so they always share values.
  const rows = parsedRows.map((row) => ({
    ...row,
    [SOURCE_ROW_ID_COLUMN]: randomUUID(),
  }));

  // Detect multi-entry building parts now, while the whole file is parsed in
  // memory, so the awaiting_review surface never re-reads it (ADR-0004).
  await saveMultiEntrySummary(uploadId, detectMultiEntry(rows, upload.columnMapping!));

  const transformed = buildAddressCsv(rows, upload.columnMapping!);
  if (transformed.error)
    return NextResponse.json({ error: transformed.error }, { status: 422 });

  const transformedKey = addressCsvKey(portfolioId, uploadId);
  try {
    await outputS3
      .putObject({
        Bucket: outputBucket,
        Key: transformedKey,
        Body: transformed.csv,
        ContentType: "text/csv",
      })
      .promise();
  } catch (err) {
    console.error("Failed to upload transformed CSV:", err);
    return NextResponse.json({ error: "Failed to store transformed file" }, { status: 500 });
  }

  const s3Uri = `s3://${outputBucket}/${transformedKey}`;

  // Convert the mapped classifier columns to their own CSV so the classifier
  // lambda always parses a real CSV, never the raw upload (which may be
  // .xlsx/.xls). Only when the user mapped ≥1 classifier column. See ADR-0003.
  const classifierMap = classifierMapping(upload.columnMapping!);
  let classifierS3Uri: string | undefined;
  if (Object.keys(classifierMap).length > 0) {
    const classifierKey = classifierCsvKey(portfolioId, uploadId);
    try {
      await outputS3
        .putObject({
          Bucket: outputBucket,
          Key: classifierKey,
          Body: buildClassifierCsv(rows, classifierMap),
          ContentType: "text/csv",
        })
        .promise();
      classifierS3Uri = `s3://${outputBucket}/${classifierKey}`;
    } catch (err) {
      // Non-blocking: classification is skipped, address matching proceeds.
      console.error("Failed to upload classifier CSV:", err);
    }
  }

  const sessionToken = readSessionToken(request);
  const trigger = await triggerAddressMatching({ uploadId, s3Uri, sessionToken });
  if (trigger.kind === "trigger_failed")
    return NextResponse.json({ error: trigger.message }, { status: trigger.status });

  // Co-fire the landlord classifier (non-blocking) under the same task.
  if (classifierS3Uri)
    await triggerClassifier({ taskId: trigger.taskId, uploadId, s3Uri: classifierS3Uri, sessionToken });

  return NextResponse.json({ taskId: trigger.taskId }, { status: 200 });
}
