import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { toast } from 'sonner';
import {
  ArrowLeft,
  ChevronDown,
  ChevronUp,
  Download,
  Lock,
  Loader2,
  Folder,
  Zap,
  Users,
  Search,
  X,
  RefreshCw,
} from 'lucide-react';
import { Card, CardHeader, CardBody, SubCard, SubCardHeader, RowList, Pill } from './Cards';
import { UserMenu } from './UserMenu';
import {
  SLSKD_REFRESH_CHANNEL,
  basename,
  formatSize,
  formatSpeed,
  formatDuration,
  groupResponses,
  type ResultUser,
  type ResultFolder,
  type ResultFile,
  type SlskdResponse,
  type SlskdSearchSummary,
} from './shared';

// The results subpanel for one past search — loads its STORED responses (GET /searches/{id}/responses),
// never re-runs the search. Presented the way slskd does: grouped by responder (user) → folder → files,
// each user a collapsible card with its upload speed / free-slot / queue / file-count, and per-file plus
// per-folder download actions.
//
// Filtering is a way of *finding* a folder as much as of hiding files, so nothing it hides is out of
// reach: a matching folder expands to the whole album in place, and a matching peer expands to the rest
// of its folders. Download buttons always queue exactly what is on screen.
//
// Caching is stale-while-revalidate: the cached grouping paints immediately, then we always re-fetch in
// the background. Never serve the cache alone — opening a search before its first responses land used to
// freeze that empty snapshot forever. While the search is still running we also poll, so results fill in
// live; the header's refresh re-reads on demand.

const MAX_USERS = 200;
const POLL_MS = 2500;
// Safety net: a search whose completion we can't confirm shouldn't poll forever (~2 min of polling).
const MAX_POLLS = 48;
const resultsCache = new Map<string, ResultUser[]>();

export const dropCachedResults = (searchId: string) => resultsCache.delete(searchId);

const fileKey = (username: string, filename: string) => `${username}::${filename}`;

// Client-side filtering over already-loaded results (mirrors slskd's filter tokens, no re-query).
const LOSSLESS = new Set(['flac', 'wav', 'aiff', 'aif', 'ape', 'alac', 'wv']);
const EXT_PILLS = ['flac', 'mp3', 'wav', 'm4a', 'ogg'];
const BITRATE_PILLS = [256, 320];

type Filters = {
  text: string;
  freeOnly: boolean;
  hideLocked: boolean;
  exts: Set<string>;
  minBitrate: number | null;
};
const EMPTY_FILTERS: Filters = { text: '', freeOnly: false, hideLocked: false, exts: new Set(), minBitrate: null };

const filtersActive = (f: Filters) =>
  f.text.trim() !== '' || f.freeOnly || f.hideLocked || f.exts.size > 0 || f.minBitrate !== null;

// Filtering narrows *files*, but the reason to filter is usually to find a folder: you type one track
// name you know to pick your album out of 300 unrelated ones. So a filtered folder keeps a reference to
// the whole folder alongside its matching files, and can be expanded back to the full album in place —
// otherwise finding it would be the same act as hiding the rest of it.
type FolderMatch = { folder: ResultFolder; matched: ResultFile[]; matchedSize: number };
type UserMatch = { user: ResultUser; folders: FolderMatch[]; matchedCount: number };

const folderKey = (username: string, path: string) => `${username}::${path}`;
// Same reveal set, for "this peer's folders that matched nothing" — the discography around the album.
const userKey = (username: string) => `user::${username}`;

const applyFilters = (users: ResultUser[], f: Filters): UserMatch[] => {
  const terms = f.text.toLowerCase().split(/\s+/).filter(Boolean);
  const pos = terms.filter((t) => !t.startsWith('-'));
  const neg = terms
    .filter((t) => t.startsWith('-'))
    .map((t) => t.slice(1))
    .filter(Boolean);
  const fileOk = (file: ResultFile) => {
    if (f.hideLocked && file.isLocked) return false;
    if (f.exts.size && !f.exts.has(file.extension)) return false;
    if (f.minBitrate !== null && !((file.bitRate ?? 0) >= f.minBitrate || LOSSLESS.has(file.extension))) return false;
    const hay = file.filename.toLowerCase();
    if (pos.some((t) => !hay.includes(t))) return false;
    if (neg.some((t) => hay.includes(t))) return false;
    return true;
  };
  const out: UserMatch[] = [];
  for (const user of users) {
    if (f.freeOnly && !user.hasFreeUploadSlot) continue;
    const folders: FolderMatch[] = [];
    for (const folder of user.folders) {
      const matched = folder.files.filter(fileOk);
      if (matched.length) folders.push({ folder, matched, matchedSize: matched.reduce((n, x) => n + x.size, 0) });
    }
    if (folders.length) {
      out.push({ user, folders, matchedCount: folders.reduce((n, fd) => n + fd.matched.length, 0) });
    }
  }
  return out;
};

// `initial` is the history row we came from, when there is one. Opening by URL — or landing here the
// moment a search is created — has no row yet, so the summary is fetched like everything else and the
// header fills in a beat later.
type SearchResultsProps = { searchId: string; initial?: SlskdSearchSummary | null; onBack: () => void };

export const SearchResults = ({ searchId, initial, onBack }: SearchResultsProps) => {
  const client = useClient();
  const [, bumpRefresh] = usePanelChannel<number>(SLSKD_REFRESH_CHANNEL, 0);
  const cachedInit = resultsCache.get(searchId) ?? null;
  const [users, setUsers] = useState<ResultUser[] | null>(cachedInit);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [summary, setSummary] = useState<SlskdSearchSummary | null>(initial ?? null);
  const [isComplete, setIsComplete] = useState(initial?.isComplete ?? false);
  // Users start expanded; toggling collapses them.
  const [open, setOpen] = useState<Set<string>>(() => new Set(cachedInit?.map((u) => u.username)));
  const [queued, setQueued] = useState<Set<string>>(new Set());
  const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
  // Folders the reader has expanded past the filter, keyed username::path.
  const [revealed, setRevealed] = useState<Set<string>>(new Set());
  // Usernames we've already auto-expanded — lets a revalidation expand newcomers without re-opening
  // the cards the reader deliberately collapsed.
  const seenRef = useRef<Set<string>>(new Set(cachedInit?.map((u) => u.username) ?? []));
  const aliveRef = useRef(true);
  const pollsRef = useRef(0);

  const filtered = useMemo(() => (users ? applyFilters(users, filters) : null), [users, filters]);

  useEffect(() => {
    aliveRef.current = true;
    return () => {
      aliveRef.current = false;
    };
  }, []);

  const refresh = useCallback(
    async (quiet: boolean) => {
      if (!quiet) setLoading(true);
      try {
        // The summary tells us whether to keep polling; tolerate its absence rather than fail the load.
        const [responses, meta] = await Promise.all([
          client.get<SlskdResponse[]>(`/slskd/api/v0/searches/${searchId}/responses`),
          client.get<SlskdSearchSummary>(`/slskd/api/v0/searches/${searchId}`).catch(() => null),
        ]);
        if (!aliveRef.current) return;
        const grouped = groupResponses(responses);
        resultsCache.set(searchId, grouped);
        setUsers(grouped);
        setError(null);
        setOpen((prev) => {
          const next = new Set(prev);
          for (const u of grouped) {
            if (!seenRef.current.has(u.username)) {
              seenRef.current.add(u.username);
              next.add(u.username);
            }
          }
          return next;
        });
        if (meta) {
          setSummary(meta);
          setIsComplete(meta.isComplete);
        }
      } catch (err) {
        if (aliveRef.current) setError(err instanceof Error ? err.message : String(err));
      } finally {
        if (aliveRef.current) setLoading(false);
      }
    },
    // useClient() returns a fresh object each render, so depend only on the search id.
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [searchId],
  );

  useEffect(() => {
    const cached = resultsCache.get(searchId) ?? null;
    seenRef.current = new Set(cached?.map((u) => u.username) ?? []);
    pollsRef.current = 0;
    setUsers(cached);
    setOpen(new Set(cached?.map((u) => u.username)));
    setRevealed(new Set());
    setError(null);
    // Assume running until the summary says otherwise — a search opened straight after being created has
    // no row to tell us, and guessing "complete" would mean never polling for its first responses.
    setIsComplete(initial?.id === searchId ? initial.isComplete : false);
    // Quiet when something is already on screen — no spinner flash over usable results.
    refresh(Boolean(cached));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [searchId, refresh]);

  useEffect(() => {
    if (isComplete) return;
    const timer = setInterval(() => {
      pollsRef.current += 1;
      if (pollsRef.current > MAX_POLLS) {
        clearInterval(timer);
        return;
      }
      refresh(true);
    }, POLL_MS);
    return () => clearInterval(timer);
  }, [isComplete, refresh]);

  const toggle = (username: string) =>
    setOpen((prev) => {
      const next = new Set(prev);
      if (next.has(username)) next.delete(username);
      else next.add(username);
      return next;
    });

  const toggleReveal = (key: string) =>
    setRevealed((prev) => {
      const next = new Set(prev);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });

  const queue = async (username: string, files: ResultFile[]) => {
    const usable = files.filter((f) => !f.isLocked);
    if (usable.length === 0) return;
    try {
      await client.post(
        `/slskd/api/v0/transfers/downloads/${encodeURIComponent(username)}`,
        usable.map((f) => ({ filename: f.filename, size: f.size })),
      );
      setQueued((prev) => {
        const next = new Set(prev);
        usable.forEach((f) => next.add(fileKey(username, f.filename)));
        return next;
      });
      bumpRefresh(Date.now());
      toast.success(usable.length === 1 ? `Queued ${basename(usable[0]!.filename)}` : `Queued ${usable.length} files`);
    } catch (err) {
      toast.error(`Download failed: ${err instanceof Error ? err.message : String(err)}`);
    }
  };

  const toggleExt = (ext: string) =>
    setFilters((f) => {
      const exts = new Set(f.exts);
      if (exts.has(ext)) exts.delete(ext);
      else exts.add(ext);
      return { ...f, exts };
    });
  const setMinBitrate = (n: number) => setFilters((f) => ({ ...f, minBitrate: f.minBitrate === n ? null : n }));

  const shown = filtered ? filtered.slice(0, MAX_USERS) : [];
  const totalFiles = filtered?.reduce((n, u) => n + u.matchedCount, 0) ?? 0;
  const anyFilter = filtersActive(filters);

  return (
    <div className="flex h-full flex-col overflow-hidden">
      <div className="flex shrink-0 items-center gap-3 border-b bg-background/60 px-4 py-3">
        <button
          type="button"
          onClick={onBack}
          title="Back to history"
          className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground"
        >
          <ArrowLeft className="h-4 w-4" />
        </button>
        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-2">
            <span className="truncate text-sm font-semibold">{summary?.searchText ?? initial?.searchText ?? '…'}</span>
            {!isComplete && (
              <span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
                <Loader2 className="h-3 w-3 animate-spin" /> searching
              </span>
            )}
          </div>
          <div className="text-xs text-muted-foreground">
            {users === null && !error
              ? 'Loading results…'
              : `${totalFiles.toLocaleString()} files from ${(filtered?.length ?? 0).toLocaleString()} users${
                  anyFilter ? ' (filtered)' : ''
                }${filtered && filtered.length > shown.length ? ` — showing top ${shown.length}` : ''}`}
          </div>
        </div>
        <button
          type="button"
          onClick={() => refresh(false)}
          disabled={loading}
          title="Reload results"
          className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-40"
        >
          <RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
        </button>
      </div>

      {/* Filter bar — text tokens plus one-click pills, applied client-side to loaded results */}
      {users && users.length > 0 && (
        <div className="flex shrink-0 flex-col gap-2 border-b bg-muted/20 px-3 py-2.5">
          <div className="relative">
            <Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
            <input
              value={filters.text}
              onChange={(ev) => setFilters((f) => ({ ...f, text: ev.target.value }))}
              placeholder="Filter files…  (space-separated, prefix - to exclude)"
              className="h-9 w-full rounded-lg border bg-background pl-9 pr-8 text-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
            />
            {filters.text && (
              <button
                type="button"
                onClick={() => setFilters((f) => ({ ...f, text: '' }))}
                className="absolute right-2 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
              >
                <X className="h-3.5 w-3.5" />
              </button>
            )}
          </div>
          <div className="flex flex-wrap items-center gap-1.5">
            <Pill active={filters.freeOnly} onClick={() => setFilters((f) => ({ ...f, freeOnly: !f.freeOnly }))}>
              <Zap className="h-3 w-3" /> Free slots
            </Pill>
            <Pill active={filters.hideLocked} onClick={() => setFilters((f) => ({ ...f, hideLocked: !f.hideLocked }))}>
              <Lock className="h-3 w-3" /> Hide locked
            </Pill>
            <span className="mx-0.5 h-4 w-px bg-border" />
            {EXT_PILLS.map((ext) => (
              <Pill key={ext} active={filters.exts.has(ext)} onClick={() => toggleExt(ext)}>
                {ext.toUpperCase()}
              </Pill>
            ))}
            <span className="mx-0.5 h-4 w-px bg-border" />
            {BITRATE_PILLS.map((br) => (
              <Pill key={br} active={filters.minBitrate === br} onClick={() => setMinBitrate(br)}>
                ≥{br}
              </Pill>
            ))}
            {anyFilter && (
              <button
                type="button"
                onClick={() => setFilters(EMPTY_FILTERS)}
                className="ml-1 inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
              >
                <X className="h-3 w-3" /> Clear
              </button>
            )}
          </div>
        </div>
      )}

      <div className="min-h-0 flex-1 overflow-y-auto p-3">
        {error && <p className="p-1 text-sm text-red-500">Could not load results: {error}</p>}
        {!error && users === null && (
          <div className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
            <Loader2 className="h-4 w-4 animate-spin" /> Loading results…
          </div>
        )}
        {!error && users && users.length === 0 && (
          <p className="flex items-center gap-2 p-1 text-sm text-muted-foreground">
            {isComplete ? (
              'No results stored.'
            ) : (
              <>
                <Loader2 className="h-4 w-4 animate-spin" /> Waiting for responses…
              </>
            )}
          </p>
        )}
        {!error && users && users.length > 0 && filtered && filtered.length === 0 && (
          <p className="p-1 text-sm text-muted-foreground">No files match the current filters.</p>
        )}

        <div className="flex flex-col gap-2">
          {shown.map((match) => (
            <UserCard
              key={match.user.username}
              match={match}
              open={open.has(match.user.username)}
              onToggle={() => toggle(match.user.username)}
              queued={queued}
              onDownload={queue}
              revealed={revealed}
              onToggleReveal={toggleReveal}
            />
          ))}
        </div>
      </div>
    </div>
  );
};

type UserCardProps = {
  match: UserMatch;
  open: boolean;
  onToggle: () => void;
  queued: Set<string>;
  onDownload: (username: string, files: ResultFile[]) => void;
  revealed: Set<string>;
  onToggleReveal: (key: string) => void;
};

const UserCard = ({ match, open, onToggle, queued, onDownload, revealed, onToggleReveal }: UserCardProps) => {
  const { user, folders, matchedCount } = match;
  // A peer with the album usually has more of the artist, and the filter hid all of it. Expanding brings
  // the non-matching folders back, each already whole.
  const allFolders = revealed.has(userKey(user.username));
  const hiddenFolders = user.folders.length - folders.length;
  const visible: FolderMatch[] = allFolders
    ? user.folders.map(
        (folder) =>
          folders.find((fm) => fm.folder.path === folder.path) ?? {
            folder,
            matched: folder.files,
            matchedSize: folder.size,
          },
      )
    : folders;
  // Every action queues what is on screen, expanded folders included — never the hidden remainder.
  const shownFiles = visible.flatMap((fm) =>
    revealed.has(folderKey(user.username, fm.folder.path)) ? fm.folder.files : fm.matched,
  );
  const meta = (
    <>
      {user.hasFreeUploadSlot ? (
        <span className="inline-flex items-center gap-1 text-green-500">
          <Zap className="h-3 w-3" /> free
        </span>
      ) : (
        <span className="inline-flex items-center gap-1">
          <Users className="h-3 w-3" /> {user.queueLength}
        </span>
      )}
      {user.uploadSpeed > 0 && <span>{formatSpeed(user.uploadSpeed)}</span>}
      <span className="tabular-nums">
        {matchedCount < user.fileCount
          ? `${matchedCount.toLocaleString()} of ${user.fileCount.toLocaleString()} files`
          : `${user.fileCount.toLocaleString()} files`}
      </span>
    </>
  );
  return (
    <Card>
      <CardHeader open={open} onToggle={onToggle} titleMenu={<UserMenu username={user.username} />} meta={meta} />
      {open && (
        <CardBody>
          {visible.map((fm) => (
            <FolderBlock
              key={fm.folder.path}
              username={user.username}
              match={fm}
              revealed={revealed.has(folderKey(user.username, fm.folder.path))}
              onToggleReveal={() => onToggleReveal(folderKey(user.username, fm.folder.path))}
              queued={queued}
              onDownload={onDownload}
            />
          ))}
          {hiddenFolders > 0 && (
            <button
              type="button"
              onClick={() => onToggleReveal(userKey(user.username))}
              className="flex items-center justify-center gap-1.5 rounded-lg border border-dashed border-white/10 py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
            >
              {allFolders ? (
                <>
                  <ChevronUp className="h-3.5 w-3.5" /> Show only the {folders.length} matching{' '}
                  {folders.length === 1 ? 'folder' : 'folders'}
                </>
              ) : (
                <>
                  <ChevronDown className="h-3.5 w-3.5" /> Show this peer's other {hiddenFolders}{' '}
                  {hiddenFolders === 1 ? 'folder' : 'folders'}
                </>
              )}
            </button>
          )}
          {shownFiles.length > 1 && (
            <button
              type="button"
              onClick={() => onDownload(user.username, shownFiles)}
              className="flex items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.02] py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
            >
              <Download className="h-3.5 w-3.5" /> Download all ({shownFiles.filter((f) => !f.isLocked).length})
            </button>
          )}
        </CardBody>
      )}
    </Card>
  );
};

type FolderBlockProps = {
  username: string;
  match: FolderMatch;
  revealed: boolean;
  onToggleReveal: () => void;
  queued: Set<string>;
  onDownload: (username: string, files: ResultFile[]) => void;
};

const FolderBlock = ({ username, match, revealed, onToggleReveal, queued, onDownload }: FolderBlockProps) => {
  const { folder, matched, matchedSize } = match;
  const [open, setOpen] = useState(true);
  const files = revealed ? folder.files : matched;
  const hidden = folder.files.length - matched.length;
  const matchedNames = revealed && hidden > 0 ? new Set(matched.map((f) => f.filename)) : null;
  const unlocked = files.filter((f) => !f.isLocked).length;
  return (
    <SubCard>
      <SubCardHeader
        open={open}
        onToggle={() => setOpen((v) => !v)}
        icon={<Folder className="h-3.5 w-3.5 shrink-0 text-zinc-500" />}
        label={folder.label}
        title={folder.path}
        meta={`${hidden > 0 && !revealed ? `${files.length} of ${folder.files.length}` : files.length} · ${formatSize(
          revealed ? folder.size : matchedSize,
        )}`}
        action={
          files.length > 1 ? (
            <button
              type="button"
              onClick={() => onDownload(username, files)}
              title={revealed || hidden === 0 ? 'Download folder' : `Download the ${files.length} matching files`}
              className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100"
            >
              <Download className="h-3.5 w-3.5" />
            </button>
          ) : null
        }
      />
      {open && (
        <>
          {/* The point of the whole exercise: the track you filtered for has found the album, so open the
              album. Revealing also widens every download button below to the full folder. */}
          {hidden > 0 && (
            <button
              type="button"
              onClick={onToggleReveal}
              className="flex w-full items-center justify-center gap-1.5 border-b border-white/5 bg-white/[0.02] py-1.5 text-xs text-zinc-400 transition hover:bg-white/5 hover:text-zinc-100"
            >
              {revealed ? (
                <>
                  <ChevronUp className="h-3.5 w-3.5" /> Show only the {matched.length} matching
                </>
              ) : (
                <>
                  <ChevronDown className="h-3.5 w-3.5" /> Show the whole folder ({folder.files.length} files)
                </>
              )}
            </button>
          )}
          <RowList>
            {files.map((file) => {
              const isQueued = queued.has(fileKey(username, file.filename));
              const duration = formatDuration(file.length);
              // In an expanded folder, keep the file you filtered for legible among its neighbours.
              const isMatch = !matchedNames || matchedNames.has(file.filename);
              return (
                <div
                  key={file.filename}
                  className={`flex items-center gap-3 px-3 py-1.5 pl-8 text-sm ${
                    isMatch ? 'text-zinc-200' : 'text-zinc-400'
                  }`}
                >
                  <span className="flex min-w-0 flex-1 items-center gap-1.5 truncate">
                    {file.isLocked && <Lock className="h-3 w-3 shrink-0 text-zinc-500" />}
                    <span className={`truncate ${isMatch && matchedNames ? 'font-medium text-zinc-100' : ''}`}>
                      {file.name}
                    </span>
                  </span>
                  <div className="flex shrink-0 items-center gap-3 text-xs tabular-nums text-zinc-500">
                    {file.bitRate ? <span>{file.bitRate} kbps</span> : null}
                    {duration && <span>{duration}</span>}
                    <span className="w-16 text-right">{formatSize(file.size)}</span>
                  </div>
                  <button
                    type="button"
                    onClick={() => onDownload(username, [file])}
                    disabled={file.isLocked || isQueued}
                    title={isQueued ? 'Queued' : 'Download'}
                    className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-zinc-400 transition hover:bg-white/10 hover:text-zinc-100 disabled:opacity-40"
                  >
                    {isQueued ? <Loader2 className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}
                  </button>
                </div>
              );
            })}
          </RowList>
          {unlocked > 1 && (
            <button
              type="button"
              onClick={() => onDownload(username, files)}
              className="flex w-full items-center justify-center gap-1.5 border-t border-white/5 py-1.5 text-xs text-zinc-300 transition hover:bg-white/5 hover:text-zinc-100"
            >
              <Download className="h-3.5 w-3.5" />
              {revealed || hidden === 0 ? `Download folder (${unlocked})` : `Download ${unlocked} matching`}
            </button>
          )}
        </>
      )}
    </SubCard>
  );
};
