// Client for the manual-search API in the fetch stack.
//
// The dashboard cannot search indexers itself: it runs on the host, outside the
// VPN, and every query would announce this house's real address. So it asks the
// indexer container, which lives in gluetun's network namespace, over loopback.
//
// Loopback is also why there is no TLS here and why that is fine -- the socket
// is published as 127.0.0.1:8081 and never leaves this machine.

const BASE_URL = process.env.AMPELOS_INDEXER_URL ?? "http://127.0.0.1:8081";
const TOKEN = process.env.AMPELOS_AGENT_TOKEN ?? "";

// A fan-out across five indexers, some of them slow, some of them behind a VPN
// hop. Long enough to be patient, short enough that a hung indexer does not
// hold a server action open indefinitely.
const SEARCH_TIMEOUT_MS = 60_000;

export type SearchCandidate = {
  infoHash: string | null;
  title: string;
  size: number | null;
  seeders: number | null;
  leechers: number | null;
  indexerName: string;
  origin: string | null;
  publishedAt: string | null;
  indexerCount: number;
  quality: string | null;
  source: string | null;
  group: string | null;
  isSeasonPack: boolean;
  hasAtmos: boolean;
  accepted: boolean;
  score: number;
  reasons: string[];
  rejection: string | null;
};

export type SearchResult = {
  searchId: string;
  label: string;
  durationMs: number;
  indexersQueried: number;
  skipped: { indexer: string; reason: string }[];
  errors: { indexer: string; error: string }[];
  candidates: SearchCandidate[];
};

export class IndexerUnavailableError extends Error {}

async function call<T>(path: string, body: unknown): Promise<T> {
  if (!TOKEN) {
    throw new IndexerUnavailableError(
      "AMPELOS_AGENT_TOKEN is not set, so the dashboard cannot authenticate to the search service.",
    );
  }

  let response: Response;
  try {
    response = await fetch(BASE_URL + path, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: "Bearer " + TOKEN,
      },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
      cache: "no-store",
    });
  } catch (error) {
    // The common cause by far is the fetch stack being down, which is a
    // perfectly ordinary state, so say that rather than surfacing ECONNREFUSED.
    const detail = error instanceof Error ? error.message : String(error);
    throw new IndexerUnavailableError(
      `The search service is not answering on ${BASE_URL} (${detail}). Is the fetch stack running?`,
    );
  }

  const payload = (await response.json().catch(() => ({}))) as { error?: string } & T;
  if (!response.ok) {
    throw new Error(payload.error ?? `Search service returned ${response.status}`);
  }
  return payload;
}

export function searchReleases(target: {
  mediaItemId: string;
  seasonNumber?: number | null;
  episodeNumber?: number | null;
}) {
  return call<SearchResult>("/search", target);
}

export function grabRelease(choice: { searchId: string; infoHash: string }) {
  return call<{ grabbed: boolean; grabId?: string; title?: string; reason?: string }>(
    "/grab",
    choice,
  );
}
