"use client";

import { useCallback, useEffect, useMemo, useState } from "react";

import {
  ApiError,
  api,
  type BatchProgress,
  type BulkCompose,
  type ComposedPane,
  type SendBatchResult,
} from "@/lib/api/browser";

import Modal from "./Modal";
import { Button, ErrorNote, Loading, inputClass } from "./ui";

/**
 * Send the two invitation messages to a selection of guests (add-bulk-invitation-send).
 *
 * The whole screen exists for one moment: the exact wording both halves of the list will
 * receive, on screen, before anything is queued. So both panes are shown as editable text
 * rather than as a preview of something else, and both are shown at once — including the one
 * with no recipients, which is rendered inert rather than hidden so the modal always tells
 * the same two-sided story.
 *
 * The text is a template, not a finished message: `{guest_name}` and `{invitation_link}` are
 * substituted per recipient by the server, which is what makes 300 personal messages out of
 * one authored one. Composition is server-side for the same reason the single-guest panel's
 * is — the sentence resolves event → wedding → built-in default by locale and invitation
 * type, and a second implementation here is how this and the guest's page start disagreeing.
 */

/** How often to ask how the batch is getting on while anything is still queued (D7). */
const POLL_MS = 2000;

type Pane = ComposedPane;
type PaneKey = string;

const keyOf = (pane: { locale: string; invitation_type: string }): PaneKey =>
  `${pane.locale}:${pane.invitation_type}`;

const TYPE_LABEL: Record<string, string> = {
  single: "Single guest",
  family: "Family / multiple",
};

const LOCALE_LABEL: Record<string, string> = {
  en: "English",
  bn: "বাংলা",
};

export default function BulkInviteModal({
  eventId,
  eventTitle,
  guestIds,
  onClose,
  onSent,
}: {
  eventId: string;
  eventTitle: string;
  /** Exactly the guests the admin selected — resolved to ids before this opened (D2). */
  guestIds: string[];
  onClose: () => void;
  /** Fired once a batch has been recorded, so the list behind can refresh. */
  onSent?: () => void;
}) {
  /**
   * One batch id per modal instance, generated on open (D5).
   *
   * It is the duplicate boundary: every Send attempt from this modal carries it, so a double
   * click, a retried request or a quiet-hours confirmation resend all collapse into the same
   * batch. A new modal means a new id, which is how a deliberate second send still goes out.
   */
  const batchId = useMemo(() => crypto.randomUUID(), []);

  const [composed, setComposed] = useState<BulkCompose | null>(null);
  const [panes, setPanes] = useState<Record<PaneKey, Pane>>({});
  const [locale, setLocale] = useState<string>("");

  const [loadError, setLoadError] = useState<unknown>(null);
  const [sendError, setSendError] = useState<unknown>(null);
  /**
   * A refusal that belongs to one pane, keyed by it (D4, task 6.10).
   *
   * The API names which locale and invitation type it refused, so a mistyped placeholder is
   * shown against the box it is in rather than as a banner the admin then has to match up
   * against two editors by reading.
   */
  const [paneError, setPaneError] = useState<{ key: PaneKey; message: string } | null>(null);
  const [sending, setSending] = useState(false);
  const [recorded, setRecorded] = useState<SendBatchResult | null>(null);
  const [progress, setProgress] = useState<BatchProgress | null>(null);

  useEffect(() => {
    let cancelled = false;
    api
      .composeBulkInvitation(eventId, guestIds)
      .then((message) => {
        if (cancelled) return;
        setComposed(message);
        setPanes(Object.fromEntries(message.panes.map((pane) => [keyOf(pane), pane])));
        setLocale(message.panes[0]?.locale ?? "en");
        setLoadError(null);
      })
      .catch((err) => {
        if (!cancelled) setLoadError(err);
      });
    return () => {
      cancelled = true;
    };
    // `guestIds` is a fresh array each render; the modal is keyed on its selection by the
    // parent, so re-composing on identity changes would be a request per keystroke elsewhere.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [eventId]);

  /**
   * Poll the batch while anything is still waiting (D7).
   *
   * Polling rather than a push channel: this is one admin watching one batch for a few
   * minutes, and it means progress survives closing and reopening the modal — which a
   * socket would have to reimplement.
   */
  useEffect(() => {
    if (!recorded) return;
    let cancelled = false;
    let timer: ReturnType<typeof setTimeout>;

    const tick = async () => {
      try {
        const next = await api.batchProgress(recorded.batch_id);
        if (cancelled) return;
        setProgress(next);
        if (!next.finished) timer = setTimeout(tick, POLL_MS);
      } catch {
        // A failed poll is not a failed send. The jobs are recorded and the worker will
        // finish them; stopping the poll loses the running commentary and nothing else.
      }
    };
    void tick();

    return () => {
      cancelled = true;
      clearTimeout(timer);
    };
  }, [recorded]);

  const update = useCallback((key: PaneKey, field: keyof Pane, value: string) => {
    setPanes((current) => ({ ...current, [key]: { ...current[key], [field]: value } }));
    // Editing the box that was refused clears the refusal; leaving it up while the text
    // changes underneath would be describing a message that no longer exists.
    setPaneError((current) => (current?.key === key ? null : current));
  }, []);

  async function send(confirmedQuietHours = false) {
    setSending(true);
    setSendError(null);
    setPaneError(null);
    try {
      const result = await api.sendInvitationBatch(eventId, {
        batch_id: batchId,
        guest_ids: guestIds,
        panes: Object.values(panes).map((pane) => ({
          locale: pane.locale,
          invitation_type: pane.invitation_type,
          subject: pane.subject,
          header: pane.header,
          body: pane.body,
          footer: pane.footer,
        })),
        confirmed_quiet_hours: confirmedQuietHours,
      });
      setRecorded(result);
      onSent?.();
    } catch (err) {
      // The server owns the clock and the question; this only relays it (D9).
      if (err instanceof ApiError && err.code === "quiet_hours" && !confirmedQuietHours) {
        if (confirm(err.message)) {
          setSending(false);
          return send(true);
        }
      } else if (
        err instanceof ApiError &&
        (err.code === "bad_placeholder" || err.code === "empty_message") &&
        typeof err.context.locale === "string" &&
        typeof err.context.invitation_type === "string"
      ) {
        setPaneError({
          key: `${err.context.locale}:${err.context.invitation_type}`,
          message: err.message,
        });
        setLocale(err.context.locale);
      } else {
        setSendError(err);
      }
    } finally {
      setSending(false);
    }
  }

  const locales = useMemo(
    () => [...new Set(Object.values(panes).map((pane) => pane.locale))],
    [panes],
  );
  const shown = useMemo(
    () => Object.values(panes).filter((pane) => pane.locale === locale),
    [panes, locale],
  );
  const deliverable = composed?.deliverable ?? 0;
  // A draft event's invitation page answers 404 to every guest, so every `{invitation_link}`
  // this batch would substitute is dead. The API refuses the send; saying so here means the
  // admin learns it before writing to 300 people rather than after.
  const draft = composed !== null && !composed.event_published;

  return (
    <Modal
      title={recorded ? `Sending — ${eventTitle}` : `Send invitations — ${eventTitle}`}
      size="full"
      onClose={onClose}
      footer={
        recorded ? (
          <div className="flex items-center justify-between gap-3">
            <ProgressLine progress={progress} dryRun={recorded.dry_run} />
            <Button variant="secondary" onClick={onClose}>
              Close
            </Button>
          </div>
        ) : (
          <div className="flex flex-wrap items-center justify-between gap-3">
            <p className="text-xs text-stone-600">
              {draft
                ? `${eventTitle} is not published, so nothing can be sent yet.`
                : deliverable === 0
                  ? "There is nobody in this selection who can be emailed."
                  : `Sends ${deliverable} email${deliverable === 1 ? "" : "s"}, each with that
                     guest's own name and link.`}
            </p>
            <div className="flex items-center gap-2">
              <Button variant="secondary" onClick={onClose} disabled={sending}>
                Cancel
              </Button>
              <Button
                onClick={() => void send()}
                disabled={sending || !composed || deliverable === 0 || draft}
                title={
                  draft
                    ? "This event is not published, so its invitation links return 404"
                    : deliverable === 0
                      ? "Nobody in this selection can be emailed"
                      : undefined
                }
              >
                {sending ? "Sending…" : `Send ${deliverable} invitation${deliverable === 1 ? "" : "s"}`}
              </Button>
            </div>
          </div>
        )
      }
    >
      {loadError ? (
        <ErrorNote error={loadError} />
      ) : !composed ? (
        <Loading label="Composing…" />
      ) : recorded ? (
        <BatchOutcome recorded={recorded} progress={progress} />
      ) : (
        <div className="space-y-4">
          {draft && (
            <p role="alert" className="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-900">
              {eventTitle} is still a draft, so every invitation link in these messages shows
              “page not found”. Publish the event on the Events screen, then send.
            </p>
          )}

          <Summary composed={composed} />

          {/* Only when the selection actually spans locales (D11). In the ordinary case
              there are exactly two panes and no control here at all. */}
          {locales.length > 1 && (
            <div className="flex items-center gap-2" role="group" aria-label="Message language">
              <span className="text-xs font-medium text-stone-500">Language</span>
              {locales.map((code) => (
                <Button
                  key={code}
                  variant={code === locale ? "primary" : "secondary"}
                  onClick={() => setLocale(code)}
                  aria-pressed={code === locale}
                >
                  {LOCALE_LABEL[code] ?? code}
                </Button>
              ))}
              <span className="text-xs text-stone-500">
                Guests are sent the message in their own language.
              </span>
            </div>
          )}

          <div className="grid gap-4 lg:grid-cols-2">
            {shown.map((pane) => (
              <PaneEditor
                key={keyOf(pane)}
                pane={pane}
                placeholders={composed.placeholders}
                error={paneError?.key === keyOf(pane) ? paneError.message : null}
                onChange={(field, value) => update(keyOf(pane), field, value)}
              />
            ))}
          </div>

          <p className="text-xs text-stone-500">
            An unsubscribe line for {eventTitle} is added to every message automatically.
          </p>

          <ErrorNote error={sendError} />
        </div>
      )}
    </Modal>
  );
}

/** Who this will reach, and who it will not — stated before the button, not after (D10). */
function Summary({ composed }: { composed: BulkCompose }) {
  return (
    <div className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm">
      <p className="text-stone-800">
        <span className="font-medium">{composed.selected}</span> selected,{" "}
        <span className="font-medium">{composed.deliverable}</span> will be emailed.
      </p>
      {composed.exclusions.length > 0 && (
        <ul className="mt-1 space-y-0.5 text-xs text-amber-900">
          {composed.exclusions.map((exclusion) => (
            <li key={exclusion.code}>
              {exclusion.count} excluded — {exclusion.reason.toLowerCase()}
            </li>
          ))}
        </ul>
      )}
      {composed.missing > 0 && (
        <p className="mt-1 text-xs text-stone-500">
          {composed.missing} selected guest{composed.missing === 1 ? "" : "s"} no longer
          exist and will be skipped.
        </p>
      )}
    </div>
  );
}

/** One pane. A pane with no recipients is shown, and shown to be doing nothing. */
function PaneEditor({
  pane,
  placeholders,
  error,
  onChange,
}: {
  pane: Pane;
  placeholders: string[];
  error: string | null;
  onChange: (field: keyof Pane, value: string) => void;
}) {
  const id = keyOf(pane).replace(":", "-");
  const inert = pane.recipient_count === 0;
  const label = TYPE_LABEL[pane.invitation_type] ?? pane.invitation_type;

  return (
    <section
      aria-labelledby={`${id}-heading`}
      className={`rounded-xl border p-3 ${
        error
          ? "border-red-400 bg-white"
          : inert
            ? "border-stone-200 bg-stone-50/60"
            : "border-stone-300 bg-white"
      }`}
    >
      <header className="mb-2 flex items-baseline justify-between gap-2">
        <h3 id={`${id}-heading`} className="text-sm font-semibold text-stone-900">
          {label}
        </h3>
        <span className={`text-xs ${inert ? "text-stone-500" : "text-stone-600"}`}>
          {inert
            ? "No guests of this type selected"
            : `${pane.recipient_count} recipient${pane.recipient_count === 1 ? "" : "s"}`}
        </span>
      </header>

      <div className={`space-y-2 ${inert ? "opacity-60" : ""}`}>
        <Labelled label="Subject" htmlFor={`${id}-subject`}>
          <input
            id={`${id}-subject`}
            value={pane.subject}
            onChange={(e) => onChange("subject", e.target.value)}
            className={inputClass}
            disabled={inert}
          />
        </Labelled>
        <Labelled label="Greeting" htmlFor={`${id}-header`}>
          <input
            id={`${id}-header`}
            value={pane.header}
            onChange={(e) => onChange("header", e.target.value)}
            className={inputClass}
            disabled={inert}
          />
        </Labelled>
        <Labelled label="Message" htmlFor={`${id}-body`}>
          <textarea
            id={`${id}-body`}
            rows={7}
            value={pane.body}
            onChange={(e) => onChange("body", e.target.value)}
            className={`${inputClass} font-normal`}
            disabled={inert}
          />
        </Labelled>
        <Labelled label="Sign-off" htmlFor={`${id}-footer`}>
          <textarea
            id={`${id}-footer`}
            rows={2}
            value={pane.footer}
            onChange={(e) => onChange("footer", e.target.value)}
            className={inputClass}
            disabled={inert}
          />
        </Labelled>
      </div>

      {error && (
        <p role="alert" className="mt-2 rounded-md bg-red-50 px-2 py-1.5 text-xs text-red-800">
          {error}
        </p>
      )}

      {/* Stated next to the fields rather than in help text elsewhere: these are the two
          things in the box that are not literal, and the link one is required (D4). */}
      <p className="mt-2 text-xs text-stone-500">
        {placeholders.map((name) => `{${name}}`).join(" and ")} are replaced with each
        guest&apos;s own details. Keep {"{invitation_link}"} in the message — it is how they
        answer.
      </p>
    </section>
  );
}

/** What actually happened, never dressed up as a delivery. */
function BatchOutcome({
  recorded,
  progress,
}: {
  recorded: SendBatchResult;
  progress: BatchProgress | null;
}) {
  return (
    <div className="space-y-3">
      {recorded.already_recorded && (
        <p role="status" className="rounded-lg bg-stone-100 px-3 py-2 text-sm text-stone-700">
          This send was already recorded, so nothing was sent twice.
        </p>
      )}
      {recorded.dry_run && (
        <p role="status" className="rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-900">
          Rehearsed only — dry run is on, so no email was sent.
        </p>
      )}

      <dl className="grid grid-cols-2 gap-2 sm:grid-cols-4">
        <Stat label="Recorded" value={progress?.total ?? recorded.queued} />
        <Stat label="Sent" value={progress?.sent ?? 0} />
        <Stat label="Waiting" value={progress?.waiting ?? recorded.queued} />
        <Stat label="Not sent" value={(progress?.failed ?? 0) + (progress?.skipped ?? 0)} />
      </dl>

      {recorded.exclusions.length > 0 && (
        <ul className="space-y-0.5 text-xs text-amber-900">
          {recorded.exclusions.map((exclusion) => (
            <li key={exclusion.code}>
              {exclusion.count} were not sent to — {exclusion.reason.toLowerCase()}
            </li>
          ))}
        </ul>
      )}

      {progress && progress.problems.length > 0 && (
        <section aria-labelledby="batch-problems">
          <h3 id="batch-problems" className="mb-1 text-sm font-semibold text-stone-900">
            Did not reach
          </h3>
          <ul className="space-y-1 text-sm">
            {progress.problems.map((problem, index) => (
              <li key={`${problem.guest_name}-${index}`} className="text-stone-700">
                <span className="font-medium">{problem.guest_name}</span> — {problem.reason}
              </li>
            ))}
          </ul>
          {progress.problems_capped && (
            <p className="mt-1 text-xs text-stone-500">
              Showing the first {progress.problems.length}. The counts above are complete.
            </p>
          )}
        </section>
      )}
    </div>
  );
}

function ProgressLine({
  progress,
  dryRun,
}: {
  progress: BatchProgress | null;
  dryRun: boolean;
}) {
  if (!progress) return <span className="text-sm text-stone-500">Starting…</span>;
  if (!progress.finished) {
    return (
      <span role="status" className="text-sm text-stone-700">
        {progress.sent} of {progress.total} sent…
      </span>
    );
  }
  return (
    <span role="status" className="text-sm text-green-800">
      {dryRun ? "Rehearsal finished" : "Finished"} — {progress.sent} sent
      {progress.failed + progress.skipped > 0
        ? `, ${progress.failed + progress.skipped} not sent`
        : ""}
      .
    </span>
  );
}

function Stat({ label, value }: { label: string; value: number }) {
  return (
    <div className="rounded-lg border border-stone-200 px-3 py-2">
      <dt className="text-xs text-stone-500">{label}</dt>
      <dd className="text-lg font-semibold text-stone-900">{value}</dd>
    </div>
  );
}

function Labelled({
  label,
  htmlFor,
  children,
}: {
  label: string;
  htmlFor: string;
  children: React.ReactNode;
}) {
  return (
    <div>
      <label
        htmlFor={htmlFor}
        className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500"
      >
        {label}
      </label>
      {children}
    </div>
  );
}
