import { ErpReadonlyService } from 'src/erp-readonly/erp-readonly.service';
import { AnthropicService } from 'src/llm/anthropic.service';
import { Sesion } from 'src/sesion/sesion.types';
import { ConsultorService, esquemaParaModelo, tabular } from './consultor.service';

const sesion: Sesion = {
  usuarioId: '11111111-1111-1111-1111-111111111111',
  username: 'ana',
  nombre: 'Ana',
  empresaId: '22222222-2222-2222-2222-222222222222',
  empresaRuc: '80012345',
  empresaRazonSocial: 'Empresa SA',
  empresaTipo: 'cliente',
  isSuperadmin: false,
  accesoElevado: false,
  privilegios: new Set(['VEN_FAC_FACTURA_VER']),
  alcance: { sucursalIds: ['33333333-3333-3333-3333-333333333333'], establecimientos: ['001'] },
  esNovasis: false,
};

const COLUMNAS = [
  { vista: 'v_ventas', columna: 'fecha_dia', tipo: 'date' },
  { vista: 'v_ventas', columna: 'total', tipo: 'numeric' },
  { vista: 'v_ventas', columna: 'vigente', tipo: 'boolean' },
  { vista: 'v_ventas', columna: 'moneda', tipo: 'text' },
  { vista: 'v_stock', columna: 'disponible', tipo: 'numeric' },
];

const USO = { input_tokens: 100, output_tokens: 50 };

function salida(estado: string, consultas: Array<{ proposito: string; sql: string }>, explicacion = 'mes actual') {
  return {
    model: 'claude-opus-5-5',
    stop_reason: 'end_turn',
    usage: USO,
    content: [{ type: 'text', text: JSON.stringify({ estado, explicacion, consultas }) }],
  };
}

function armar(respuestas: unknown[], ejecutar: (sql: string) => unknown[] = () => []) {
  const create = jest.fn();
  respuestas.forEach((r) => create.mockResolvedValueOnce(r));
  const llm = { client: { beta: { messages: { create } } } } as unknown as AnthropicService;
  const sentencias: string[] = [];
  const alcances: unknown[] = [];
  const erp = {
    columnasVistas: jest.fn().mockResolvedValue(COLUMNAS),
    runScoped: jest.fn(async (alcance: unknown, fn: (q: (sql: string) => Promise<unknown[]>) => Promise<unknown>) => {
      alcances.push(alcance);
      return fn((sql: string) => {
        sentencias.push(sql);
        if (/^(SET|SAVEPOINT|RELEASE|ROLLBACK)/.test(sql)) return Promise.resolve([]);
        // Como pg: un error de la consulta llega como promesa rechazada.
        return Promise.resolve().then(() => ejecutar(sql));
      });
    }),
  } as unknown as ErpReadonlyService;
  return { consultor: new ConsultorService(llm, erp), create, sentencias, alcances };
}

const VENTAS_MES =
  "SELECT moneda, sum(total) AS total FROM soporte_ro.v_ventas WHERE vigente AND fecha_dia >= date_trunc('month', current_date) GROUP BY moneda";

describe('ConsultorService', () => {
  it('escribe, valida y ejecuta con el alcance de la sesión', async () => {
    const { consultor, create, sentencias, alcances } = armar(
      [salida('consultas', [{ proposito: 'total del mes', sql: VENTAS_MES }])],
      () => [{ moneda: 'PYG', total: '1500000' }],
    );
    const r = await consultor.consultar({ sesion, pregunta: '¿cuánto vendí este mes?' });

    expect(r.estado).toBe('consultas');
    expect(r.resultados[0]).toMatchObject({
      columnas: ['moneda', 'total'],
      filas: [['PYG', '1500000']],
      hay_mas: false,
    });
    expect(r.usos).toHaveLength(1);
    // El alcance sale de la sesión (empresa, sucursales, privilegios), no del modelo.
    expect(alcances[0]).toMatchObject({ empresaId: sesion.empresaId, establecimientos: ['001'] });
    expect(sentencias).toEqual(
      expect.arrayContaining(["SET LOCAL TIME ZONE 'America/Asuncion'", "SET LOCAL statement_timeout = '4s'"]),
    );
    expect(sentencias.find((s) => s.includes('v_ventas'))).toMatch(/LIMIT 201$/);

    // El esquema muestra solo las vistas permitidas; las otras, por nombre.
    const sistema = create.mock.calls[0][0].system as Array<{ text: string }>;
    expect(sistema[1].text).toContain('soporte_ro.v_ventas');
    expect(sistema[1].text).toContain('fecha_dia date');
    expect(sistema[1].text).not.toContain('disponible numeric');
    expect(sistema[1].text).toMatch(/no tiene permiso de ver: .*v_stock/);
  });

  it('una vista sin permiso no llega a la base: reintenta con el error', async () => {
    const { consultor, create, sentencias } = armar([
      salida('consultas', [{ proposito: 'stock', sql: 'SELECT sum(disponible) FROM soporte_ro.v_stock' }]),
      salida('sin_permiso', [], 'el stock no está entre sus vistas'),
    ]);
    const r = await consultor.consultar({ sesion, pregunta: '¿cuánto stock tengo?' });

    expect(sentencias.some((s) => s.includes('v_stock'))).toBe(false);
    expect(create).toHaveBeenCalledTimes(2);
    const reintento = create.mock.calls[1][0].messages.at(-1).content as string;
    expect(reintento).toMatch(/v_stock/);
    expect(r.estado).toBe('sin_permiso');
  });

  it('un error de SQL vuelve al modelo una vez, con el mensaje', async () => {
    const malo = 'SELECT sum(totl) FROM soporte_ro.v_ventas';
    const { consultor, create, sentencias } = armar(
      [
        salida('consultas', [{ proposito: 'total', sql: malo }]),
        salida('consultas', [{ proposito: 'total', sql: 'SELECT sum(total) AS total FROM soporte_ro.v_ventas' }]),
      ],
      (sql) => {
        if (sql.includes('totl')) throw new Error('no existe la columna «totl»');
        return [{ total: '10' }];
      },
    );
    const r = await consultor.consultar({ sesion, pregunta: 'total vendido' });

    expect(create).toHaveBeenCalledTimes(2);
    expect(create.mock.calls[1][0].messages.at(-1).content).toContain('totl');
    expect(sentencias).toContain('ROLLBACK TO SAVEPOINT consulta');
    expect(r.resultados[0]).toMatchObject({ filas: [['10']] });
    expect(r.resultados[0].error).toBeUndefined();
  });

  it('no reintenta más de una vez', async () => {
    const malo = { proposito: 'x', sql: 'SELECT * FROM public.usuario' };
    const { consultor, create } = armar([salida('consultas', [malo]), salida('consultas', [malo])]);
    const r = await consultor.consultar({ sesion, pregunta: 'usuarios' });
    expect(create).toHaveBeenCalledTimes(2);
    expect(r.resultados[0].error).toMatch(/soporte_ro/);
  });

  it('no_cubierto y aclarar no ejecutan nada', async () => {
    const { consultor, sentencias } = armar([salida('no_cubierto', [], 'no hay datos del clima')]);
    const r = await consultor.consultar({ sesion, pregunta: '¿va a llover?' });
    expect(r).toMatchObject({ estado: 'no_cubierto', explicacion: 'no hay datos del clima', resultados: [] });
    expect(sentencias).toEqual([]);
  });

  it('una salida que no es JSON es un error, no una excepción', async () => {
    const { consultor } = armar([
      { model: 'm', stop_reason: 'end_turn', usage: USO, content: [{ type: 'text', text: 'hola' }] },
    ]);
    const r = await consultor.consultar({ sesion, pregunta: 'x' });
    expect(r.estado).toBe('error');
  });
});

describe('esquemaParaModelo', () => {
  it('con alcance por sucursal, le avisa al modelo que las cifras son de esas sucursales', () => {
    const conAlcance = esquemaParaModelo([], [], new Map(), { sucursalIds: ['x'], establecimientos: ['003'] });
    expect(conAlcance).toMatch(/establecimientos 003/);
    expect(esquemaParaModelo([], [], new Map(), null)).not.toMatch(/Alcance:/);
  });
});

describe('tabular', () => {
  it('marca hay_mas cuando vienen más filas que el tope', () => {
    const filas = Array.from({ length: 201 }, (_, i) => ({ n: i }));
    const t = tabular(filas);
    expect(t.filas).toHaveLength(200);
    expect(t.hay_mas).toBe(true);
  });

  it('las fechas salen en hora local, sin correrse a UTC', () => {
    const t = tabular([{ dia: new Date(2026, 9, 1), momento: new Date(2026, 9, 1, 23, 30, 5) }]);
    expect(t.filas[0]).toEqual(['2026-10-01', '2026-10-01 23:30:05']);
  });

  it('corta por tamaño aunque haya pocas filas', () => {
    const t = tabular(Array.from({ length: 100 }, () => ({ texto: 'x'.repeat(290) })));
    expect(t.filas.length).toBeLessThan(100);
    expect(t.hay_mas).toBe(true);
  });
});
