import { db } from "@/db/client";
import { externalIds, mediaItems } from "@/db/schema";
import { and, asc, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
import Image from "next/image";
import Link from "next/link";
import { confirmTmdbLink, rejectTmdbLink, resetTmdbLink } from "./actions";

export const dynamic = "force-dynamic";

const PAGE_SIZE = 24;

const STATES = [
  { value: "needs", label: "Needs review", blurb: "nobody has looked at these yet" },
  { value: "rejected", label: "Rejected", blurb: "someone said the link is wrong; these need resolving" },
  { value: "confirmed", label: "Confirmed", blurb: "a person checked the poster against the files" },
] as const;

type ReviewState = (typeof STATES)[number]["value"];

const KINDS = [
  { value: "all", label: "Everything" },
  { value: "movie", label: "Films" },
  { value: "tv_series", label: "Series" },
] as const;

type Kind = (typeof KINDS)[number]["value"];

/**
 * Why the evidence could not settle this link.
 *
 * The same three facts verify-tmdb-links.mjs tests, expressed as filters, so a
 * reviewer can spend their attention where judgement is actually worth
 * something. "The name disagrees" is 105 real decisions; "TMDB publishes no
 * runtime" is three hundred titles where the third fact does not exist and
 * clicking through them means re-checking by eye what the machine already
 * checked better.
 */
const FAULTS = [
  { value: "any", label: "Any fault" },
  { value: "name", label: "Name disagrees" },
  { value: "runtime", label: "Runtime disagrees" },
  { value: "unmeasurable", label: "Nothing to measure" },
] as const;

type Fault = (typeof FAULTS)[number]["value"];

function parseState(raw: string | undefined): ReviewState {
  return STATES.find((entry) => entry.value === raw)?.value ?? "needs";
}

function parseKind(raw: string | undefined): Kind {
  return KINDS.find((entry) => entry.value === raw)?.value ?? "all";
}

function parseFault(raw: string | undefined): Fault {
  return FAULTS.find((entry) => entry.value === raw)?.value ?? "any";
}

function parsePage(raw: string | undefined) {
  const value = Number.parseInt(raw ?? "1", 10);
  return Number.isFinite(value) && value > 0 ? value : 1;
}

function href(params: { state: ReviewState; kind: Kind; fault: Fault; page?: number }) {
  const query = new URLSearchParams();
  if (params.state !== "needs") query.set("state", params.state);
  if (params.kind !== "all") query.set("kind", params.kind);
  if (params.fault !== "any") query.set("fault", params.fault);
  if (params.page && params.page > 1) query.set("page", String(params.page));
  const search = query.toString();
  return search ? `/admin/verify?${search}` : "/admin/verify";
}

/**
 * The condition each review state stands for.
 *
 * Unreviewed is the ABSENCE of both marks rather than a state of its own, so a
 * link that has never been looked at and one whose verdict was undone are the
 * same thing -- which is what a reviewer means by "back in the queue".
 */
function stateFilter(state: ReviewState) {
  if (state === "confirmed") return isNotNull(externalIds.verifiedAt);
  if (state === "rejected") return isNotNull(externalIds.rejectedAt);
  return and(isNull(externalIds.verifiedAt), isNull(externalIds.rejectedAt));
}

export default async function AdminVerifyPage({
  searchParams,
}: {
  searchParams: Promise<{ state?: string; kind?: string; fault?: string; page?: string }>;
}) {
  const params = await searchParams;
  const state = parseState(params.state);
  const kind = parseKind(params.kind);
  const fault = parseFault(params.fault);
  const page = parsePage(params.page);

  const kindFilter = kind === "all" ? undefined : eq(mediaItems.mediaType, kind);

  // How far the files disagree with the runtime TMDB claims for this title.
  // Not a verdict -- a reviewer still decides -- but it points the eye at the
  // rows worth slowing down for, which is the difference between reviewing a
  // queue and scrolling past one.
  const runtimeGap = sql<number | null>`(
    case ${mediaItems.mediaType}
      when 'movie' then (
        select round(min(abs(sf.duration_seconds / 60.0 - m.runtime_minutes)))
          from storage_files sf, movies m
         where sf.media_item_id = ${mediaItems.id} and m.id = ${mediaItems.id}
           and sf.missing_at is null and sf.duration_seconds > 0 and m.runtime_minutes > 0)
      else (
        -- The median episode length has to be aggregated in its OWN subquery.
        -- Subtracting the series' runtime from an aggregate in the same select
        -- leaves that column ungrouped, which Postgres rejects at execution --
        -- long after anything static would have caught it.
        select round(abs((
                 select percentile_cont(0.5) within group (order by sf.duration_seconds / 60.0)
                   from storage_files sf
                  where sf.media_item_id = ${mediaItems.id}
                    and sf.missing_at is null and sf.duration_seconds > 0
               ) - s.episode_runtime_minutes))
          from series s
         where s.id = ${mediaItems.id} and s.episode_runtime_minutes > 0)
    end)`;

  // Both names reduced to letters and digits before comparing, because the
  // folder is where punctuation goes to die: "Mission- Impossible", "The Man
  // from U N C L E". Those are the same film written by a filesystem.
  const nameAgrees = sql<boolean>`(
    ${externalIds.remoteTitle} is not null
    and regexp_replace(lower(${mediaItems.title}), '[^a-z0-9]', '', 'g')
      = regexp_replace(lower(${externalIds.remoteTitle}), '[^a-z0-9]', '', 'g'))`;

  const unmeasurable = sql<boolean>`(${runtimeGap} is null)`;

  const faultFilter =
    fault === "name"
      ? sql`${externalIds.remoteTitle} is not null and not ${nameAgrees}`
      : fault === "runtime"
        ? sql`${nameAgrees} and ${runtimeGap} is not null and ${runtimeGap} > 3`
        : fault === "unmeasurable"
          ? sql`${unmeasurable}`
          : undefined;

  const where = and(eq(externalIds.source, "tmdb"), stateFilter(state), kindFilter, faultFilter);

  const [rows, [totals]] = await Promise.all([
    db
      .select({
        id: mediaItems.id,
        title: mediaItems.title,
        year: mediaItems.year,
        kind: mediaItems.mediaType,
        posterPath: mediaItems.posterPath,
        tmdbId: externalIds.externalId,
        remoteTitle: externalIds.remoteTitle,
        nameAgrees,
        verifiedAt: externalIds.verifiedAt,
        rejectedAt: externalIds.rejectedAt,
        runtimeGap,
        fileCount: sql<number>`(select count(*)::int from storage_files sf
          where sf.media_item_id = ${mediaItems.id} and sf.missing_at is null)`,
        // Series carry up to a thousand files; nobody reviews a link by reading
        // all of them. Four names from the largest files say what the folder
        // holds, which is the question the poster is being checked against.
        filenames: sql<string[]>`(select coalesce(array_agg(f.filename order by f.size_bytes desc), '{}')
          from (select sf.filename, sf.size_bytes from storage_files sf
                 where sf.media_item_id = ${mediaItems.id} and sf.missing_at is null
                 order by sf.size_bytes desc limit 4) f)`,
      })
      .from(mediaItems)
      .innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
      .where(where)
      .orderBy(desc(sql`coalesce(${runtimeGap}, -1)`), asc(mediaItems.sortTitle), asc(mediaItems.title))
      .limit(PAGE_SIZE)
      .offset((page - 1) * PAGE_SIZE),
    db
      .select({
        matching: sql<number>`count(*)::int`,
        needs: sql<number>`count(*) filter (where ${externalIds.verifiedAt} is null and ${externalIds.rejectedAt} is null)::int`,
        rejected: sql<number>`count(*) filter (where ${externalIds.rejectedAt} is not null)::int`,
        confirmed: sql<number>`count(*) filter (where ${externalIds.verifiedAt} is not null)::int`,
      })
      .from(mediaItems)
      .innerJoin(externalIds, eq(externalIds.mediaItemId, mediaItems.id))
      // Scoped to the same fault, so the tallies on the state chips describe
      // the pile actually being worked rather than the whole collection.
      .where(and(eq(externalIds.source, "tmdb"), kindFilter, faultFilter)),
  ]);

  const inState =
    state === "needs" ? totals.needs : state === "rejected" ? totals.rejected : totals.confirmed;
  const pages = Math.max(1, Math.ceil(inState / PAGE_SIZE));
  const counts: Record<ReviewState, number> = {
    needs: totals.needs,
    rejected: totals.rejected,
    confirmed: totals.confirmed,
  };

  return (
    <div className="space-y-6">
      <div>
        <p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Metadata</p>
        <h1 className="mt-2 font-serif text-3xl font-semibold">Verify Links</h1>
        <p className="mt-2 max-w-3xl text-sm text-admin-muted">
          Each title below was matched to TMDB by comparing a folder name against search results, which
          has been wrong in ways nothing downstream can detect. Check that the poster and title are the
          film the files hold. A tick records that a person confirmed it; a cross sets it aside to be
          resolved. Titles the runtime disagrees with come first.
        </p>
      </div>

      <div className="flex flex-wrap items-center gap-2">
        {STATES.map((entry) => (
          <Link
            key={entry.value}
            href={href({ state: entry.value, kind, fault })}
            title={entry.blurb}
            className={
              "rounded-full border px-3 py-1 text-xs font-medium transition " +
              (entry.value === state
                ? "border-admin-accent text-admin-accent"
                : "border-admin-line text-admin-muted hover:text-admin-text")
            }
          >
            {entry.label}
            <span className="ml-2 tabular-nums opacity-70">{counts[entry.value]}</span>
          </Link>
        ))}
        <span className="mx-1 h-4 w-px bg-admin-line" aria-hidden="true" />
        {KINDS.map((entry) => (
          <Link
            key={entry.value}
            href={href({ state, kind: entry.value, fault })}
            className={
              "rounded-full border px-3 py-1 text-xs font-medium transition " +
              (entry.value === kind
                ? "border-admin-accent text-admin-accent"
                : "border-admin-line text-admin-muted hover:text-admin-text")
            }
          >
            {entry.label}
          </Link>
        ))}
        <span className="mx-1 h-4 w-px bg-admin-line" aria-hidden="true" />
        {FAULTS.map((entry) => (
          <Link
            key={entry.value}
            href={href({ state, kind, fault: entry.value })}
            className={
              "rounded-full border px-3 py-1 text-xs font-medium transition " +
              (entry.value === fault
                ? "border-admin-accent text-admin-accent"
                : "border-admin-line text-admin-muted hover:text-admin-text")
            }
          >
            {entry.label}
          </Link>
        ))}
      </div>

      {rows.length === 0 ? (
        <p className="rounded-lg border border-admin-line px-4 py-8 text-center text-sm text-admin-muted">
          {state === "needs"
            ? "Nothing left to review here."
            : state === "rejected"
              ? "No rejected links."
              : "Nothing confirmed yet."}
        </p>
      ) : (
        <ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
          {rows.map((row) => (
            <li
              key={row.id}
              className="flex gap-3 rounded-lg border border-admin-line p-3"
            >
              <div className="relative h-[138px] w-[92px] shrink-0 overflow-hidden rounded bg-admin-line/40">
                {row.posterPath ? (
                  <Image
                    src={`https://image.tmdb.org/t/p/w185${row.posterPath}`}
                    alt=""
                    fill
                    sizes="92px"
                    className="object-cover"
                  />
                ) : (
                  <span className="flex h-full items-center justify-center text-[10px] text-admin-muted">
                    no art
                  </span>
                )}
              </div>

              <div className="flex min-w-0 flex-1 flex-col">
                <p className="truncate font-medium" title={row.title}>
                  {row.title}
                </p>
                {/* TMDB's own name for the id, shown only when it differs.
                    Repeating an identical name would add a line of noise to
                    every card and bury the handful that actually clash. */}
                {row.remoteTitle && !row.nameAgrees ? (
                  <p className="truncate text-xs font-medium text-admin-warn" title={row.remoteTitle}>
                    tmdb: {row.remoteTitle}
                  </p>
                ) : null}
                <p className="text-xs text-admin-muted">
                  {row.year ?? "year unknown"} · {row.kind === "movie" ? "film" : "series"} ·{" "}
                  <a
                    href={`https://www.themoviedb.org/${row.kind === "movie" ? "movie" : "tv"}/${row.tmdbId}`}
                    target="_blank"
                    rel="noreferrer"
                    className="underline decoration-dotted underline-offset-2 hover:text-admin-text"
                  >
                    tmdb:{row.tmdbId}
                  </a>
                </p>

                {row.runtimeGap != null && row.runtimeGap > 10 ? (
                  <p className="mt-1 text-xs text-admin-warn">
                    runtime off by {row.runtimeGap}m
                  </p>
                ) : null}

                <ul className="mt-2 min-w-0 flex-1 space-y-0.5 text-[11px] leading-snug text-admin-muted">
                  {row.filenames.length === 0 ? (
                    <li className="italic">holds no files</li>
                  ) : (
                    row.filenames.map((name) => (
                      <li key={name} className="truncate" title={name}>
                        {name}
                      </li>
                    ))
                  )}
                  {row.fileCount > row.filenames.length ? (
                    <li className="opacity-70">and {row.fileCount - row.filenames.length} more</li>
                  ) : null}
                </ul>

                <div className="mt-2 flex items-center gap-2">
                  {state === "needs" ? (
                    <>
                      <form action={confirmTmdbLink}>
                        <input type="hidden" name="mediaItemId" value={row.id} />
                        <button
                          type="submit"
                          title="This is the right title"
                          className="rounded border border-admin-line px-3 py-1 text-sm font-semibold text-admin-accent transition hover:border-admin-accent"
                        >
                          ✓
                        </button>
                      </form>
                      <form action={rejectTmdbLink}>
                        <input type="hidden" name="mediaItemId" value={row.id} />
                        <button
                          type="submit"
                          title="This is not the right title — set aside to resolve"
                          className="rounded border border-admin-line px-3 py-1 text-sm font-semibold text-admin-warn transition hover:border-admin-warn"
                        >
                          ✗
                        </button>
                      </form>
                    </>
                  ) : (
                    <form action={resetTmdbLink}>
                      <input type="hidden" name="mediaItemId" value={row.id} />
                      <button
                        type="submit"
                        title="Put this back in the review queue"
                        className="rounded border border-admin-line px-3 py-1 text-xs text-admin-muted transition hover:text-admin-text"
                      >
                        {state === "confirmed" ? "Unconfirm" : "Back to queue"}
                      </button>
                    </form>
                  )}
                </div>
              </div>
            </li>
          ))}
        </ul>
      )}

      {pages > 1 ? (
        <div className="flex items-center justify-between text-xs text-admin-muted">
          <span>
            Page {Math.min(page, pages)} of {pages} · {inState} title{inState === 1 ? "" : "s"}
          </span>
          <div className="flex gap-2">
            {page > 1 ? (
              <Link href={href({ state, kind, fault, page: page - 1 })} className="underline underline-offset-2">
                Previous
              </Link>
            ) : null}
            {page < pages ? (
              <Link href={href({ state, kind, fault, page: page + 1 })} className="underline underline-offset-2">
                Next
              </Link>
            ) : null}
          </div>
        </div>
      ) : null}
    </div>
  );
}
