"use client";

// The manual search panel: what Sonarr's magnifying glass does.
//
// The design principle here is that a rejected release is information, not
// noise. When the automatic pass reports "nothing found", the useful answer is
// almost never "the internet is empty" -- it is "eleven results, and every one
// of them was a telesync" or "the only copy is 40MB, which is a fake". So this
// lists everything the fan-out saw, marks what the rules refused and why, and
// still lets a person take it anyway. They can see the reason; they can
// overrule it.

import { useState, useTransition } from "react";
import {
  runManualSearch,
  grabCandidate,
  type SearchState,
  type GrabState,
} from "./search-actions";
import type { SearchCandidate } from "@/lib/indexer";

function formatBytes(value: number | null) {
  if (value === null || !Number.isFinite(value) || value <= 0) return "—";
  const units = ["B", "KB", "MB", "GB", "TB"];
  let size = value;
  let unit = 0;
  while (size >= 1024 && unit < units.length - 1) {
    size /= 1024;
    unit += 1;
  }
  return size.toFixed(unit === 0 ? 0 : 1) + " " + units[unit];
}

// The rejection reasons are machine strings from decide.mjs. They are readable
// enough to keep as a tooltip, but the badge should say the thing in English.
function rejectionLabel(reason: string | null) {
  if (!reason) return "Rejected";
  const [kind, detail] = reason.split(":");
  switch (kind) {
    case "blocklisted": return "Blocklisted";
    case "blocked-group": return `Blocked group ${detail ?? ""}`.trim();
    case "rejected": return detail === "cam" ? "Camcorder rip" : `Rejected (${detail})`;
    case "below-min-quality": return `Below minimum quality`;
    case "too-few-seeders": return `Only ${detail} seeders`;
    case "seeders-unknown-and-required": return "Swarm size unknown";
    case "too-small": return "Suspiciously small";
    case "too-large": return "Larger than wanted";
    case "season-pack-not-wanted": return "Season pack";
    case "no-download-link": return "No usable link";
    default: return reason;
  }
}

function Candidate({
  candidate,
  searchId,
  onGrabbed,
}: {
  candidate: SearchCandidate;
  searchId: string;
  onGrabbed: (state: GrabState) => void;
}) {
  const [isPending, startTransition] = useTransition();

  function grab() {
    if (!candidate.infoHash) return;
    startTransition(async () => {
      onGrabbed(await grabCandidate({ searchId, infoHash: candidate.infoHash! }));
    });
  }

  return (
    <div className={"grid gap-2 px-3 py-2 text-xs lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]" + (candidate.accepted ? "" : " bg-admin-missing")}>
      <div className="min-w-0">
        {/* The full release name, unabbreviated and selectable. A group that
            ships broken files is identified by this string and no other. */}
        <p className="break-all font-mono text-admin-text">{candidate.title}</p>
        <p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-admin-muted">
          <span>{candidate.indexerName}</span>
          {candidate.origin ? <span>via {candidate.origin}</span> : null}
          {candidate.indexerCount > 1 ? (
            <span title="Found on more than one indexer, which makes a fake less likely" className="text-admin-good">
              ×{candidate.indexerCount} indexers
            </span>
          ) : null}
          {candidate.hasAtmos ? <span className="text-admin-accent">Atmos</span> : null}
          {candidate.isSeasonPack ? <span>season pack</span> : null}
          {!candidate.accepted ? (
            <span title={candidate.rejection ?? ""} className="font-semibold text-admin-warn">
              {rejectionLabel(candidate.rejection)}
            </span>
          ) : null}
        </p>
      </div>

      <span className="text-admin-muted">{candidate.quality ?? "unknown"}</span>
      <span className="text-admin-muted">{candidate.source ?? "—"}</span>
      <span className={candidate.seeders === null ? "text-admin-warn" : "text-admin-text"}>
        {candidate.seeders === null ? "?" : candidate.seeders}
      </span>
      <span className="text-admin-muted">{formatBytes(candidate.size)}</span>
      <span title={candidate.reasons.join("\n")} className="cursor-help font-semibold">
        {candidate.accepted ? candidate.score : "—"}
      </span>

      <span>
        <button
          type="button"
          onClick={grab}
          disabled={isPending || !candidate.infoHash}
          title={
            candidate.accepted
              ? "Send to qBittorrent"
              : "Send to qBittorrent anyway, overruling: " + (candidate.rejection ?? "")
          }
          className={
            "admin-nav-button w-full px-2 py-1 text-xs font-medium " +
            (candidate.accepted ? "" : "text-admin-warn")
          }
        >
          {isPending ? "…" : candidate.accepted ? "Grab" : "Grab anyway"}
        </button>
      </span>
    </div>
  );
}

export function ManualSearch({
  mediaItemId,
  seasonNumber = null,
  episodeNumber = null,
  label,
  compact = false,
}: {
  mediaItemId: string;
  seasonNumber?: number | null;
  episodeNumber?: number | null;
  label?: string;
  compact?: boolean;
}) {
  const [state, setState] = useState<SearchState | null>(null);
  const [notice, setNotice] = useState<GrabState | null>(null);
  const [showRejected, setShowRejected] = useState(false);
  const [isPending, startTransition] = useTransition();

  function search() {
    setNotice(null);
    startTransition(async () => {
      setState(await runManualSearch({ mediaItemId, seasonNumber, episodeNumber }));
    });
  }

  const result = state?.ok ? state.result : null;
  const accepted = result?.candidates.filter((c) => c.accepted) ?? [];
  const rejected = result?.candidates.filter((c) => !c.accepted) ?? [];
  const shown = showRejected ? [...accepted, ...rejected] : accepted;

  return (
    <div className={compact ? "" : "space-y-2"}>
      <button
        type="button"
        onClick={(event) => { event.stopPropagation(); search(); }}
        disabled={isPending}
        className="admin-nav-button px-2 py-1 text-xs font-medium"
        title={"Search indexers for " + (label ?? "this title")}
      >
        {isPending ? "Searching…" : state ? "Search again" : "Search"}
      </button>

      {state && !state.ok ? (
        <p className="mt-2 rounded-md border border-admin-warn px-3 py-2 text-xs text-admin-warn">
          {state.error}
        </p>
      ) : null}

      {notice ? (
        <p className={"mt-2 text-xs " + (notice.ok ? "text-admin-good" : "text-admin-warn")}>
          {notice.ok ? notice.message : notice.error}
        </p>
      ) : null}

      {result ? (
        <div className="mt-2 rounded-md border border-admin-line bg-admin-subpanel" onClick={(event) => event.stopPropagation()}>
          <div className="flex flex-wrap items-center justify-between gap-2 border-b border-admin-line px-3 py-2">
            <p className="text-xs text-admin-muted">
              <span className="font-semibold text-admin-text">{result.label}</span>
              {" — "}
              {result.candidates.length} results from {result.indexersQueried} indexers
              {" · "}{accepted.length} usable
              {" · "}{(result.durationMs / 1000).toFixed(1)}s
            </p>
            {rejected.length ? (
              <button
                type="button"
                onClick={() => setShowRejected((value) => !value)}
                className="admin-nav-button px-2 py-1 text-xs font-medium"
              >
                {showRejected ? "Hide" : "Show"} {rejected.length} rejected
              </button>
            ) : null}
          </div>

          {/* Why an indexer contributed nothing. Without this, a thin result
              set reads as "nothing exists" when the real answer is that EZTV
              was skipped because this series has no IMDB id recorded. */}
          {result.skipped.length || result.errors.length ? (
            <p className="border-b border-admin-line px-3 py-2 text-xs text-admin-muted">
              {result.skipped.map((s) => `${s.indexer}: ${s.reason}`).join(" · ")}
              {result.skipped.length && result.errors.length ? " · " : ""}
              {result.errors.map((e) => `${e.indexer} failed: ${e.error}`).join(" · ")}
            </p>
          ) : null}

          <div className="hidden border-b border-admin-line px-3 py-1.5 text-[10px] uppercase tracking-[0.18em] text-admin-muted lg:grid lg:grid-cols-[1fr_5rem_5rem_4rem_7rem_5rem_5rem]">
            <span>Release</span><span>Quality</span><span>Source</span>
            <span>Seed</span><span>Size</span><span>Score</span><span />
          </div>

          <div className="divide-y divide-admin-line">
            {shown.map((candidate, index) => (
              <Candidate
                key={(candidate.infoHash ?? "no-hash") + ":" + index}
                candidate={candidate}
                searchId={result.searchId}
                onGrabbed={setNotice}
              />
            ))}
          </div>

          {!shown.length ? (
            <p className="px-3 py-3 text-xs text-admin-muted">
              {rejected.length
                ? `No usable release. All ${rejected.length} results were rejected — show them to see why.`
                : "No indexer returned anything for this."}
            </p>
          ) : null}
        </div>
      ) : null}
    </div>
  );
}
