import { db } from "@/db/client";
import { classics, episodes, mediaItems, seasons, storageFiles, storageTiers } from "@/db/schema";
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
import Link from "next/link";
import { InventoryBulkActions } from "./bulk-actions";
import { InventorySearch } from "./inventory-search";
import { InventoryTable, type FileEntry, type SeasonEntry } from "./inventory-table";

export const dynamic = "force-dynamic";

type PageProps = {
  searchParams?: Promise<Record<string, string | string[] | undefined>>;
};

type InventoryKind = "movie" | "tv";

// Which tier's files decide whether a title is listed at all, and what the
// Files/Size/Quality columns describe. Defaults to live so the page keeps
// meaning what it used to mean.
const TIER_FILTERS = [
  { value: "live", label: "Live", blurb: "titles with files on the live tier" },
  { value: "backup", label: "Backup", blurb: "titles with files on the backup mirror" },
  { value: "archive", label: "Archive", blurb: "titles with files on the archive tier" },
  { value: "all", label: "All tiers", blurb: "titles with files anywhere" },
] as const;

type TierFilter = (typeof TIER_FILTERS)[number]["value"];

// Whether the TMDB link behind a title has been checked by a person.
//
// Almost every id here was derived by matching a folder name against TMDB
// search, and that has been wrong in ways nothing downstream could see. This
// filter exists so the checking can be done a few at a time rather than as one
// impossible sitting -- ampelos-agent scripts/check-tmdb-links.mjs ranks which ones deserve
// the attention first.
const LINK_FILTERS = [
  { value: "any", label: "Any", blurb: "every title" },
  { value: "unverified", label: "Unverified", blurb: "titles whose TMDB link nobody has confirmed" },
  { value: "verified", label: "Verified", blurb: "titles whose TMDB link has been confirmed" },
] as const;

type LinkFilter = (typeof LINK_FILTERS)[number]["value"];

const KIND_COPY = {
  movie: {
    label: "Movies",
    heading: "Main Movie Inventory",
    mediaType: "movie" as const,
    searchLabel: "Search movies",
    searchPlaceholder: "Search main movies",
    blurb:
      "Dense canonical movie inventory backed by live storage files. Click a row to inspect its files, or select rows for bulk policy changes.",
  },
  tv: {
    label: "Television",
    heading: "Main Television Inventory",
    mediaType: "tv_series" as const,
    searchLabel: "Search series",
    searchPlaceholder: "Search main series",
    blurb:
      "Canonical series inventory backed by live storage files and TMDB metadata. Click a row to expand its seasons and episodes; episodes with no file on any tier are highlighted.",
  },
} satisfies Record<InventoryKind, unknown>;

function singleParam(value: string | string[] | undefined) {
  return Array.isArray(value) ? value[0] : value;
}

function parseKind(value: string | string[] | undefined): InventoryKind {
  return singleParam(value) === "tv" ? "tv" : "movie";
}

function parseTier(value: string | string[] | undefined): TierFilter {
  const raw = singleParam(value);
  const match = TIER_FILTERS.find((entry) => entry.value === raw);
  return match ? match.value : "live";
}

function parseLink(value: string | string[] | undefined): LinkFilter {
  const raw = singleParam(value);
  const match = LINK_FILTERS.find((entry) => entry.value === raw);
  return match ? match.value : "any";
}

function parsePage(value: string | string[] | undefined) {
  const page = Number.parseInt(singleParam(value) ?? "1", 10);
  return Number.isInteger(page) && page > 0 ? page : 1;
}

function inventoryHref(kind: InventoryKind, query: string, page: number, tier: TierFilter, link: LinkFilter = "any") {
  const params = new URLSearchParams();
  if (kind !== "movie") params.set("kind", kind);
  if (tier !== "live") params.set("tier", tier);
  if (link !== "any") params.set("md", link);
  if (query) params.set("q", query);
  if (page > 1) params.set("page", String(page));
  const value = params.toString();
  return value ? "/admin/inventory?" + value : "/admin/inventory";
}

export default async function AdminInventoryPage({ searchParams }: PageProps) {
  const resolvedSearchParams = (await searchParams) ?? {};
  const kind = parseKind(resolvedSearchParams.kind);
  const copy = KIND_COPY[kind];
  const query = singleParam(resolvedSearchParams.q)?.trim() ?? "";
  const expandedId = singleParam(resolvedSearchParams.expanded) ?? null;
  const tier = parseTier(resolvedSearchParams.tier);
  const link = parseLink(resolvedSearchParams.md);
  const page = parsePage(resolvedSearchParams.page);
  const pageSize = 100;

  // "all" means every tier, so it contributes no predicate at all.
  const tierFilter = tier === "all" ? undefined : eq(storageTiers.tier, tier);

  // Season/episode counts come from the files themselves, so the numbers
  // describe what is actually on the selected tier.
  const rows = await db
    .select({
      id: mediaItems.id,
      title: mediaItems.title,
      year: mediaItems.year,
      overview: mediaItems.overview,
      seasonCount: sql<string>`count(distinct ${seasons.id})::text`,
      episodeCount: sql<string>`count(distinct ${episodes.id})::text`,
      fileCount: sql<string>`count(${storageFiles.id})::text`,
      totalBytes: sql<string>`coalesce(sum(${storageFiles.sizeBytes}), 0)::text`,
      qualities: sql<string>`coalesce(string_agg(distinct ${storageFiles.quality}, ', ' order by ${storageFiles.quality}) filter (where ${storageFiles.quality} is not null), '')`,
      codecs: sql<string>`coalesce(string_agg(distinct ${storageFiles.codec}, ', ' order by ${storageFiles.codec}) filter (where ${storageFiles.codec} is not null), '')`,
      // Quality/codec are only trustworthy once ffprobe has read the file. Where
      // nothing is probed they are filename guesses, and this library's
      // filenames are demonstrably wrong — so say so rather than showing a
      // guess next to a measurement as if they were peers.
      probedFiles: sql<number>`count(*) filter (where ${storageFiles.probedAt} is not null)::int`,
      isClassic: sql<boolean>`bool_or(${classics.id} is not null)`,
      // Scalar subqueries rather than another join: external_ids has no unique
      // constraint per (item, source), and a second tmdb row would silently
      // double every count in this query.
      tmdbId: sql<string | null>`(select ei.external_id from external_ids ei
                                   where ei.media_item_id = ${mediaItems.id} and ei.source = 'tmdb'
                                   limit 1)`,
      tmdbVerified: sql<boolean>`exists (select 1 from external_ids ei
                                          where ei.media_item_id = ${mediaItems.id}
                                            and ei.source = 'tmdb'
                                            and ei.verified_at is not null)`,
    })
    .from(mediaItems)
    .innerJoin(storageFiles, eq(storageFiles.mediaItemId, mediaItems.id))
    .innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
    .leftJoin(episodes, eq(episodes.id, storageFiles.episodeId))
    .leftJoin(seasons, eq(seasons.id, episodes.seasonId))
    .leftJoin(classics, eq(classics.mediaItemId, mediaItems.id))
    .where(
      and(
        eq(mediaItems.mediaType, copy.mediaType),
        tierFilter,
        isNull(storageFiles.missingAt),
        query ? ilike(mediaItems.title, "%" + query + "%") : undefined,
        // A title with no tmdb row at all counts as unverified: nobody has
        // confirmed anything about it, which is exactly what the filter asks.
        link === "unverified"
          ? sql`not exists (select 1 from external_ids ei
                             where ei.media_item_id = ${mediaItems.id}
                               and ei.source = 'tmdb'
                               and ei.verified_at is not null)`
          : link === "verified"
            ? sql`exists (select 1 from external_ids ei
                           where ei.media_item_id = ${mediaItems.id}
                             and ei.source = 'tmdb'
                             and ei.verified_at is not null)`
            : undefined,
      ),
    )
    .groupBy(mediaItems.id)
    .orderBy(desc(sql`bool_or(${classics.id} is not null)`), mediaItems.title)
    .limit(pageSize)
    .offset((page - 1) * pageSize);

  // Deliberately NOT tier-filtered: the point of this column is to show where a
  // title lives across every tier even while the table is filtered to one of
  // them, so "on archive but not live" is visible at a glance.
  const visibleIds = rows.map((row) => row.id);
  const tierCountRows = visibleIds.length
    ? await db
        .select({
          mediaItemId: storageFiles.mediaItemId,
          tier: storageTiers.tier,
          files: sql<number>`count(*)::int`,
        })
        .from(storageFiles)
        .innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
        .where(and(inArray(storageFiles.mediaItemId, visibleIds), isNull(storageFiles.missingAt)))
        .groupBy(storageFiles.mediaItemId, storageTiers.tier)
    : [];

  const tierCounts = new Map<string, { tier: string; files: number }[]>();
  for (const row of tierCountRows) {
    if (!row.mediaItemId) continue;
    const list = tierCounts.get(row.mediaItemId) ?? [];
    list.push({ tier: row.tier, files: row.files });
    tierCounts.set(row.mediaItemId, list);
  }

  const tierOrder = ["live", "backup", "archive"];
  const tableRows = rows.map((row) => ({
    ...row,
    tiers: (tierCounts.get(row.id) ?? []).sort(
      (a, b) => tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier),
    ),
  }));

  const expandedItem = expandedId ? rows.find((row) => row.id === expandedId) : null;

  // Television expansion lists every episode TMDB knows about, not just the
  // ones with files, so gaps in the collection are visible.
  let seasonTree: SeasonEntry[] = [];
  let movieFiles: FileEntry[] = [];

  if (expandedItem && kind === "tv") {
    const treeRows = await db
      .select({
        seasonId: seasons.id,
        seasonNumber: seasons.seasonNumber,
        episodeId: episodes.id,
        episodeNumber: episodes.episodeNumber,
        episodeTitle: episodes.title,
        airDate: episodes.airDate,
        fileId: storageFiles.id,
        relativePath: storageFiles.relativePath,
        sizeBytes: storageFiles.sizeBytes,
        quality: storageFiles.quality,
        codec: storageFiles.codec,
        width: storageFiles.width,
        height: storageFiles.height,
        edition: storageFiles.edition,
        replaceRequestedAt: storageFiles.replaceRequestedAt,
        probedAt: storageFiles.probedAt,
        tier: storageTiers.tier,
      })
      .from(seasons)
      .innerJoin(episodes, eq(episodes.seasonId, seasons.id))
      .leftJoin(storageFiles, and(eq(storageFiles.episodeId, episodes.id), isNull(storageFiles.missingAt)))
      .leftJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
      .where(eq(seasons.seriesId, expandedItem.id))
      .orderBy(desc(seasons.seasonNumber), asc(episodes.episodeNumber), asc(storageFiles.relativePath));

    // Backup coverage is judged per file: same relative path AND same byte
    // size. A path that exists at a different size is a stale mirror, which is
    // worth surfacing separately from "not backed up at all".
    const backupRows = await db
      .select({ relativePath: storageFiles.relativePath, sizeBytes: storageFiles.sizeBytes })
      .from(storageFiles)
      .innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
      .where(
        and(
          eq(storageTiers.tier, "backup"),
          isNull(storageFiles.missingAt),
          eq(storageFiles.mediaItemId, expandedItem.id),
        ),
      );

    const backupIndex = new Map(backupRows.map((row) => [row.relativePath, String(row.sizeBytes ?? "")]));

    const seasonIndex = new Map<string, SeasonEntry>();
    const episodeIndex = new Map<string, SeasonEntry["episodes"][number]>();

    for (const row of treeRows) {
      let season = seasonIndex.get(row.seasonId);
      if (!season) {
        season = { id: row.seasonId, number: row.seasonNumber, episodes: [] };
        seasonIndex.set(row.seasonId, season);
        seasonTree.push(season);
      }

      let episode = episodeIndex.get(row.episodeId);
      if (!episode) {
        episode = {
          id: row.episodeId,
          number: row.episodeNumber,
          title: row.episodeTitle,
          airDate: row.airDate,
          files: [],
        };
        episodeIndex.set(row.episodeId, episode);
        season.episodes.push(episode);
      }

      // Backup is a mirror of live, not a separate copy anyone would ever play.
      // Listing it duplicated every episode with a byte-identical row, and
      // because backup is unprobed its quality/codec are filename guesses — so
      // the two rows disagreed on files that are the same bytes. Report backup
      // as coverage on the live file instead of as a file of its own.
      if (row.fileId && row.tier !== "backup") {
        const backupSize = backupIndex.get(row.relativePath ?? "");
        episode.files.push({
          id: row.fileId,
          relativePath: row.relativePath ?? "",
          // bigint is not serialisable across the server/client boundary.
          sizeBytes: Number(row.sizeBytes ?? 0),
          quality: row.quality,
          codec: row.codec,
          width: row.width,
          height: row.height,
          edition: row.edition,
          tier: row.tier,
          probed: row.probedAt !== null,
          replaceRequested: row.replaceRequestedAt !== null,
          // Only live is mirrored to backup; archive is itself the long-term
          // copy, so "not backed up" is not a finding there.
          backup:
            row.tier !== "live"
              ? "n/a"
              : backupSize === undefined
                ? "missing"
                : backupSize === String(row.sizeBytes ?? "")
                  ? "ok"
                  : "stale",
        });
      }
    }

    // Newest season first, Specials last.
    seasonTree = seasonTree.sort((a, b) => {
      if (a.number === 0) return 1;
      if (b.number === 0) return -1;
      return b.number - a.number;
    });
  }

  if (expandedItem && kind === "movie") {
    const fileRows = await db
      .select({
        id: storageFiles.id,
        relativePath: storageFiles.relativePath,
        sizeBytes: storageFiles.sizeBytes,
        quality: storageFiles.quality,
        codec: storageFiles.codec,
        width: storageFiles.width,
        height: storageFiles.height,
        edition: storageFiles.edition,
        replaceRequestedAt: storageFiles.replaceRequestedAt,
        probedAt: storageFiles.probedAt,
        tier: storageTiers.tier,
      })
      .from(storageFiles)
      .innerJoin(storageTiers, eq(storageTiers.id, storageFiles.tierId))
      .where(
        and(
          tierFilter,
          isNull(storageFiles.missingAt),
          eq(storageFiles.mediaItemId, expandedItem.id),
        ),
      )
      .orderBy(asc(storageTiers.tier), asc(storageFiles.relativePath));

    movieFiles = fileRows.map((row) => ({
      id: row.id,
      relativePath: row.relativePath,
      sizeBytes: Number(row.sizeBytes ?? 0),
      quality: row.quality,
      codec: row.codec,
      width: row.width,
      height: row.height,
      edition: row.edition,
      tier: row.tier,
      probed: row.probedAt !== null,
      replaceRequested: row.replaceRequestedAt !== null,
      backup: "n/a" as const,
    }));
  }

  return (
    <div className="space-y-6">
      <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
        <div>
          <p className="text-xs font-semibold uppercase tracking-[0.32em] text-admin-accent">Inventory</p>
          <h1 className="mt-2 font-serif text-3xl font-semibold">{copy.heading}</h1>
          <p className="mt-2 max-w-3xl text-sm text-admin-muted">{copy.blurb}</p>
        </div>
        <Link href="/admin/storage" className="admin-nav-button px-3 py-2 text-sm font-medium">Storage tiers</Link>
      </div>

      <div className="flex flex-wrap items-center gap-4">
        <div className="flex flex-wrap gap-2">
          {(Object.keys(KIND_COPY) as InventoryKind[]).map((value) => (
            <Link
              key={value}
              prefetch={false}
              href={inventoryHref(value, query, 1, tier, link)}
              aria-current={value === kind ? "page" : undefined}
              // The selected fill comes from aria-current in globals.css, so
              // only the unselected state needs a class here.
              className={
                "admin-nav-button px-4 py-2 text-sm font-medium " +
                (value === kind ? "" : "text-admin-muted")
              }
            >
              {KIND_COPY[value].label}
            </Link>
          ))}
        </div>

        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs uppercase tracking-[0.18em] text-admin-muted">Tier</span>
          {TIER_FILTERS.map((entry) => (
            <Link
              key={entry.value}
              prefetch={false}
              href={inventoryHref(kind, query, 1, entry.value, link)}
              aria-current={entry.value === tier ? "true" : undefined}
              title={"Show " + entry.blurb}
              className={
                "admin-nav-button px-3 py-1.5 text-xs font-medium " +
                (entry.value === tier ? "" : "text-admin-muted")
              }
            >
              {entry.label}
            </Link>
          ))}
        </div>

        <div className="flex flex-wrap items-center gap-2">
          <span className="text-xs uppercase tracking-[0.18em] text-admin-muted">TMDB link</span>
          {LINK_FILTERS.map((entry) => (
            <Link
              key={entry.value}
              prefetch={false}
              href={inventoryHref(kind, query, 1, tier, entry.value)}
              aria-current={entry.value === link ? "true" : undefined}
              title={"Show " + entry.blurb}
              className={
                "admin-nav-button px-3 py-1.5 text-xs font-medium " +
                (entry.value === link ? "" : "text-admin-muted")
              }
            >
              {entry.label}
            </Link>
          ))}
        </div>
      </div>

      <div className="grid gap-4 xl:grid-cols-[1fr_auto]">
        <InventorySearch
          key={kind + ":" + tier}
          kind={kind}
          tier={tier}
          initialQuery={query}
          label={copy.searchLabel}
          placeholder={copy.searchPlaceholder}
        />
        <InventoryBulkActions />
      </div>

      <section className="admin-panel overflow-hidden">
        <div className="flex flex-col gap-2 border-b border-admin-line px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
          <div>
            <h2 className="font-serif text-xl font-semibold">{copy.label}</h2>
            <p className="mt-1 text-sm text-admin-muted">
              Showing page {page} with {rows.length} records — {TIER_FILTERS.find((entry) => entry.value === tier)?.blurb}.
            </p>
          </div>
          <div className="flex items-center gap-2 text-xs text-admin-muted">
            {page > 1 ? <Link prefetch={false} href={inventoryHref(kind, query, page - 1, tier, link)} className="text-admin-accent">Previous</Link> : <span>Previous</span>}
            <span>Page {page}</span>
            {rows.length === pageSize ? <Link prefetch={false} href={inventoryHref(kind, query, page + 1, tier, link)} className="text-admin-accent">Next</Link> : <span>Next</span>}
          </div>
        </div>

        <InventoryTable
          kind={kind}
          rows={tableRows}
          query={query}
          page={page}
          tier={tier}
          link={link}
          expandedId={expandedItem?.id ?? null}
          seasons={seasonTree}
          movieFiles={movieFiles}
        />
      </section>
    </div>
  );
}
