import Link from "next/link";

import { db } from "@/db/client";
import { sql } from "drizzle-orm";
import {
  torrentStates,
  transferInfo,
  formatBytes,
  formatSpeed,
  formatEta,
  type TorrentState,
} from "@/lib/qbittorrent";
import { pauseAction, resumeAction, removeAction, blocklistAction, blockGroupAction } from "./actions";

export const dynamic = "force-dynamic";

// What qBittorrent shows is a torrent name. What this page shows is what the
// torrent is FOR -- which title, which episode, why this release was chosen and
// where it ended up. That join is the reason this exists rather than an iframe
// of qBittorrent's own UI, which structurally cannot know any of it.
type GrabRow = {
  id: string;
  info_hash: string;
  release_title: string;
  status: string;
  status_detail: string | null;
  progress: number | null;
  size_bytes: string | null;
  indexer_name: string;
  origin: string | null;
  score: number | null;
  score_reasons: string[] | null;
  source: string;
  imported_path: string | null;
  imported_at: Date | null;
  seed_released_at: Date | null;
  created_at: Date;
  title: string;
  media_type: string;
  season_number: number | null;
  episode_number: number | null;
  file_count: number;
};

const STATUS_STYLE: Record<string, string> = {
  queued: "border-admin-line text-admin-muted",
  downloading: "border-admin-accent text-admin-accent",
  completed: "border-admin-accent text-admin-accent",
  importing: "border-admin-accent text-admin-accent",
  imported: "border-admin-good text-admin-good",
  failed: "border-admin-warn text-admin-warn",
  orphaned: "border-admin-warn text-admin-warn",
};

function episodeLabel(row: GrabRow) {
  if (row.season_number === null) return row.media_type === "movie" ? "Movie" : "Series";
  const season = String(row.season_number).padStart(2, "0");
  if (row.episode_number === null) return `Season ${season}`;
  return `S${season}E${String(row.episode_number).padStart(2, "0")}`;
}

// The group is the last dash-delimited token of a scene name. Extracting it
// here is what makes a one-click group ban possible from this page.
function releaseGroup(title: string): string | null {
  return title.match(/-([A-Za-z0-9]+)$/)?.[1] ?? null;
}

// The page answers two questions that do not belong in one list: what is still
// coming, and what is still seeding. Sorted together by date they interleave,
// and the in-flight work -- the only part anyone can act on -- ends up buried.
//
// Every view but `done` hides grabs whose seed has been released. That is the
// end of a grab's life: the file is in the library and qBittorrent holds
// nothing, so the row is a record, not a job. They stay reachable under Done
// rather than being deleted.
const IN_FLIGHT = sql`g.status in ('queued','downloading','completed','importing')`;

const VIEWS = {
  active: {
    label: "Downloading",
    blurb: "queued, downloading, or waiting to be imported",
    where: IN_FLIGHT,
    // Nearly-finished first. Percentage is what makes one of these worth
    // looking at, and a date sort hides it behind whatever was grabbed last.
    order: sql`coalesce(g.progress, 0) desc, g.created_at desc`,
    empty: "Nothing is downloading. Searches from the Inventory page appear here.",
  },
  seeding: {
    label: "Seeding",
    blurb: "imported, still seeding, still holding a qBittorrent slot",
    where: sql`g.status = 'imported' and g.seed_released_at is null`,
    order: sql`g.imported_at desc nulls last`,
    empty: "Nothing is seeding. Imports release their seed as soon as the copy lands.",
  },
  problems: {
    label: "Problems",
    blurb: "failed or orphaned, and not yet cleared",
    where: sql`g.status in ('failed','orphaned') and g.seed_released_at is null`,
    order: sql`g.updated_at desc`,
    empty: "Nothing has failed.",
  },
  done: {
    label: "Done",
    blurb: "seed released — finished, kept for the record",
    where: sql`g.seed_released_at is not null`,
    order: sql`g.seed_released_at desc`,
    empty: "Nothing has finished yet.",
  },
} as const;

type ViewKey = keyof typeof VIEWS;

function isViewKey(value: unknown): value is ViewKey {
  return typeof value === "string" && value in VIEWS;
}

type CountRow = {
  active: number;
  seeding: number;
  problems: number;
  done: number;
  seeding_bytes: string | null;
};

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

export default async function AdminDownloadsPage({ searchParams }: PageProps) {
  const resolvedSearchParams = (await searchParams) ?? {};
  const requestedView = Array.isArray(resolvedSearchParams.view)
    ? resolvedSearchParams.view[0]
    : resolvedSearchParams.view;
  const view: ViewKey = isViewKey(requestedView) ? requestedView : "active";

  const [{ rows }, { rows: countRows }] = await Promise.all([
    db.execute<GrabRow>(sql`
    select g.id, g.info_hash, g.release_title, g.status, g.status_detail, g.progress,
           g.size_bytes::text, g.indexer_name, g.origin, g.score, g.score_reasons,
           g.source, g.imported_path, g.imported_at, g.seed_released_at, g.created_at,
           mi.title, mi.media_type, g.season_number, g.episode_number,
           (select count(*)::int from grab_files gf where gf.grab_id = g.id) as file_count
      from grabs g
      join media_items mi on mi.id = g.media_item_id
     where ${VIEWS[view].where}
     order by ${VIEWS[view].order}
     limit 200`),
    // Counted from the same fragments the views filter on, so a tab can never
    // promise a number the list then fails to show.
    db.execute<CountRow>(sql`
    select count(*) filter (where ${VIEWS.active.where})::int   as active,
           count(*) filter (where ${VIEWS.seeding.where})::int  as seeding,
           count(*) filter (where ${VIEWS.problems.where})::int as problems,
           count(*) filter (where ${VIEWS.done.where})::int     as done,
           coalesce(sum(g.size_bytes) filter (where ${VIEWS.seeding.where}), 0)::text as seeding_bytes
      from grabs g`),
  ]);

  const counts: CountRow = countRows[0] ?? {
    active: 0, seeding: 0, problems: 0, done: 0, seeding_bytes: "0",
  };

  // Ask qBittorrent only about torrents we grabbed. Enumerating the client
  // would also surface hand-added torrents, which are deliberately not this
  // page's business.
  const liveHashes = rows
    .filter((row) => row.seed_released_at === null)
    .map((row) => row.info_hash);
  const [{ states, error: qbtError }, transfer] = await Promise.all([
    torrentStates(liveHashes),
    transferInfo(),
  ]);

  const reclaimable = Number(counts.seeding_bytes ?? 0);

  return (
    <div className="space-y-6">
      <header className="flex flex-wrap items-end justify-between gap-4">
        <div>
          <h1 className="font-serif text-3xl font-semibold">Downloads</h1>
          <p className="mt-1 text-sm text-admin-muted">
            Torrents Ampelos grabbed, and what each one is for. Anything added by hand in
            qBittorrent is not shown and is never touched from here.
          </p>
        </div>
        <dl className="flex gap-6 text-sm">
          <div>
            <dt className="text-xs uppercase tracking-wider text-admin-muted">Down</dt>
            <dd className="font-mono">{transfer ? formatSpeed(transfer.dlSpeed) : "—"}</dd>
          </div>
          <div>
            <dt className="text-xs uppercase tracking-wider text-admin-muted">Up</dt>
            <dd className="font-mono">{transfer ? formatSpeed(transfer.upSpeed) : "—"}</dd>
          </div>
          <div>
            <dt className="text-xs uppercase tracking-wider text-admin-muted">Active</dt>
            <dd className="font-mono">{counts.active}</dd>
          </div>
          <div>
            <dt className="text-xs uppercase tracking-wider text-admin-muted">Seeding</dt>
            <dd className="font-mono">{counts.seeding}</dd>
          </div>
          <div>
            {/* Under copy-mode imports this is real disk, not a hardlink: the
                download copy is separate data from the library copy, so every
                byte here is reclaimed by releasing the seed. */}
            <dt className="text-xs uppercase tracking-wider text-admin-muted">Held by seeds</dt>
            <dd className="font-mono">{formatBytes(reclaimable)}</dd>
          </div>
        </dl>
      </header>

      <div className="flex flex-wrap items-center gap-2">
        {(Object.keys(VIEWS) as ViewKey[]).map((key) => {
          const count =
            key === "active" ? counts.active
            : key === "seeding" ? counts.seeding
            : key === "problems" ? counts.problems
            : counts.done;
          return (
            <Link
              key={key}
              prefetch={false}
              href={key === "active" ? "/admin/downloads" : `/admin/downloads?view=${key}`}
              aria-current={key === view ? "page" : undefined}
              title={VIEWS[key].blurb}
              // The selected fill comes from aria-current in globals.css, so
              // only the unselected state needs a class here.
              className={
                "admin-nav-button px-3 py-1.5 text-xs font-medium " +
                (key === view ? "" : "text-admin-muted")
              }
            >
              {VIEWS[key].label}
              <span className="ml-2 font-mono opacity-70">{count}</span>
            </Link>
          );
        })}
      </div>

      {qbtError && (
        <p className="rounded border border-admin-warn px-4 py-3 text-sm text-admin-warn">
          qBittorrent is not reachable ({qbtError}). The records below are from the database;
          live progress and the controls will not work until it is back.
        </p>
      )}

      {rows.length === 0 ? (
        <p className="rounded border border-admin-line px-4 py-6 text-sm text-admin-muted">
          {VIEWS[view].empty}
        </p>
      ) : (
        <div className="overflow-x-auto">
          <table className="w-full min-w-[64rem] border-collapse text-sm">
            <thead>
              <tr className="border-b border-admin-line text-left text-xs uppercase tracking-wider text-admin-muted">
                <th className="py-2 pr-4">Title</th>
                <th className="py-2 pr-4">Release</th>
                <th className="py-2 pr-4">State</th>
                <th className="py-2 pr-4 text-right">Size</th>
                <th className="py-2 pr-4">Source</th>
                <th className="py-2 pr-4">Actions</th>
              </tr>
            </thead>
            <tbody>
              {rows.map((row) => {
                const live: TorrentState | undefined = states.get(row.info_hash.toLowerCase());
                const group = releaseGroup(row.release_title);
                const progress = live?.progress ?? row.progress ?? 0;
                const inFlight = ["queued", "downloading", "completed", "importing"].includes(row.status);

                return (
                  <tr key={row.id} className="border-b border-admin-line/50 align-top">
                    <td className="py-3 pr-4">
                      <div className="font-medium">{row.title}</div>
                      <div className="text-xs text-admin-muted">{episodeLabel(row)}</div>
                    </td>

                    <td className="py-3 pr-4">
                      <div className="font-mono text-xs break-all">{row.release_title}</div>
                      {row.score !== null && (
                        // Why this release, in the terms the engine actually used.
                        // An automatic choice that cannot explain itself is
                        // indistinguishable from a random one.
                        <details className="mt-1">
                          <summary className="cursor-pointer text-xs text-admin-muted">
                            score {row.score}
                          </summary>
                          <div className="mt-1 font-mono text-[11px] text-admin-muted">
                            {(row.score_reasons ?? []).join("  ")}
                          </div>
                        </details>
                      )}
                      {row.imported_path && (
                        <div className="mt-1 font-mono text-[11px] text-admin-good break-all">
                          → {row.imported_path}
                          {row.file_count > 1 && ` (+${row.file_count - 1} more)`}
                        </div>
                      )}
                      {row.status_detail && (
                        <div className="mt-1 text-[11px] text-admin-muted">{row.status_detail}</div>
                      )}
                    </td>

                    <td className="py-3 pr-4">
                      <span
                        className={`inline-block rounded border px-2 py-0.5 text-[11px] uppercase tracking-wide ${
                          STATUS_STYLE[row.status] ?? "border-admin-line text-admin-muted"
                        }`}
                      >
                        {row.status}
                      </span>
                      {inFlight && (
                        <div className="mt-1 text-xs text-admin-muted">
                          {progress}%
                          {live && !live.isComplete && (
                            <>
                              {" · "}
                              {formatSpeed(live.dlSpeed)}
                              {" · "}
                              {formatEta(live.eta)}
                            </>
                          )}
                        </div>
                      )}
                      {row.seed_released_at && (
                        <div className="mt-1 text-[11px] text-admin-muted">seed released</div>
                      )}
                      {row.status === "imported" && !row.seed_released_at && live && (
                        <div className="mt-1 text-[11px] text-admin-muted">
                          seeding · ratio {live.ratio?.toFixed(2) ?? "—"}
                        </div>
                      )}
                    </td>

                    <td className="py-3 pr-4 text-right font-mono text-xs">
                      {formatBytes(row.size_bytes ? Number(row.size_bytes) : live?.sizeBytes ?? null)}
                    </td>

                    <td className="py-3 pr-4 text-xs">
                      <div>{row.indexer_name}</div>
                      {/* Knaben aggregates other trackers, so its name is not
                          where the release actually lives. */}
                      {row.origin && row.origin !== row.indexer_name && (
                        <div className="text-admin-muted">via {row.origin}</div>
                      )}
                      <div className="text-admin-muted">{row.source}</div>
                    </td>

                    <td className="py-3 pr-4">
                      <div className="flex flex-wrap gap-1">
                        {live && !live.isComplete && (
                          <form action={live.isPaused ? resumeAction : pauseAction}>
                            <input type="hidden" name="infoHash" value={row.info_hash} />
                            <button type="submit" className="admin-nav-button px-2 py-1 text-xs">
                              {live.isPaused ? "Resume" : "Pause"}
                            </button>
                          </form>
                        )}

                        {!row.seed_released_at && (
                          <form action={removeAction}>
                            <input type="hidden" name="infoHash" value={row.info_hash} />
                            <button
                              type="submit"
                              className="admin-nav-button px-2 py-1 text-xs"
                              // The label differs before and after import
                              // because the consequence does. Imports copy
                              // rather than hardlink, so after import there
                              // are two real files and this deletes the
                              // download one; before import there is only one.
                              title={
                                row.status === "imported"
                                  ? "Stops seeding and deletes the download copy, freeing its disk. The library has its own copy."
                                  : "Deletes the partial download. Nothing has been imported yet, so this discards it."
                              }
                            >
                              {row.status === "imported" ? "Release seed" : "Cancel"}
                            </button>
                          </form>
                        )}

                        <form action={blocklistAction}>
                          <input type="hidden" name="infoHash" value={row.info_hash} />
                          <button
                            type="submit"
                            className="admin-nav-button px-2 py-1 text-xs"
                            title="Never choose this exact release again."
                          >
                            Block
                          </button>
                        </form>

                        {group && (
                          <form action={blockGroupAction}>
                            <input type="hidden" name="group" value={group} />
                            <button
                              type="submit"
                              className="admin-nav-button px-2 py-1 text-xs"
                              title={`Never choose any release from ${group} again.`}
                            >
                              Block {group}
                            </button>
                          </form>
                        )}
                      </div>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}
