import type { SessionSettings, SessionStats, Torrent } from './types';
import { DETAIL_FIELDS, LIST_FIELDS } from './types';
import { decorate, rpc, TransmissionError } from './rpc';

// Officer-owned routes for the transmission sidecar — the entire feature surface lives under /_officer/.
//
// Nothing here is a passthrough. Transmission speaks a single POST endpoint with a `method` field, which is
// a terrible fit for an HTTP cache, for auth scoping and for a browser client: it would mean shipping the
// 409 handshake, the `result: 'success'` convention and the whole method vocabulary into the SPA. So the
// verbs are re-expressed as real REST paths and every quirk is absorbed in rpc.ts.

export type OfficerContext = { req: Request; url: URL; userId: number };

/** 400 with a machine-readable reason. */
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
/** 404 for an unknown /_officer/ path or a missing object. */
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
/** 405 when the path exists but the verb doesn't. */
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });

async function readJson(req: Request): Promise<Record<string, unknown> | null> {
  const body = await req.json().catch(() => null);
  return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
}

/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */
function readIds(body: Record<string, unknown>): number[] | null {
  const raw = body['ids'];
  if (!Array.isArray(raw)) return null;
  const ids = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
  return ids.length === raw.length ? ids : null;
}

// Actions that take {ids} and nothing else. Mapping them by name keeps the browser from naming RPC methods.
const ACTIONS: Record<string, string> = {
  start: 'torrent-start',
  'start-now': 'torrent-start-now',
  stop: 'torrent-stop',
  verify: 'torrent-verify',
  reannounce: 'torrent-reannounce',
  'queue-top': 'queue-move-top',
  'queue-up': 'queue-move-up',
  'queue-down': 'queue-move-down',
  'queue-bottom': 'queue-move-bottom',
};

// Whitelists, not passthroughs. Transmission silently accepts some nonsense and hard-fails on other, so the
// browser is never allowed to name an arbitrary RPC argument — a typo here is a 400, not a silent no-op.
const TORRENT_SET_FIELDS = new Set([
  'bandwidthPriority',
  'downloadLimit',
  'downloadLimited',
  'files-unwanted',
  'files-wanted',
  'group',
  'honorsSessionLimits',
  'labels',
  'peer-limit',
  'priority-high',
  'priority-low',
  'priority-normal',
  'queuePosition',
  'seedIdleLimit',
  'seedIdleMode',
  'seedRatioLimit',
  'seedRatioMode',
  'sequential_download',
  'trackerAdd',
  'trackerList',
  'uploadLimit',
  'uploadLimited',
]);

const SESSION_SET_FIELDS = new Set([
  'alt-speed-down',
  'alt-speed-enabled',
  'alt-speed-time-begin',
  'alt-speed-time-day',
  'alt-speed-time-enabled',
  'alt-speed-time-end',
  'alt-speed-up',
  'blocklist-enabled',
  'blocklist-url',
  'cache-size-mb',
  'default-trackers',
  'dht-enabled',
  'download-dir',
  'download-queue-enabled',
  'download-queue-size',
  'encryption',
  'idle-seeding-limit',
  'idle-seeding-limit-enabled',
  'incomplete-dir',
  'incomplete-dir-enabled',
  'lpd-enabled',
  'peer-limit-global',
  'peer-limit-per-torrent',
  'peer-port',
  'peer-port-random-on-start',
  'pex-enabled',
  'port-forwarding-enabled',
  'queue-stalled-enabled',
  'queue-stalled-minutes',
  'rename-partial-files',
  'script-torrent-done-enabled',
  'script-torrent-done-filename',
  'seed-queue-enabled',
  'seed-queue-size',
  'seedRatioLimit',
  'seedRatioLimited',
  'speed-limit-down',
  'speed-limit-down-enabled',
  'speed-limit-up',
  'speed-limit-up-enabled',
  'start-added-torrents',
  'trash-original-torrent-files',
  'utp-enabled',
]);

function pick(body: Record<string, unknown>, allowed: Set<string>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(body)) {
    if (allowed.has(key)) out[key] = value;
  }
  return out;
}

/**
 * Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
 *
 * `userId` was authenticated by the platform proxy and validated at the server boundary — every call below
 * needs it, because which daemon to talk to is per-owner state read from the database.
 */
export async function handleOfficerRoute(userId: number, req: Request, url: URL): Promise<Response | null> {
  const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
  if (segments.length === 0) return null;

  const ctx: OfficerContext = { req, url, userId };

  try {
    switch (segments[0]) {
      case 'session':
        return await handleSession(ctx, segments.slice(1));
      case 'stats':
        return await handleStats(ctx);
      case 'torrents':
        return await handleTorrents(ctx, segments.slice(1));
      case 'free-space':
        return await handleFreeSpace(ctx);
      case 'port-test':
        return await handlePortTest(ctx);
      case 'blocklist-update':
        return await handleBlocklistUpdate(ctx);
      default:
        return null;
    }
  } catch (err) {
    if (err instanceof TransmissionError) return Response.json({ error: err.message }, { status: err.status });
    throw err;
  }
}

async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
  if (rest.length > 0) return null;

  if (ctx.req.method === 'GET') {
    // No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it.
    const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
    return Response.json({ session });
  }

  if (ctx.req.method === 'POST') {
    const body = await readJson(ctx.req);
    if (!body) return badRequest('expected a JSON object body');
    const args = pick(body, SESSION_SET_FIELDS);
    if (Object.keys(args).length === 0) return badRequest('no writable session fields in body');
    await rpc(ctx.userId, 'session-set', args);
    // Read back rather than echoing the request: Transmission clamps and normalises several of these
    // (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding.
    const session = await rpc<SessionSettings>(ctx.userId, 'session-get');
    return Response.json({ session });
  }

  return methodNotAllowed();
}

async function handleStats(ctx: OfficerContext): Promise<Response> {
  if (ctx.req.method !== 'GET') return methodNotAllowed();
  const stats = await rpc<SessionStats>(ctx.userId, 'session-stats');
  return Response.json({ stats });
}

async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
  if (ctx.req.method !== 'GET') return methodNotAllowed();
  const path = ctx.url.searchParams.get('path');
  if (!path) return badRequest('path query parameter is required');
  const result = await rpc<{ path: string; 'size-bytes': number }>(ctx.userId, 'free-space', { path });
  return Response.json({ path: result.path, bytes: result['size-bytes'] });
}

// GET: it asks the daemon whether the peer port is reachable and returns the answer. Nothing changes,
// on this side or Transmission's. Blocklist-update below stays POST because it genuinely refetches and
// replaces the list — the two look alike and are not.
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
  if (ctx.req.method !== 'GET') return methodNotAllowed();
  const result = await rpc<{ 'port-is-open': boolean }>(ctx.userId, 'port-test');
  return Response.json({ open: result['port-is-open'] });
}

async function handleBlocklistUpdate(ctx: OfficerContext): Promise<Response> {
  if (ctx.req.method !== 'POST') return methodNotAllowed();
  const result = await rpc<{ 'blocklist-size': number }>(ctx.userId, 'blocklist-update');
  return Response.json({ size: result['blocklist-size'] });
}

async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
  const { req } = ctx;

  // GET /_officer/torrents — the list poll.
  if (rest.length === 0 && req.method === 'GET') {
    const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', { fields: [...LIST_FIELDS] });
    return Response.json({ torrents: result.torrents.map(decorate) });
  }

  if (rest.length === 1) {
    const [segment] = rest as [string];

    if (req.method === 'POST') {
      switch (segment) {
        case 'add':
          return await addTorrent(ctx);
        case 'action':
          return await runAction(ctx);
        case 'set':
          return await setTorrent(ctx);
        case 'location':
          return await setLocation(ctx);
        case 'rename':
          return await renamePath(ctx);
        case 'remove':
          return await removeTorrents(ctx);
      }
    }

    // GET /_officer/torrents/:id — the detail poll, one torrent, with the expensive arrays.
    const id = Number(segment);
    if (!Number.isInteger(id) || id <= 0) return notFound();
    if (req.method !== 'GET') return methodNotAllowed();

    const result = await rpc<{ torrents: Torrent[] }>(ctx.userId, 'torrent-get', {
      ids: [id],
      fields: [...LIST_FIELDS, ...DETAIL_FIELDS],
    });
    const torrent = result.torrents[0];
    if (!torrent) return notFound('torrent not found');
    return Response.json({ torrent: decorate(torrent) });
  }

  return null;
}

async function addTorrent(ctx: OfficerContext): Promise<Response> {
  const body = await readJson(ctx.req);
  if (!body) return badRequest('expected a JSON object body');

  const args: Record<string, unknown> = {};
  // Exactly one source. `metainfo` is a base64 .torrent; `filename` is a magnet link or an http(s) URL.
  if (typeof body['metainfo'] === 'string' && body['metainfo']) args['metainfo'] = body['metainfo'];
  else if (typeof body['filename'] === 'string' && body['filename']) args['filename'] = body['filename'];
  else return badRequest('either metainfo or filename is required');

  if (typeof body['downloadDir'] === 'string' && body['downloadDir']) args['download-dir'] = body['downloadDir'];
  if (Array.isArray(body['labels'])) args['labels'] = body['labels'].filter((l) => typeof l === 'string');
  if (typeof body['paused'] === 'boolean') args['paused'] = body['paused'];
  if (typeof body['bandwidthPriority'] === 'number') args['bandwidthPriority'] = body['bandwidthPriority'];
  if (typeof body['sequentialDownload'] === 'boolean') args['sequential_download'] = body['sequentialDownload'];

  const result = await rpc<{
    'torrent-added'?: { id: number; name: string; hashString: string };
    'torrent-duplicate'?: { id: number; name: string; hashString: string };
  }>(ctx.userId, 'torrent-add', args);

  const added = result['torrent-added'];
  const duplicate = result['torrent-duplicate'];
  // A duplicate is not an error — Transmission reports the torrent you already had, and the UI counts it
  // separately so a batch add can say "3 added, 1 already present" instead of failing opaquely.
  if (duplicate) return Response.json({ status: 'duplicate', torrent: duplicate });
  if (added) return Response.json({ status: 'added', torrent: added });
  return Response.json({ status: 'added', torrent: null });
}

async function runAction(ctx: OfficerContext): Promise<Response> {
  const body = await readJson(ctx.req);
  if (!body) return badRequest('expected a JSON object body');

  const action = typeof body['action'] === 'string' ? body['action'] : '';
  const method = ACTIONS[action];
  if (!method) return badRequest(`unknown action "${action}"`);

  const ids = readIds(body);
  if (!ids) return badRequest('ids must be an array of positive integers');
  if (ids.length === 0) return Response.json({ ok: true, affected: 0 });

  await rpc(ctx.userId, method, { ids });
  return Response.json({ ok: true, affected: ids.length });
}

async function setTorrent(ctx: OfficerContext): Promise<Response> {
  const body = await readJson(ctx.req);
  if (!body) return badRequest('expected a JSON object body');

  const ids = readIds(body);
  if (!ids) return badRequest('ids must be an array of positive integers');

  const fields = pick(body, TORRENT_SET_FIELDS);
  if (Object.keys(fields).length === 0) return badRequest('no writable torrent fields in body');
  if (ids.length === 0) return Response.json({ ok: true, affected: 0 });

  await rpc(ctx.userId, 'torrent-set', { ids, ...fields });
  return Response.json({ ok: true, affected: ids.length });
}

async function setLocation(ctx: OfficerContext): Promise<Response> {
  const body = await readJson(ctx.req);
  if (!body) return badRequest('expected a JSON object body');

  const ids = readIds(body);
  if (!ids) return badRequest('ids must be an array of positive integers');
  const location = typeof body['location'] === 'string' ? body['location'].trim() : '';
  if (!location) return badRequest('location is required');
  if (ids.length === 0) return Response.json({ ok: true, affected: 0 });

  // `move: false` re-points the torrent at data already sitting there; `true` physically moves it. Getting
  // this backwards either loses the data or copies gigabytes unasked, so it is required, not defaulted.
  const move = body['move'] === true;
  await rpc(ctx.userId, 'torrent-set-location', { ids, location, move });
  return Response.json({ ok: true, affected: ids.length });
}

async function renamePath(ctx: OfficerContext): Promise<Response> {
  const body = await readJson(ctx.req);
  if (!body) return badRequest('expected a JSON object body');

  const id = Number(body['id']);
  if (!Number.isInteger(id) || id <= 0) return badRequest('id must be a positive integer');
  const path = typeof body['path'] === 'string' ? body['path'] : '';
  const name = typeof body['name'] === 'string' ? body['name'].trim() : '';
  if (!path) return badRequest('path is required');
  if (!name) return badRequest('name is required');

  // torrent-rename-path takes ONE id — an array is accepted but the result is undefined. Enforce it.
  await rpc(ctx.userId, 'torrent-rename-path', { ids: [id], path, name });
  return Response.json({ ok: true });
}

async function removeTorrents(ctx: OfficerContext): Promise<Response> {
  const body = await readJson(ctx.req);
  if (!body) return badRequest('expected a JSON object body');

  const ids = readIds(body);
  if (!ids) return badRequest('ids must be an array of positive integers');
  if (ids.length === 0) return Response.json({ ok: true, affected: 0 });

  const deleteLocalData = body['deleteLocalData'] === true;
  await rpc(ctx.userId, 'torrent-remove', { ids, 'delete-local-data': deleteLocalData });
  return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
}
