import { eq, and, asc, isNull, inArray, sql } from 'drizzle-orm';
import { db } from '../db';
import { soulseekFavorites, soulseekBrowseSnapshots, soulseekBrowseDirs } from './schema';

/** A user's favourited Soulseek peers, alphabetical (the order the UI lists them in). */
export async function getSoulseekFavorites(userId: number): Promise<string[]> {
  const rows = await db
    .select({ username: soulseekFavorites.username })
    .from(soulseekFavorites)
    .where(eq(soulseekFavorites.userId, userId))
    .orderBy(asc(soulseekFavorites.username));
  return rows.map((r) => r.username);
}

/** Add a favourite (idempotent — a repeat add is a no-op via the unique constraint). */
export async function addSoulseekFavorite(userId: number, username: string): Promise<void> {
  await db.insert(soulseekFavorites).values({ userId, username }).onConflictDoNothing();
}

/** Remove a favourite (no-op if it wasn't set). */
export async function removeSoulseekFavorite(userId: number, username: string): Promise<void> {
  await db
    .delete(soulseekFavorites)
    .where(and(eq(soulseekFavorites.userId, userId), eq(soulseekFavorites.username, username)));
}

// ── Cached share trees ──

/** A file inside a cached folder, slimmed at ingest (see the schema note on dropped attributes). */
export type BrowsedFile = { name: string; size: number; extension: string };
/** One folder as slskd gives it: a full backslash-delimited path, plus its files. */
export type BrowseDirInput = { name: string; files: BrowsedFile[] };

/**
 * One folder as it's stored — a tree node. slskd's flat paths are linked up into this shape by the
 * sidecar's `buildTree` before they get here, which is why every count is already on the row.
 */
export type BrowseDirRow = {
  name: string;
  parentPath: string | null;
  depth: number;
  label: string;
  childCount: number;
  fileCount: number;
  totalSize: number;
  subtreeFileCount: number;
  subtreeSize: number;
  files: BrowsedFile[];
};

export type SoulseekBrowseSnapshot = {
  username: string;
  status: string;
  error: string | null;
  directoryCount: number;
  fileCount: number;
  totalSize: number;
  startedAt: Date;
  completedAt: Date | null;
};

const snapshotCols = {
  username: soulseekBrowseSnapshots.username,
  status: soulseekBrowseSnapshots.status,
  error: soulseekBrowseSnapshots.error,
  directoryCount: soulseekBrowseSnapshots.directoryCount,
  fileCount: soulseekBrowseSnapshots.fileCount,
  totalSize: soulseekBrowseSnapshots.totalSize,
  startedAt: soulseekBrowseSnapshots.startedAt,
  completedAt: soulseekBrowseSnapshots.completedAt,
};

/** Every cached share tree for a user, so the UI can badge each favourite in one request. */
export async function getSoulseekBrowseSnapshots(userId: number): Promise<SoulseekBrowseSnapshot[]> {
  return db
    .select(snapshotCols)
    .from(soulseekBrowseSnapshots)
    .where(eq(soulseekBrowseSnapshots.userId, userId))
    .orderBy(asc(soulseekBrowseSnapshots.username));
}

/** One peer's snapshot, or null if never fetched. */
export async function getSoulseekBrowseSnapshot(
  userId: number,
  username: string,
): Promise<SoulseekBrowseSnapshot | null> {
  const [row] = await db
    .select(snapshotCols)
    .from(soulseekBrowseSnapshots)
    .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
    .limit(1);
  return row ?? null;
}

/**
 * Mark a peer's tree as being fetched and return the snapshot id to ingest into. Deliberately leaves the
 * existing folder rows and counts alone: they stay readable while the refresh runs, and survive it failing.
 */
export async function startSoulseekBrowse(userId: number, username: string): Promise<number> {
  const [row] = await db
    .insert(soulseekBrowseSnapshots)
    .values({ userId, username, status: 'pending' })
    .onConflictDoUpdate({
      target: [soulseekBrowseSnapshots.userId, soulseekBrowseSnapshots.username],
      set: { status: 'pending', error: null, startedAt: new Date(), completedAt: null },
    })
    .returning({ id: soulseekBrowseSnapshots.id });
  if (!row) throw new Error('failed to create browse snapshot');
  return row.id;
}

// Each row carries a whole folder's file list, so keep batches small enough that no single INSERT gets
// absurd. 18k folders is ~36 round trips at this size.
const INGEST_BATCH = 500;

/** Swap in a freshly built tree: replace the folder rows and mark the snapshot ready, atomically. */
export async function finishSoulseekBrowse(snapshotId: number, tree: BrowseDirRow[]): Promise<void> {
  const rows = tree.map((r) => ({ ...r, snapshotId }));
  await db.transaction(async (tx) => {
    await tx.delete(soulseekBrowseDirs).where(eq(soulseekBrowseDirs.snapshotId, snapshotId));
    for (let i = 0; i < rows.length; i += INGEST_BATCH) {
      await tx.insert(soulseekBrowseDirs).values(rows.slice(i, i + INGEST_BATCH));
    }
    // Summed over the roots, not over every row: the subtree rollups already include descendants, so
    // adding them all up would count each file once per level above it.
    const roots = rows.filter((r) => r.parentPath === null);
    await tx
      .update(soulseekBrowseSnapshots)
      .set({
        status: 'ready',
        error: null,
        completedAt: new Date(),
        directoryCount: rows.length,
        fileCount: roots.reduce((n, r) => n + r.subtreeFileCount, 0),
        totalSize: roots.reduce((n, r) => n + r.subtreeSize, 0),
      })
      .where(eq(soulseekBrowseSnapshots.id, snapshotId));
  });
}

/** Record a failed fetch. Counts and folder rows are left as they were — a stale cache beats none. */
export async function failSoulseekBrowse(snapshotId: number, error: string): Promise<void> {
  await db
    .update(soulseekBrowseSnapshots)
    .set({ status: 'failed', error: error.slice(0, 1000), completedAt: new Date() })
    .where(eq(soulseekBrowseSnapshots.id, snapshotId));
}

/**
 * Fail every snapshot still marked pending. Called on sidecar boot: an in-flight fetch dies with the
 * process, so without this a crash mid-browse leaves a row spinning in the UI forever.
 */
export async function resetStaleSoulseekBrowses(): Promise<number> {
  const rows = await db
    .update(soulseekBrowseSnapshots)
    .set({ status: 'failed', error: 'interrupted by a sidecar restart', completedAt: new Date() })
    .where(eq(soulseekBrowseSnapshots.status, 'pending'))
    .returning({ id: soulseekBrowseSnapshots.id });
  return rows.length;
}

/** A tree row as the UI renders it. `files` stays out — those load per folder, on expand. */
export type BrowseTreeNode = {
  id: number;
  name: string;
  parentPath: string | null;
  label: string;
  depth: number;
  childCount: number;
  fileCount: number;
  totalSize: number;
  subtreeFileCount: number;
  subtreeSize: number;
};

const treeCols = {
  id: soulseekBrowseDirs.id,
  name: soulseekBrowseDirs.name,
  parentPath: soulseekBrowseDirs.parentPath,
  label: soulseekBrowseDirs.label,
  depth: soulseekBrowseDirs.depth,
  childCount: soulseekBrowseDirs.childCount,
  fileCount: soulseekBrowseDirs.fileCount,
  totalSize: soulseekBrowseDirs.totalSize,
  subtreeFileCount: soulseekBrowseDirs.subtreeFileCount,
  subtreeSize: soulseekBrowseDirs.subtreeSize,
};

/** The snapshot a request is scoped to, or null if this peer was never fetched. */
async function snapshotIdOf(userId: number, username: string): Promise<number | null> {
  const [snap] = await db
    .select({ id: soulseekBrowseSnapshots.id })
    .from(soulseekBrowseSnapshots)
    .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)))
    .limit(1);
  return snap?.id ?? null;
}

export type BrowseLevel = { nodes: BrowseTreeNode[]; total: number };
type BrowseLevelParams = { userId: number; username: string; parent?: string; limit: number; offset: number };

/**
 * One level of the tree: the children of `parent`, or the roots when it's absent. Paged, because fan-out
 * is not gentle — the widest folder measured on a real peer has 1,181 children.
 */
export async function getSoulseekBrowseLevel(params: BrowseLevelParams): Promise<BrowseLevel> {
  const { userId, username, parent, limit, offset } = params;
  const snapshotId = await snapshotIdOf(userId, username);
  if (snapshotId === null) return { nodes: [], total: 0 };

  const where = and(
    eq(soulseekBrowseDirs.snapshotId, snapshotId),
    parent ? eq(soulseekBrowseDirs.parentPath, parent) : isNull(soulseekBrowseDirs.parentPath),
  );

  const [nodes, [count]] = await Promise.all([
    db
      .select(treeCols)
      .from(soulseekBrowseDirs)
      .where(where)
      .orderBy(asc(soulseekBrowseDirs.name))
      .limit(limit)
      .offset(offset),
    db
      .select({ total: sql<number>`count(*)::int` })
      .from(soulseekBrowseDirs)
      .where(where),
  ]);
  return { nodes, total: count?.total ?? 0 };
}

// These names come off strangers' filesystems, so they're littered with typographic punctuation that no
// keyboard produces: curly apostrophes, en dashes, and U+2010 HYPHEN, which is pixel-identical to '-'.
// Typing "Hell's Ditch" or "1984-1985" found nothing at all, so both sides get folded to ASCII first.
// Same character set on both sides — extend one and you must extend the other.
const FOLD_FROM = '‘’ʼ＇“”‐‑‒–—―− ';
const FOLD_TO = "''''\"\"------- ";
// Not in the translate() pair because it's one character standing in for three.
const ELLIPSIS = '…';

const foldPunctuation = (value: string): string => {
  let out = '';
  for (const ch of value) {
    const at = FOLD_FROM.indexOf(ch);
    out += at === -1 ? ch : FOLD_TO[at];
  }
  return out.split(ELLIPSIS).join('...');
};

// Postgres reads '%', '_' and '\' as LIKE metacharacters, so unescaped input meant a typed '_' matched
// every folder in the share while a pasted path — and every name here IS a backslash-delimited path —
// silently matched none. Backslash stays the escape character, which is why it's escaped first.
const likePattern = (term: string): string => `%${foldPunctuation(term).replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;

/** The column, folded the same way `likePattern` folds the term. */
const foldedName = sql`replace(translate(${soulseekBrowseDirs.name}, ${FOLD_FROM}, ${FOLD_TO}), ${ELLIPSIS}, '...')`;

export type BrowseTreeSearch = {
  /** Matches AND every ancestor of a match, so the UI has a whole tree to render, not orphans. */
  nodes: BrowseTreeNode[];
  /** `name`s of the rows that actually matched — the rest of `nodes` are there to hold them up. */
  matched: string[];
  /** Matches in the snapshot, which exceeds `matched.length` when the cap kicked in. */
  total: number;
  truncated: boolean;
};
type BrowseTreeSearchParams = { userId: number; username: string; q: string; limit: number };

const SEP = '\\';

/** Every path above `name`, longest first: 'a\b\c' → ['a\b', 'a']. */
function ancestorsOf(name: string): string[] {
  const out: string[] = [];
  for (let cut = name.lastIndexOf(SEP); cut > 0; cut = name.lastIndexOf(SEP, cut - 1)) {
    out.push(name.slice(0, cut));
  }
  return out;
}

/**
 * Filtered view of the tree. Filtering never flattens it: matches come back with their full ancestor
 * chains, which is what lets the UI open straight down to a hit and keep the shape visible around it.
 *
 * Ancestors are resolved by string prefix rather than a recursive CTE — the parent's path IS a prefix of
 * the child's, so one indexed `name in (…)` lookup does what a walk would take `depth` round trips to do.
 */
export async function searchSoulseekBrowseTree(params: BrowseTreeSearchParams): Promise<BrowseTreeSearch> {
  const { userId, username, q, limit } = params;
  const snapshotId = await snapshotIdOf(userId, username);
  const term = q.trim();
  if (snapshotId === null || !term) return { nodes: [], matched: [], total: 0, truncated: false };

  // Words are AND-ed, each matched anywhere in the path, so 'pogues hell' finds
  // '…\The Pogues\… - Hell's Ditch …' — a folder you can name from two ends but never type in full.
  // ILIKE with a leading wildcard can't use the btree index, so this was already a scan of the snapshot's
  // ~18k rows — which is why folding the column, once per word, costs nothing on top.
  const words = term.split(/\s+/).filter(Boolean);
  const where = and(
    eq(soulseekBrowseDirs.snapshotId, snapshotId),
    ...words.map((word) => sql`${foldedName} ilike ${likePattern(word)}`),
  );

  const [matches, [count]] = await Promise.all([
    db.select(treeCols).from(soulseekBrowseDirs).where(where).orderBy(asc(soulseekBrowseDirs.name)).limit(limit),
    db
      .select({ total: sql<number>`count(*)::int` })
      .from(soulseekBrowseDirs)
      .where(where),
  ]);

  const have = new Set(matches.map((m) => m.name));
  const wanted = new Set<string>();
  for (const m of matches) for (const a of ancestorsOf(m.name)) if (!have.has(a)) wanted.add(a);

  const ancestors = wanted.size
    ? await db
        .select(treeCols)
        .from(soulseekBrowseDirs)
        .where(and(eq(soulseekBrowseDirs.snapshotId, snapshotId), inArray(soulseekBrowseDirs.name, [...wanted])))
    : [];

  const nodes = [...matches, ...ancestors].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
  const total = count?.total ?? 0;
  return { nodes, matched: matches.map((m) => m.name), total, truncated: total > matches.length };
}

/** One cached folder's files. Scoped by user so a guessed dir id can't read another account's cache. */
type DirFilesParams = { userId: number; username: string; dirId: number };

// Joined and scoped by BOTH owner and peer: a dir id alone would happily return another peer's folder
// (ids are global), so the username in the request URL has to be part of the predicate, not decoration.
export async function getSoulseekBrowseDirFiles({
  userId,
  username,
  dirId,
}: DirFilesParams): Promise<BrowsedFile[] | null> {
  const [row] = await db
    .select({ files: soulseekBrowseDirs.files })
    .from(soulseekBrowseDirs)
    .innerJoin(soulseekBrowseSnapshots, eq(soulseekBrowseDirs.snapshotId, soulseekBrowseSnapshots.id))
    .where(
      and(
        eq(soulseekBrowseDirs.id, dirId),
        eq(soulseekBrowseSnapshots.userId, userId),
        eq(soulseekBrowseSnapshots.username, username),
      ),
    )
    .limit(1);
  return row ? (row.files as BrowsedFile[]) : null;
}

/** A file as slskd wants it enqueued: the peer's own full path, plus the size it advertised. */
export type BrowseDownloadFile = { filename: string; size: number };
type BrowseDownloadParams = { userId: number; username: string; path: string; file?: string };

/**
 * Resolve a download request to the exact files to enqueue — one folder's subtree, or a single file.
 *
 * Sizes and paths come from the cache rather than the client: slskd matches a queued download on
 * filename AND size, so a wrong number is a transfer that never starts. The stored file name is a
 * basename (browse, unlike search, doesn't repeat the folder), so the peer's real path is rejoined here.
 */
export async function getSoulseekBrowseDownload({
  userId,
  username,
  path,
  file,
}: BrowseDownloadParams): Promise<BrowseDownloadFile[]> {
  const snapshotId = await snapshotIdOf(userId, username);
  if (snapshotId === null) return [];

  const rows = await db
    .select({ name: soulseekBrowseDirs.name, files: soulseekBrowseDirs.files })
    .from(soulseekBrowseDirs)
    .where(
      and(
        eq(soulseekBrowseDirs.snapshotId, snapshotId),
        // starts_with, not LIKE: these paths are full of backslashes, and one would be a LIKE escape.
        file
          ? eq(soulseekBrowseDirs.name, path)
          : sql`(${soulseekBrowseDirs.name} = ${path} or starts_with(${soulseekBrowseDirs.name}, ${path + SEP}))`,
      ),
    )
    .orderBy(asc(soulseekBrowseDirs.name));

  return rows.flatMap((row) =>
    (row.files as BrowsedFile[])
      .filter((f) => (file ? f.name === file : true))
      .map((f) => ({ filename: `${row.name}${SEP}${f.name}`, size: f.size })),
  );
}

/** Drop a peer's cache entirely (snapshot + folders, via the FK cascade). */
export async function deleteSoulseekBrowse(userId: number, username: string): Promise<void> {
  await db
    .delete(soulseekBrowseSnapshots)
    .where(and(eq(soulseekBrowseSnapshots.userId, userId), eq(soulseekBrowseSnapshots.username, username)));
}
