import { NextRequest, NextResponse } from "next/server";
import { queryRaw, queryRow } from "@/lib/db";
import {
  verifySlackSignature,
  participantForSlackUser,
  slackUserForParticipant,
} from "@/lib/slack-verify";
import { nudgeBlocks, detailsModal, partnerNudgeBlocks } from "@/lib/slack-blocks";
import { completeSplit } from "@/lib/splits";
import {
  RATINGS,
  OWNER_PARTICIPANT_ID,
  SECOND_CONSUMER_ID,
  merchantVerdict,
  type Rating,
  type ItemOpinion,
} from "@/lib/order-reviews";

/**
 * Slack button presses on the order nudge.
 *
 * Pressing "Shared 50/50" splits the transaction here and now and edits the
 * message in place. It deliberately does NOT link back to the app: being sent
 * to a web app to answer a yes/no question is enough friction that the question
 * stops getting answered (user, 2026-07-28).
 *
 * **Slack does not reach this route directly.** It posts to an n8n webhook,
 * which forwards the raw body and Slack's signature headers here. That was the
 * user's suggestion (2026-07-28) and it is the better shape: n8n already
 * terminates public webhooks, so the app keeps its blanket OAuth chain and
 * gains no internet-facing unauthenticated route. n8n cannot do the verifying
 * itself — its Code node sandbox has no `require`, so no `crypto`.
 *
 * Two independent gates, both fail closed:
 *   1. `x-ingest-token`, proving the call came from n8n over the internal
 *      network. Same shared secret as the order ingest route.
 *   2. Slack's v0 request signature over the forwarded raw body, proving the
 *      payload really came from Slack and is not a replay.
 *
 * The card is updated by POSTing to `payload.response_url`, NOT by returning a
 * message body. Block Kit interactivity ignores the HTTP response body — that
 * replacement behaviour belongs to legacy attachment-style messages. Assuming
 * otherwise meant every press wrote correctly and then left the card showing
 * stale state, so a working button looked dead and got pressed twice, undoing
 * itself. `response_url` needs no bot token, which is why this stays in the app.
 */

export async function POST(req: NextRequest) {
  const expected = process.env.ORDER_INGEST_TOKEN;
  if (!expected || req.headers.get("x-ingest-token") !== expected) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Must be the RAW body Slack signed — re-serialising the parsed form changes
  // the bytes and every signature check fails. n8n forwards it verbatim.
  const raw = await req.text();
  if (
    !verifySlackSignature(
      raw,
      req.headers.get("x-slack-request-timestamp"),
      req.headers.get("x-slack-signature")
    )
  ) {
    return NextResponse.json({ error: "bad signature" }, { status: 401 });
  }

  const payloadRaw = new URLSearchParams(raw).get("payload");
  if (!payloadRaw) return NextResponse.json({ error: "no payload" }, { status: 400 });

  const payload = JSON.parse(payloadRaw);

  if (payload.type === "view_submission") return handleModalSubmit(payload);
  if (payload.type !== "block_actions") return new NextResponse(null, { status: 200 });

  const action = payload.actions?.[0];
  // A button carries `value`; a select carries it on the chosen option. The
  // ratings moved to a select because four buttons became four full-width rows
  // on Slack mobile, so both shapes have to resolve or rating silently stops
  // working while sharing still does.
  const rawValue = action?.value ?? action?.selected_option?.value ?? "";
  const [idStr, verb, arg] = String(rawValue).split(":");
  const transactionId = Number(idStr);
  if (!Number.isInteger(transactionId)) {
    return NextResponse.json({ text: "Could not tell which order that was." });
  }

  const participantId = participantForSlackUser(payload.user?.id ?? "");
  if (!participantId) {
    // Ephemeral: only the presser sees it, so an unmapped colleague does not
    // rewrite the shared message for everyone.
    await updateMessage(payload.response_url, {
      response_type: "ephemeral",
      replace_original: false,
      text: `I don't know which participant ${payload.user?.id} is — add them to SLACK_USER_MAP.`,
    });
    return NextResponse.json({});
  }

  // The modal needs `views.open` called with this trigger_id within ~3s. The
  // app has no Slack bot token — n8n already holds the credential — so the
  // view is returned and n8n makes the call. That keeps one copy of the token.
  if (verb === "details") {
    const view = await buildDetailsModal(transactionId, participantId);
    if (!view) return NextResponse.json({ text: "That order is no longer in the ledger." });
    return NextResponse.json({ action: "open_modal", trigger_id: payload.trigger_id, view });
  }

  let refused: string | null = null;
  if (verb === "share") {
    refused = await toggleShare(transactionId);
  } else if (verb === "rate" && RATINGS.includes(arg as Rating)) {
    await setRating(transactionId, participantId, arg as Rating);
  }

  const state = await nudgeState(transactionId);
  if (!state) return NextResponse.json({ text: "That order is no longer in the ledger." });

  // Ask the other person for their verdict, but only on the press that turned
  // sharing ON — and only when it was someone else who shared it with them.
  // Re-notifying on every subsequent rating press would make one shared meal
  // a stream of DMs, which is how a useful nudge becomes muted.
  const notify =
    verb === "share" && state.shared && participantId !== SECOND_CONSUMER_ID
      ? buildPartnerNotify(state, payload.user?.name)
      : null;

  // Block Kit interactivity does NOT replace the message from the HTTP response
  // body — that is legacy attachment-style behaviour, and assuming it meant the
  // splits changed while the card kept showing stale state, so a working button
  // looked dead and got pressed twice. The update has to go to `response_url`,
  // which needs no token, so the app can post it directly.
  if (refused) {
    await updateMessage(payload.response_url, {
      response_type: "ephemeral",
      replace_original: false,
      text: refused,
    });
  }

  await updateMessage(payload.response_url, {
    replace_original: true,
    blocks: nudgeBlocks(state),
    // Notification text for clients that cannot render blocks.
    text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
  });

  // The blocks are echoed for callers that want the rendered card without
  // pressing anything — the replay tooling posts them with chat.postMessage, so
  // a card is never hand-written with a guessed `shared` state again. Slack
  // ignores the body for block_actions, which is the whole reason the real
  // update goes to response_url above.
  return NextResponse.json({
    ...(notify ? { notify } : {}),
    blocks: nudgeBlocks(state),
    text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
  });
}

/**
 * Replace the card in place.
 *
 * `response_url` is a signed, single-use-ish Slack URL carried in the
 * interaction payload; it needs no bot token, which is why this can live in the
 * app rather than being handed back to n8n. Valid for 30 minutes and 5 uses —
 * ample for a button press, and not something to cache.
 *
 * Failures are swallowed deliberately. The write already succeeded; throwing
 * here would turn a cosmetic staleness into a 500 that Slack shows the user as
 * a failed action, implying nothing happened when in fact it did.
 */
async function updateMessage(responseUrl: string | undefined, body: unknown) {
  if (!responseUrl) return;
  try {
    await fetch(responseUrl, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
  } catch {
    /* the state is correct even when the card is stale */
  }
}

/**
 * The DM payload for the other person, or null if we cannot address them.
 *
 * Returns an instruction rather than sending: the app holds no Slack bot token,
 * so n8n — which already has the credential — makes the call. Same shape as the
 * modal open.
 *
 * Null when SLACK_USER_MAP has no Slack id for the second consumer. Silent
 * rather than an error: the split is still correct and complete, and failing
 * the whole press because a DM could not be addressed would be worse than the
 * missing nudge.
 */
function buildPartnerNotify(
  state: Awaited<ReturnType<typeof nudgeState>>,
  sharerName?: string
) {
  if (!state) return null;
  const slackUser = slackUserForParticipant(SECOND_CONSUMER_ID);
  if (!slackUser) return null;
  return {
    user: slackUser,
    text: `${state.merchant} — shared with you 50/50`,
    blocks: partnerNudgeBlocks(state, sharerName || "It was"),
  };
}

/** The modal view, pre-filled with whatever this person already said. */
async function buildDetailsModal(transactionId: number, participantId: number) {
  const row = await queryRow<{
    merchant: string | null;
    line_items: { description?: string }[] | null;
  }>(
    `SELECT merchant_normalized AS merchant, line_items
       FROM expense_metadata
      WHERE transaction_id = $1 OR matched_transaction_id = $1
      LIMIT 1`,
    [transactionId]
  );
  if (!row) return null;

  const existing = await queryRow<{ note: string | null; item_verdicts: ItemOpinion[] }>(
    `SELECT note, item_verdicts FROM order_reviews
      WHERE transaction_id = $1 AND participant_id = $2`,
    [transactionId, participantId]
  );

  const items = (row.line_items ?? [])
    .map((i) => (i?.description ?? "").trim())
    .filter(Boolean);

  return detailsModal(
    transactionId,
    participantId,
    row.merchant ?? "Order",
    items,
    { note: existing?.note ?? null, itemVerdicts: existing?.item_verdicts ?? [] }
  );
}

/**
 * The modal came back. Save the note and the per-item verdicts.
 *
 * `response_action: "clear"` closes it. Returning a plain 200 with no body
 * leaves the modal open with a spinner, which reads as a hang.
 *
 * The rating is NOT touched here — it lives on the card, and a modal that
 * silently reset it would undo a decision the user did not revisit.
 */
async function handleModalSubmit(payload: {
  view: { private_metadata: string; state: { values: Record<string, Record<string, { value?: string; selected_option?: { value: string } }>> } };
}) {
  const meta = JSON.parse(payload.view.private_metadata ?? "{}");
  const transactionId = Number(meta.t);
  const participantId = Number(meta.p);
  const items: string[] = Array.isArray(meta.i) ? meta.i : [];
  if (!Number.isInteger(transactionId) || !Number.isInteger(participantId)) {
    return NextResponse.json({ response_action: "clear" });
  }

  const values = payload.view.state.values ?? {};
  const note = values.note?.value?.value?.trim() || null;

  // Slack returns block ids and values, never the labels, so the item text is
  // recovered from private_metadata by index.
  const itemVerdicts: ItemOpinion[] = [];
  items.forEach((item, i) => {
    const picked = values[`item_${i}`]?.verdict?.selected_option?.value;
    if (picked === "loved" || picked === "never") {
      itemVerdicts.push({ item, verdict: picked });
    }
  });

  await queryRaw(
    `INSERT INTO order_reviews (transaction_id, participant_id, note, item_verdicts)
     VALUES ($1, $2, $3, $4::jsonb)
     ON CONFLICT (transaction_id, participant_id) DO UPDATE
       SET note = EXCLUDED.note,
           item_verdicts = EXCLUDED.item_verdicts,
           updated_at = now()`,
    [transactionId, participantId, note, JSON.stringify(itemVerdicts)]
  );

  return NextResponse.json({ response_action: "clear" });
}

/**
 * Share or unshare, as a real 50/50 split.
 *
 * The split IS the record that an order was shared, so there is no separate
 * flag to keep in step. Safe to clear on an ingested order because such a row
 * is post-cutover by construction — the DB CHECK forbids credits orders before
 * 2026-01-09 — so there is no settled historical obligation to lose.
 */
async function toggleShare(transactionId: number): Promise<string | null> {
  const existing = await queryRaw<{ participant_id: number; share_percent: string }>(
    `SELECT participant_id, share_percent FROM transaction_splits WHERE transaction_id = $1`,
    [transactionId]
  );

  // Refuse to touch an arrangement this button cannot express. Splits are made
  // by hand here, so a third participant or an uneven share is deliberate — and
  // a one-tap button that silently flattened it would destroy a decision made
  // with more care than the tap that undid it.
  const foreign = existing.filter(
    (e) => e.participant_id !== SECOND_CONSUMER_ID && e.participant_id !== OWNER_PARTICIPANT_ID
  );
  const uneven = existing.some(
    (e) => e.participant_id === SECOND_CONSUMER_ID && Number(e.share_percent) !== 50
  );
  if (foreign.length || uneven) {
    // Say so. Returning silently left the card unchanged, which reads exactly
    // like a broken button — and a button that looks broken gets pressed again.
    return foreign.length
      ? "This one is split with someone else, so I left it alone. Change it in the app."
      : "This one is not an even 50/50, so I left it alone. Change it in the app.";
  }

  if (existing.some((e) => e.participant_id === SECOND_CONSUMER_ID)) {
    await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [
      transactionId,
    ]);
    return null;
  }
  await queryRaw(
    `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
     VALUES ($1, $2, 50)
     ON CONFLICT (transaction_id, participant_id)
       DO UPDATE SET share_percent = 50`,
    [transactionId, SECOND_CONSUMER_ID]
  );
  // Both halves, not just theirs. The button means "50/50", and a lone row for
  // the other person renders as a 50% share against a blank. The payer's half
  // comes from completeSplit rather than a second hardcoded insert, so it lands
  // on whoever actually owns the row instead of assuming that is me.
  await completeSplit(transactionId);
  return null;
}

async function setRating(transactionId: number, participantId: number, rating: Rating) {
  await queryRaw(
    `INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (transaction_id, participant_id) DO UPDATE
       SET rating = EXCLUDED.rating,
           order_again = EXCLUDED.order_again,
           updated_at = now()`,
    // "bad" and "never" both mean no; only "never" warns on a future order.
    [transactionId, participantId, rating, rating !== "never" && rating !== "bad"]
  );
}

/** Everything the refreshed message needs, read back after the write. */
async function nudgeState(transactionId: number) {
  const row = await queryRow<{
    merchant: string | null;
    currency: string | null;
    amount: string;
  }>(
    `SELECT em.merchant_normalized AS merchant,
            COALESCE(em.currency, 'AUD') AS currency,
            t.amount
       FROM transactions t
       LEFT JOIN expense_metadata em
         ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
      WHERE t.id = $1`,
    [transactionId]
  );
  if (!row) return null;

  const [splits, ratings, verdict] = await Promise.all([
    queryRaw<{ participant_id: number }>(
      `SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`,
      [transactionId]
    ),
    queryRaw<{ name: string; rating: Rating }>(
      `SELECT p.name, r.rating FROM order_reviews r
         JOIN participants p ON p.id = r.participant_id
        WHERE r.transaction_id = $1 AND r.rating IS NOT NULL
        ORDER BY r.participant_id`,
      [transactionId]
    ),
    merchantVerdict(row.merchant, transactionId),
  ]);

  return {
    transactionId,
    merchant: row.merchant ?? "Unknown merchant",
    currency: row.currency ?? "AUD",
    total: Number(row.amount),
    shared: splits.some((s) => s.participant_id === SECOND_CONSUMER_ID),
    ratings,
    warn: verdict?.warn ?? false,
    warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
  };
}
