import type { Torrent, TrackerStat } from './types';
import type { TransmissionConfig } from './upstream';
import { getTransmissionConfig } from './upstream';

// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled exactly once:
//
//  • THE 409 HANDSHAKE. Transmission's CSRF defence answers the first request of a session with
//    `409 Conflict` and an `X-Transmission-Session-Id` header, expecting the client to repeat the request
//    carrying it. The token also rotates whenever the daemon restarts, so this is not a start-up ritual you
//    can do once — any call can 409 at any time. Absorbing it here is the single biggest reason this
//    sidecar exists: a client that handles it naively works until the daemon is restarted and then fails
//    in a way that looks like an auth bug.
//  • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries
//    `{"result": "some error string"}`; only `result === 'success'` means it worked.
//  • Auth is HTTP Basic, and an EMPTY username must send no header at all — see basicAuth in upstream.ts.
//  • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so
//    LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed.

const DEFAULT_TIMEOUT_MS = 30_000;

/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
export class TransmissionError extends Error {
  constructor(
    readonly status: number,
    message: string,
  ) {
    super(message);
    this.name = 'TransmissionError';
  }
}

// The CSRF token for a daemon session, keyed by endpoint rather than by owner: the token belongs to the
// daemon, so two owners pointed at the same one share a refresh, and re-pointing at a different daemon
// cannot carry a token that daemon never issued.
const sessionIds = new Map<string, string>();

type RpcResponse<T> = { result: string; arguments?: T };

/** Issue one RPC call against the owner's configured daemon. */
export async function rpc<T = unknown>(userId: number, method: string, args: Record<string, unknown> = {}): Promise<T> {
  const cfg = await getTransmissionConfig(userId);
  if (!cfg) throw new TransmissionError(503, 'transmission is not connected');
  return call<T>(cfg, method, args, true);
}

/** The same call against a connection that may not be stored yet — used to validate one before saving it. */
export async function rpcWith<T = unknown>(
  cfg: TransmissionConfig,
  method: string,
  args: Record<string, unknown> = {},
): Promise<T> {
  return call<T>(cfg, method, args, true);
}

async function call<T>(
  cfg: TransmissionConfig,
  method: string,
  args: Record<string, unknown>,
  mayRetry: boolean,
): Promise<T> {
  const endpoint = `${cfg.base}${cfg.rpcPath}`;

  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  const sessionId = sessionIds.get(endpoint);
  if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
  if (cfg.auth) headers['Authorization'] = cfg.auth;

  let res: Response;
  try {
    res = await fetch(endpoint, {
      method: 'POST',
      headers,
      body: JSON.stringify({ method, arguments: args }),
      signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
    });
  } catch (err) {
    const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'unreachable';
    throw new TransmissionError(502, `transmission upstream ${reason}`);
  }

  if (res.status === 409) {
    const fresh = res.headers.get('X-Transmission-Session-Id');
    if (fresh && mayRetry) {
      sessionIds.set(endpoint, fresh);
      return call<T>(cfg, method, args, false);
    }
    throw new TransmissionError(502, 'transmission rejected the session id handshake');
  }

  if (res.status === 401) throw new TransmissionError(401, 'transmission rejected the credentials');
  if (!res.ok) throw new TransmissionError(502, `transmission returned ${res.status}`);

  const body = (await res.json().catch(() => null)) as RpcResponse<T> | null;
  if (!body) throw new TransmissionError(502, 'transmission returned an unreadable body');
  // A 200 with a non-'success' result is the normal way Transmission reports a failure.
  if (body.result !== 'success') throw new TransmissionError(502, body.result || 'transmission reported a failure');

  return (body.arguments ?? {}) as T;
}

/** Reachability + credential probe, used by /_health and before a connection is saved. */
export async function probe(
  cfg: TransmissionConfig,
): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
  try {
    const args = await rpcWith<{ version: string; 'rpc-version': number }>(cfg, 'session-get', {
      fields: ['version', 'rpc-version'],
    });
    return { ok: true, version: args.version, rpcVersion: args['rpc-version'] };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

// ── Derived fields ────────────────────────────────────────────────────────────────────────────────

// Subdomain prefixes that carry no identity — `tracker.example.org` and `t.example.org` are the same
// operator to a human grouping their torrents, and showing them as two entries makes the filter useless.
const IGNORED_TRACKER_PREFIXES = new Set(['t', 'tr', 'tk', 'tracker', 'bt', 'open', 'opentracker', 'pt']);

const ANNOUNCE_STATE: Record<number, string> = {
  0: 'Inactive',
  1: 'Waiting',
  2: 'Queued',
  3: 'Announcing',
};

/** `udp://tr.example.org:1337/announce` → `example.org`. Empty string when there is no tracker at all. */
export function trackerHost(stats: TrackerStat[]): string {
  const primary = stats.find((t) => !t.isBackup) ?? stats[0];
  if (!primary) return '';

  const raw = primary.host || primary.announce;
  let host: string;
  try {
    host = new URL(raw.includes('://') ? raw : `http://${raw}`).hostname;
  } catch {
    host = raw
      .replace(/^[a-z]+:\/\//i, '')
      .split('/')[0]!
      .split(':')[0]!;
  }

  const parts = host.split('.');
  // Only strip a prefix when something recognisable remains — `t.co` must not become `co`.
  if (parts.length > 2 && IGNORED_TRACKER_PREFIXES.has(parts[0]!.toLowerCase())) parts.shift();
  return parts.join('.');
}

function trackerStatus(stats: TrackerStat[]): string {
  const primary = stats.find((t) => !t.isBackup) ?? stats[0];
  if (!primary) return '';
  if (primary.lastAnnounceSucceeded === false && primary.lastAnnounceResult) return primary.lastAnnounceResult;
  return ANNOUNCE_STATE[primary.announceState] ?? '';
}

/** Attach the `officer*` fields the UI groups and filters by. Mutating the parsed body is fine — it's ours. */
export function decorate(torrent: Torrent): Torrent {
  const stats = torrent.trackerStats ?? [];
  torrent.officerTrackerHost = trackerHost(stats);
  torrent.officerTrackerStatus = trackerStatus(stats);
  torrent.officerTrackerErrors = stats.filter((t) => t.lastAnnounceSucceeded === false && t.hasAnnounced).length;
  return torrent;
}
