import type { BrowseDirInput, BrowseDirRow, BrowsedFile } from 'officerdb';
import { startSoulseekBrowse, finishSoulseekBrowse, failSoulseekBrowse } from 'officerdb';
import { authHeaders, getSlskdConfig } from './upstream';

// Background share-tree fetches.
//
// slskd's GET /users/{u}/browse is a single blocking call that inlines every file of every folder —
// measured at 59 MB / 18k folders / 284k files for one real peer, and it can take minutes because it
// round-trips to that peer over the Soulseek network. Doing it from the browser means a tab that hangs
// and throws the result away on navigation.
//
// So it runs HERE instead: the route kicks this off and returns immediately, the fetch outlives the
// request (and the tab), and the result lands in Postgres for the UI to page through. The sidecar is a
// long-lived PM2 process, which is what makes "come back later and it's there" work at all.

// slskd's wire shape. Files carry `attributes` (bitrate/duration) that real peers leave empty, plus
// `code`/`attributeCount` noise; all of it is dropped at ingest.
type SlskdBrowseFile = { filename: string; size?: number; extension?: string };
type SlskdBrowseDir = { name: string; fileCount?: number; files?: SlskdBrowseFile[] };
type SlskdBrowsePayload = { directories?: SlskdBrowseDir[] } | SlskdBrowseDir[];

// One browse per (user, peer) at a time. A second click while one is running is a no-op rather than a
// duplicate multi-minute fetch of the same tree.
const inFlight = new Map<string, Promise<void>>();
const keyOf = (userId: number, username: string) => `${userId}:${username}`;

/** Peers currently being fetched, so a caller can report "already running" without racing the map. */
export const isBrowseRunning = (userId: number, username: string): boolean => inFlight.has(keyOf(userId, username));

// Generous, but not unbounded: a peer that accepts the connection and then stalls shouldn't pin a
// snapshot in 'pending' forever.
const BROWSE_TIMEOUT_MS = 10 * 60 * 1000;

const extensionOf = (file: SlskdBrowseFile): string => {
  const raw = file.extension?.trim();
  if (raw) return raw.replace(/^\./, '').toLowerCase();
  const dot = file.filename.lastIndexOf('.');
  return dot > 0 ? file.filename.slice(dot + 1).toLowerCase() : '';
};

/**
 * Strip the response to what the UI actually renders. Locked directories are skipped: they're shares the
 * peer won't serve us, so listing them would only offer downloads that always fail.
 */
function slim(payload: SlskdBrowsePayload): BrowseDirInput[] {
  const dirs = Array.isArray(payload) ? payload : (payload?.directories ?? []);
  return dirs.map((dir) => {
    const files: BrowsedFile[] = (dir.files ?? []).map((f) => ({
      // Browse gives a basename only, unlike search results' full remote path. Downloading one means
      // rejoining it to the directory name with a backslash.
      name: f.filename,
      size: f.size ?? 0,
      extension: extensionOf(f),
    }));
    return { name: dir.name, files };
  });
}

const SEP = '\\';

/** Split a slskd path into the parent path and the last segment, ignoring any trailing separator. */
function splitPath(name: string): { parentPath: string | null; label: string; depth: number } {
  const trimmed = name.endsWith(SEP) ? name.slice(0, -1) : name;
  const cut = trimmed.lastIndexOf(SEP);
  const depth = trimmed.split(SEP).filter(Boolean).length || 1;
  // `cut > 0` not `>= 0`: a leading separator would otherwise make the parent an empty string, which is
  // neither null nor a real row, and the level query would never find it.
  return { parentPath: cut > 0 ? trimmed.slice(0, cut) : null, label: trimmed.slice(cut + 1), depth };
}

/**
 * Turn slskd's flat list of paths into a linked tree, once, here — so the UI can open one level at a
 * time instead of the platform recomputing the shape on every request.
 *
 * Two things the response can't be trusted on. Ancestors: measured across two real peers (~30k folders)
 * exactly one intermediate folder was missing, so they're nearly always present, but a single gap would
 * make a whole subtree unreachable — hence `ensure` synthesizes any absent one as an empty folder.
 * Rollups: a parent's own file count is usually just cover art, so the number worth showing on a
 * collapsed row is the subtree's, and that's summed bottom-up here rather than per request.
 */
export function buildTree(dirs: BrowseDirInput[]): BrowseDirRow[] {
  const byName = new Map<string, BrowseDirRow>();

  const ensure = (name: string): BrowseDirRow => {
    const existing = byName.get(name);
    if (existing) return existing;
    const { parentPath, label, depth } = splitPath(name);
    const row: BrowseDirRow = {
      name,
      parentPath,
      label,
      depth,
      childCount: 0,
      fileCount: 0,
      totalSize: 0,
      subtreeFileCount: 0,
      subtreeSize: 0,
      files: [],
    };
    // Set before recursing so a cycle-shaped path can't loop, and so the parent's own ancestors follow.
    byName.set(name, row);
    if (parentPath) ensure(parentPath);
    return row;
  };

  for (const dir of dirs) {
    const row = ensure(dir.name);
    // Concatenated rather than assigned: a path repeated in the response would otherwise lose files.
    row.files = row.files.length ? row.files.concat(dir.files) : dir.files;
    row.fileCount = row.files.length;
    row.totalSize = row.files.reduce((n, f) => n + (f.size || 0), 0);
  }

  const rows = [...byName.values()];
  for (const row of rows) {
    row.subtreeFileCount = row.fileCount;
    row.subtreeSize = row.totalSize;
  }
  // Deepest first, so every child is final before its parent absorbs it.
  for (const row of [...rows].sort((a, b) => b.depth - a.depth)) {
    const parent = row.parentPath ? byName.get(row.parentPath) : undefined;
    if (!parent) continue;
    parent.childCount += 1;
    parent.subtreeFileCount += row.subtreeFileCount;
    parent.subtreeSize += row.subtreeSize;
  }
  return rows;
}

async function run(userId: number, username: string, snapshotId: number): Promise<void> {
  const cfg = await getSlskdConfig(userId);
  if (!cfg) {
    await failSoulseekBrowse(snapshotId, 'slskd upstream not configured');
    return;
  }
  const started = Date.now();
  try {
    const res = await fetch(`${cfg.base}/api/v0/users/${encodeURIComponent(username)}/browse`, {
      headers: authHeaders(cfg),
      signal: AbortSignal.timeout(BROWSE_TIMEOUT_MS),
    });
    if (!res.ok) {
      const detail = (await res.text().catch(() => '')).slice(0, 200);
      throw new Error(`slskd returned ${res.status}${detail ? `: ${detail}` : ''}`);
    }

    const dirs = slim((await res.json()) as SlskdBrowsePayload);
    const tree = buildTree(dirs);
    await finishSoulseekBrowse(snapshotId, tree);
    const files = dirs.reduce((n, d) => n + d.files.length, 0);
    // Against distinct names, not the array length, so a repeated path doesn't read as a negative.
    const synthesized = tree.length - new Set(dirs.map((d) => d.name)).size;
    console.log(
      `[slskd] browse ${username} -> ${tree.length} dirs (${synthesized} synthesized) / ${files} files in ${Date.now() - started}ms`,
    );
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    console.error(`[slskd] browse ${username} failed after ${Date.now() - started}ms: ${message}`);
    await failSoulseekBrowse(snapshotId, message);
  }
}

/**
 * Start caching a peer's share tree and return once it's *registered*, not once it's done — the caller
 * responds 202 and the fetch continues in the background. Resolves false if one was already running.
 */
export async function startBrowse(userId: number, username: string): Promise<boolean> {
  const key = keyOf(userId, username);
  if (inFlight.has(key)) return false;

  const snapshotId = await startSoulseekBrowse(userId, username);
  // Registered before awaiting anything inside run(), so two rapid clicks can't both get past the check.
  const task = run(userId, username, snapshotId).finally(() => inFlight.delete(key));
  inFlight.set(key, task);
  return true;
}
