import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { isAbsolute, join, normalize, relative, resolve, sep } from 'path';
import { envs } from 'src/config/envs';
import type { Ficha, IndiceManual } from './construir';
import { sinTildes, tokens } from './normalizar';

/**
 * Manual del sistema (plan §6.9): fichas de cada pantalla con los textos reales que ve
 * el usuario, armadas desde el código (pnpm manual:construir). Búsqueda BM25 en memoria.
 * Sin índice (no se construyó en este entorno) responde vacío: el agente sigue con las
 * guías de NovaIA.
 */
const K1 = 1.2;
const B = 0.5;
/**
 * Búsqueda por pasajes: ventanas de textos consecutivos de un mismo componente. Lo que
 * distingue al cobro del POS no es que diga "crédito" (lo dicen decenas de pantallas) sino
 * que "Condición de pago", "Crédito" y "Plazo" aparecen JUNTOS. Cada pantalla puntúa por
 * sus mejores pasajes y al modelo le llegan esos pasajes.
 */
const VENTANA = 8;
const PASO = 4;
const MAX_TEXTOS_POR_FICHA = 30;
const MAX_LINEAS_CODIGO = 220;

interface Pasaje {
  ficha: number;
  /** Índices en ficha.textos (ya ordenados por archivo y línea). */
  indices: number[];
  clave: string;
  tf: Map<string, number>;
  largo: number;
}

export interface ResultadoManual {
  id: string;
  pantalla: string;
  ruta: string | null;
  se_abre_desde?: string[];
  /** Otras pantallas que muestran lo mismo (comparten el componente). */
  tambien_en?: string[];
  textos: string[];
  archivos: string[];
  captura?: string;
}

/** Guía paso a paso grabada con Playwright (frontend e2e/manual-guias.real.spec.js). */
export interface Guia {
  id: string;
  titulo: string;
  descripcion: string;
  palabras: string[];
  generada: string;
  pasos: Array<{
    n: number;
    texto: string;
    imagen: string;
    ancho: number;
    alto: number;
    /** Recuadros a resaltar, en % de la imagen. */
    marcas: Array<{ x: number; y: number; w: number; h: number }>;
  }>;
}

/** Lo que recibe el redactor de una guía: la marca para insertarla y qué muestra cada paso. */
export interface ResultadoGuia {
  marca: string;
  titulo: string;
  pasos: string[];
}

const ID_GUIA = /^[a-z0-9-]+$/;

@Injectable()
export class ManualService implements OnModuleInit {
  private readonly logger = new Logger(ManualService.name);
  private fichas: Ficha[] = [];
  private tokensMiga: Array<Set<string>> = [];
  private pasajes: Pasaje[] = [];
  private df = new Map<string, number>();
  private promedio = 1;
  private dir = resolve(envs.manualDir);
  private refs: Record<string, string> = {};
  private guias: Guia[] = [];
  private tokensGuia: Array<Set<string>> = [];

  onModuleInit() {
    this.cargar();
  }

  get disponible(): boolean {
    return this.fichas.length > 0;
  }

  get version(): Record<string, string> {
    return this.refs;
  }

  /** Carga (o recarga) el índice. Público para los tests. */
  cargar(dir = this.dir, indice?: IndiceManual) {
    this.dir = resolve(dir);
    const archivo = join(this.dir, 'indice.json');
    if (!indice) {
      if (!existsSync(archivo)) {
        this.logger.warn(`Sin manual en ${archivo}: las preguntas de uso usan solo las guías.`);
        return;
      }
      indice = JSON.parse(readFileSync(archivo, 'utf8')) as IndiceManual;
    }
    this.refs = indice.refs ?? {};
    this.archivosCodigo = null;
    this.cargarGuias();
    this.fichas = indice.fichas.map((f) => ({
      ...f,
      textos: [...f.textos].sort((a, b) => a.archivo.localeCompare(b.archivo) || a.linea - b.linea),
    }));
    this.tokensMiga = this.fichas.map((f) => new Set([...tokens(f.miga), ...tokens(f.titulo)]));
    this.pasajes = [];
    this.df = new Map();
    this.fichas.forEach((f, fi) => {
      const tokensTextos = f.textos.map((x) => tokens(x.t));
      // Ventanas dentro de cada archivo: no se mezclan textos de componentes distintos.
      let inicio = 0;
      while (inicio < f.textos.length) {
        let fin = inicio;
        while (fin < f.textos.length && f.textos[fin].archivo === f.textos[inicio].archivo) fin++;
        for (let i = inicio; i < fin; i += PASO) {
          const indices = Array.from({ length: Math.min(VENTANA, fin - i) }, (_, k) => i + k);
          const tf = new Map<string, number>();
          for (const j of indices) for (const t of tokensTextos[j]) tf.set(t, (tf.get(t) ?? 0) + 1);
          for (const t of tf.keys()) this.df.set(t, (this.df.get(t) ?? 0) + 1);
          const largo = [...tf.values()].reduce((a, b) => a + b, 0);
          this.pasajes.push({ ficha: fi, indices, clave: `${f.textos[i].archivo}:${f.textos[i].linea}`, tf, largo });
          if (i + VENTANA >= fin) break;
        }
        inicio = fin;
      }
    });
    this.promedio = this.pasajes.reduce((a, p) => a + p.largo, 0) / Math.max(1, this.pasajes.length);
    this.logger.log(
      `Manual: ${this.fichas.length} fichas, ${this.pasajes.length} pasajes (${Object.values(this.refs).join(' | ')})`,
    );
  }

  /** manual/guias/<id>.json: las que tengan id válido, pasos y todas sus imágenes. */
  private cargarGuias() {
    const dir = join(this.dir, 'guias');
    this.guias = [];
    if (existsSync(dir)) {
      for (const nombre of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
        try {
          const g = JSON.parse(readFileSync(join(dir, nombre), 'utf8')) as Guia;
          const completa =
            ID_GUIA.test(g.id ?? '') &&
            g.pasos?.length > 0 &&
            g.pasos.every((p) => this.rutaCaptura(p.imagen)?.startsWith(dir + sep));
          if (completa) this.guias.push(g);
          else this.logger.warn(`Guía ${nombre} incompleta (id, pasos o imágenes): se ignora.`);
        } catch (err) {
          this.logger.warn(`Guía ${nombre} ilegible: ${(err as Error).message}`);
        }
      }
    }
    this.tokensGuia = this.guias.map(
      (g) => new Set(tokens([g.titulo, g.descripcion, ...(g.palabras ?? [])].join(' '))),
    );
  }

  /** Para el chat: la guía con los nombres de sus imágenes (se piden a /manual/capturas). */
  guia(id: string): Guia | null {
    return this.guias.find((g) => g.id === id) ?? null;
  }

  /**
   * Guías para la pregunta: las que eligió el enrutador (ids "guia:…") y, si no eligió
   * ninguna, la que comparta la mayoría de las palabras de su título y descripción.
   */
  guiasPara(ids: string[], pregunta: string): ResultadoGuia[] {
    let elegidas = ids
      .filter((i) => i.startsWith('guia:'))
      .map((i) => this.guia(i.slice(5)))
      .filter((g): g is Guia => !!g);
    if (!elegidas.length) {
      const q = new Set(tokens(pregunta));
      const mejor = this.guias
        .map((g, i) => ({ g, s: [...this.tokensGuia[i]].filter((t) => q.has(t)).length }))
        .sort((a, b) => b.s - a.s)[0];
      // Al menos tres palabras en común: "condición", "crédito" y "plazo", no solo "crédito".
      if (mejor && mejor.s >= 3) elegidas = [mejor.g];
    }
    return elegidas.slice(0, 2).map((g) => ({
      marca: `[[guia:${g.id}]]`,
      titulo: g.titulo,
      pasos: g.pasos.map((p) => p.texto),
    }));
  }

  private idf(t: string): number {
    const n = this.pasajes.length;
    const df = this.df.get(t) ?? 0;
    return Math.log(1 + (n - df + 0.5) / (df + 0.5));
  }

  private puntaje(p: Pasaje, q: string[]): number {
    let s = 0;
    for (const t of q) {
      const tf = p.tf.get(t) ?? 0;
      if (tf) s += (this.idf(t) * tf * (K1 + 1)) / (tf + K1 * (1 - B + (B * p.largo) / this.promedio));
    }
    return s;
  }

  buscar(pregunta: string, cantidad = 4): ResultadoManual[] {
    const q = [...new Set(tokens(pregunta))].filter((t) => this.df.has(t));
    if (!q.length) return [];
    const porFicha = new Map<number, Array<{ p: Pasaje; s: number }>>();
    for (const p of this.pasajes) {
      const s = this.puntaje(p, q);
      if (s <= 0) continue;
      const l = porFicha.get(p.ficha) ?? [];
      l.push({ p, s });
      porFicha.set(p.ficha, l);
    }
    const ranking = [...porFicha.entries()]
      .map(([fi, l]) => {
        l.sort((a, b) => b.s - a.s);
        // La ubicación también cuenta: "facturación" en la miga es esa pantalla.
        const enMiga = q.reduce((a, t) => a + (this.tokensMiga[fi].has(t) ? this.idf(t) * 0.5 : 0), 0);
        return { fi, l, s: l[0].s + 0.5 * (l[1]?.s ?? 0) + enMiga };
      })
      .sort((a, b) => b.s - a.s);

    // Pantallas que comparten el mismo mejor pasaje (el POS de /pos y /pos-admin): una sola.
    const salida: ResultadoManual[] = [];
    const vistos = new Map<string, ResultadoManual>();
    for (const r of ranking) {
      const clave = r.l[0].p.clave;
      const previo = vistos.get(clave);
      if (previo) {
        previo.tambien_en = [...(previo.tambien_en ?? []), this.fichas[r.fi].miga];
        continue;
      }
      if (salida.length >= cantidad) break;
      const res = this.resumen(r.fi, r.l.slice(0, 3).map((x) => x.p));
      vistos.set(clave, res);
      salida.push(res);
    }
    return salida;
  }

  /** Una línea por pantalla, para el enrutador: id | miga | desde dónde se abre | resumen. */
  indiceDePantallas(): string {
    const pantallas = this.fichas.map((f) =>
      [f.id, f.miga, f.desde?.length ? `desde: ${f.desde.slice(0, 3).join(', ')}` : '', f.resumen ?? '']
        .filter(Boolean)
        .join(' | '),
    );
    const guias = this.guias.map((g) => `guia:${g.id} | Guía paso a paso: ${g.titulo} | ${g.descripcion}`);
    return [...pantallas, ...guias].join('\n');
  }

  existe(id: string): boolean {
    return this.resolverId(id) !== null;
  }

  /** El id tal cual, o como lo suele devolver un modelo: "/ventas", "metodos-pago", "Ventas › Facturación". */
  resolverId(id: string): string | null {
    const v = id.trim();
    if (v.startsWith('guia:')) return this.guia(v.slice(5)) ? v : null;
    for (const c of [v, `ruta:${v}`, `config:${v}`, `reglas:${v}`]) if (this.fichas.some((f) => f.id === c)) return c;
    return this.fichas.find((f) => f.miga === v)?.id ?? null;
  }

  /**
   * Las pantallas que eligió el enrutador, con sus mejores pasajes para `consulta`
   * (la pregunta + los términos). Sin pasajes que coincidan, el comienzo de la pantalla.
   */
  detalle(ids: string[], consulta: string): ResultadoManual[] {
    const q = [...new Set(tokens(consulta))].filter((t) => this.df.has(t));
    const salida: ResultadoManual[] = [];
    for (const pedido of ids) {
      const id = this.resolverId(pedido);
      const fi = this.fichas.findIndex((f) => f.id === id);
      if (fi < 0) continue;
      const propios = this.pasajes
        .filter((p) => p.ficha === fi)
        .map((p) => ({ p, s: this.puntaje(p, q) }))
        .sort((a, b) => b.s - a.s);
      const mejores = propios.filter((x) => x.s > 0).slice(0, 4).map((x) => x.p);
      salida.push(this.resumen(fi, mejores.length ? mejores : propios.slice(0, 2).map((x) => x.p)));
    }
    return salida;
  }

  /** Los textos de los mejores pasajes, en el orden en que aparecen. */
  private resumen(fi: number, pasajes: Pasaje[]): ResultadoManual {
    const f = this.fichas[fi];
    const indices = [...new Set(pasajes.flatMap((p) => p.indices))].sort((a, b) => a - b).slice(0, MAX_TEXTOS_POR_FICHA);
    const textos = indices.map((i) => f.textos[i]);
    const captura = this.captura(f.id);
    return {
      id: f.id,
      pantalla: f.miga,
      ruta: f.ruta,
      ...(f.desde?.length ? { se_abre_desde: f.desde.slice(0, 4) } : {}),
      textos: textos.map((x) => x.t),
      archivos: [...new Set(textos.map((x) => x.archivo))].slice(0, 6),
      ...(captura ? { captura } : {}),
    };
  }

  /** Captura de la pantalla, si el recorrido de Playwright la generó (manual/capturas/<id>.png). */
  private captura(id: string): string | undefined {
    const nombre = `${nombreArchivo(id)}.png`;
    return existsSync(join(this.dir, 'capturas', nombre)) ? `manual:${nombre}` : undefined;
  }

  /** Ruta absoluta de una captura (de pantalla o de un paso de guía), o null si no existe o el nombre no es válido. */
  rutaCaptura(nombre: string): string | null {
    if (!/^[a-z0-9-]+(-\d{2})?\.png$/.test(nombre ?? '')) return null;
    for (const sub of ['capturas', 'guias']) {
      const f = join(this.dir, sub, nombre);
      if (existsSync(f)) return f;
    }
    return null;
  }

  /** Lista (cacheada) de los archivos de la foto del código, relativos a <manual>/codigo. */
  private archivosCodigo: string[] | null = null;

  private listarCodigo(): string[] {
    if (this.archivosCodigo) return this.archivosCodigo;
    const raiz = join(this.dir, 'codigo');
    const salida: string[] = [];
    const caminar = (dir: string) => {
      if (!existsSync(dir)) return;
      for (const nombre of readdirSync(dir)) {
        const f = join(dir, nombre);
        if (statSync(f).isDirectory()) caminar(f);
        else salida.push(relative(raiz, f));
      }
    };
    caminar(raiz);
    this.archivosCodigo = salida;
    return salida;
  }

  /**
   * Busca un texto en TODA la foto del código (como un grep), para rastrear un mensaje de
   * error o una lógica sin saber en qué archivo está. Sin tildes y sin mayúsculas.
   * Devuelve archivo:línea y la línea, para seguir con leerCodigo.
   */
  buscarCodigo(texto: string, carpeta?: 'frontend' | 'backend'): {
    coincidencias: Array<{ archivo: string; linea: number; texto: string }>;
    hay_mas: boolean;
  } {
    const aguja = sinTildes((texto ?? '').toLowerCase()).trim();
    if (aguja.length < 3) return { coincidencias: [], hay_mas: false };
    const MAX = 40;
    const POR_ARCHIVO = 5;
    const coincidencias: Array<{ archivo: string; linea: number; texto: string }> = [];
    let hay_mas = false;
    for (const rel of this.listarCodigo()) {
      if (carpeta && !rel.startsWith(carpeta + '/')) continue;
      const lineas = readFileSync(join(this.dir, 'codigo', rel), 'utf8').split('\n');
      let delArchivo = 0;
      for (let i = 0; i < lineas.length; i++) {
        if (!sinTildes(lineas[i].toLowerCase()).includes(aguja)) continue;
        if (coincidencias.length >= MAX || delArchivo >= POR_ARCHIVO) {
          hay_mas = true;
          break;
        }
        coincidencias.push({ archivo: rel, linea: i + 1, texto: lineas[i].trim().slice(0, 200) });
        delArchivo += 1;
      }
      if (coincidencias.length >= MAX) {
        hay_mas = true;
        break;
      }
    }
    return { coincidencias, hay_mas };
  }

  /**
   * Un archivo de la foto del código, con números de línea. Con `buscar`, solo las zonas
   * donde aparece (±20 líneas). Nunca sale de <manual>/codigo.
   */
  leerCodigo(archivo: string, buscar?: string): { archivo: string; lineas: string } | { error: string } {
    const raiz = join(this.dir, 'codigo');
    const limpio = normalize(archivo).replace(/^(\.\.(\/|\\|$))+/, '');
    if (!archivo || isAbsolute(archivo) || limpio.includes('..')) return { error: 'archivo inválido' };
    const f = resolve(raiz, limpio);
    if (!f.startsWith(raiz + sep) || !existsSync(f)) return { error: 'archivo no encontrado' };
    const lineas = readFileSync(f, 'utf8').split('\n');
    let elegidas: number[];
    if (buscar?.trim()) {
      const q = new Set(tokens(buscar));
      const literal = sinTildes(buscar.toLowerCase());
      const coinciden = lineas
        .map((l, i) => ({ l: sinTildes(l.toLowerCase()), i }))
        .filter(({ l }) => l.includes(literal) || tokens(l).some((t) => q.has(t)))
        .map(({ i }) => i);
      const set = new Set<number>();
      for (const i of coinciden)
        for (let j = Math.max(0, i - 20); j <= Math.min(lineas.length - 1, i + 20); j++) set.add(j);
      elegidas = [...set].sort((a, b) => a - b).slice(0, MAX_LINEAS_CODIGO);
    } else {
      elegidas = lineas.slice(0, MAX_LINEAS_CODIGO).map((_, i) => i);
    }
    if (!elegidas.length) return { error: `no aparece "${buscar}" en ${limpio}` };
    let previa = -2;
    const salida: string[] = [];
    for (const i of elegidas) {
      if (i !== previa + 1) salida.push('…');
      salida.push(`${i + 1}: ${lineas[i]}`);
      previa = i;
    }
    return { archivo: limpio, lineas: salida.join('\n') };
  }
}

/** "ruta:/ventas" → "ruta-ventas"; "config:metodos-pago" → "config-metodos-pago". */
export function nombreArchivo(id: string): string {
  return sinTildes(id.toLowerCase())
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}
