// Découverte des sessions Claude sur disque (~/.claude/projects/**/*.jsonl).
// Transcription TS du spike S4 (scan.mjs) : parseur strictement tolérant, lecture partielle
// tête+queue (~1,4 s pour 1300+ fichiers), 99,8 % des transcripts exploitables.
// I/O ASYNCHRONES : le scan rend la main à la boucle d'événements entre fichiers, pour ne pas
// figer le streaming des terminaux (il tourne périodiquement dans le DiscoveryService).
import { readdir, stat, open } from 'node:fs/promises';
import { join } from 'node:path';

const HEAD_BYTES = 256 * 1024;
const TAIL_BYTES = 64 * 1024;
const TITLE_MAX = 120;

/** Reproduit le nom de dossier ~/.claude/projects à partir d'un cwd (validé 100 % — spike S4). */
export function munge(cwd: string): string {
  return cwd.replace(/[^A-Za-z0-9]/g, '-');
}

export interface DiscoveredJsonl {
  claudeSessionId: string;
  cwd: string;
  title: string | null;
  gitBranch: string | null;
  version: string | null;
  mtimeMs: number;
  /** chemin absolu du .jsonl */
  file: string;
}

async function readChunk(path: string, position: number, length: number): Promise<string> {
  const fh = await open(path, 'r');
  try {
    const buf = Buffer.alloc(length);
    const { bytesRead } = await fh.read(buf, 0, length, position);
    return buf.subarray(0, bytesRead).toString('utf8');
  } finally {
    await fh.close();
  }
}

/** Parse tolérant : chaque ligne en try/catch, lignes coupées/malformées ignorées (jamais de throw). */
export function parseLines(text: string, partialFirst = false, partialLast = false): Array<Record<string, unknown>> {
  const lines = text.split('\n');
  if (partialFirst) lines.shift(); // 1re ligne potentiellement coupée (lecture de queue)
  if (partialLast) lines.pop(); // dernière ligne potentiellement coupée (lecture de tête)
  const out: Array<Record<string, unknown>> = [];
  for (const l of lines) {
    if (!l.trim()) continue;
    try {
      const o = JSON.parse(l) as unknown;
      if (o && typeof o === 'object') out.push(o as Record<string, unknown>);
    } catch {
      /* ligne coupée ou malformée : ignorée */
    }
  }
  return out;
}

interface Meta {
  sessionId?: string;
  cwd?: string;
  gitBranch?: string;
  version?: string;
  aiTitle?: string;
  summary?: string;
  lastPrompt?: string;
  firstUserPrompt?: string;
}

function asString(v: unknown): string | undefined {
  return typeof v === 'string' && v.length > 0 ? v : undefined;
}

function extractMeta(objs: Array<Record<string, unknown>>, meta: Meta): void {
  const setOnce = (key: 'sessionId' | 'cwd' | 'gitBranch' | 'version', v: unknown): void => {
    if (meta[key] === undefined) {
      const s = asString(v);
      if (s) meta[key] = s;
    }
  };
  for (const o of objs) {
    setOnce('sessionId', o.sessionId);
    setOnce('cwd', o.cwd);
    setOnce('gitBranch', o.gitBranch);
    setOnce('version', o.version);
    // Titre : on retient la dernière valeur vue (la plus récente) — head puis tail → la queue gagne.
    const ai = asString(o.aiTitle);
    if (ai) meta.aiTitle = ai;
    const sum = asString(o.summary);
    if (sum) meta.summary = sum;
    const lp = asString(o.lastPrompt);
    if (lp) meta.lastPrompt = lp;
    // Premier prompt utilisateur (depuis la tête) : repli ultime pour le titre.
    if (meta.firstUserPrompt === undefined && o.type === 'user') {
      const msg = o.message;
      if (msg && typeof msg === 'object') {
        const text = asString((msg as Record<string, unknown>).content);
        if (text) meta.firstUserPrompt = text;
      }
    }
  }
}

/** Priorité de titre actée S4 : ai-title > summary > last-prompt > 1er prompt user. */
function pickTitle(meta: Meta): string | null {
  const raw = meta.aiTitle ?? meta.summary ?? meta.lastPrompt ?? meta.firstUserPrompt ?? null;
  if (!raw) return null;
  const t = raw.replace(/\s+/g, ' ').trim();
  return t.length > TITLE_MAX ? `${t.slice(0, TITLE_MAX - 1)}…` : t;
}

/**
 * Scanne `projectsDir` en lecture partielle tête+queue. Tolérant : un dossier/fichier illisible ou
 * malformé est ignoré (jamais de throw). Un fichier sans sessionId+cwd exploitables est écarté.
 */
export async function scanProjects(projectsDir: string): Promise<DiscoveredJsonl[]> {
  let dirs: string[];
  try {
    dirs = await readdir(projectsDir);
  } catch {
    return []; // dossier absent (Claude jamais lancé) : pas une erreur
  }
  const out: DiscoveredJsonl[] = [];
  for (const dir of dirs) {
    const dirPath = join(projectsDir, dir);
    let entries;
    try {
      entries = await readdir(dirPath, { withFileTypes: true });
    } catch {
      continue;
    }
    for (const e of entries) {
      if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
      const fp = join(dirPath, e.name);
      try {
        const st = await stat(fp);
        if (st.size === 0) continue;
        const meta: Meta = {};
        const head = await readChunk(fp, 0, Math.min(HEAD_BYTES, st.size));
        extractMeta(parseLines(head, false, st.size > HEAD_BYTES), meta);
        if (st.size > HEAD_BYTES + TAIL_BYTES) {
          const tail = await readChunk(fp, st.size - TAIL_BYTES, TAIL_BYTES);
          extractMeta(parseLines(tail, true, false), meta);
        }
        if (!meta.sessionId || !meta.cwd) continue; // pas une session exploitable
        out.push({
          claudeSessionId: meta.sessionId,
          cwd: meta.cwd,
          title: pickTitle(meta),
          gitBranch: meta.gitBranch ?? null,
          version: meta.version ?? null,
          mtimeMs: st.mtimeMs,
          file: fp,
        });
      } catch {
        /* fichier illisible : ignoré */
      }
    }
  }
  return out;
}
