import Anthropic from '@anthropic-ai/sdk';
import { Optional } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { alcanceDe } from 'src/catalogo/catalogo.service';
import { ConfigOperativaService } from 'src/config-operativa/config-operativa.service';
import { envs } from 'src/config/envs';
import { ErpReadonlyService } from 'src/erp-readonly/erp-readonly.service';
import { AnthropicService, FALLBACK } from 'src/llm/anthropic.service';
import { UsoTokens } from 'src/llm/precios';
import { Sesion } from 'src/sesion/sesion.types';
import { VistaCatalogo, vistasDelUsuario } from './catalogo-vistas';
import { SISTEMA_CONSULTOR } from './prompt-consultor';
import { MAX_FILAS, validarConsulta } from './validar-sql';

/**
 * Consultor (plan §6.7): convierte una pregunta sobre las operaciones de la
 * empresa en SQL sobre las vistas de soporte_ro, lo valida y lo ejecuta.
 *
 * El modelo solo ve las vistas que el usuario puede usar (y, por nombre, las que
 * existen pero no puede, para distinguir "sin permiso" de "no está"). El SQL corre
 * con el alcance FIRMADO de la sesión en una transacción READ ONLY: aunque el
 * validador dejara pasar algo, la base filtra por empresa, privilegio y sucursal.
 */

const MAX_CONSULTAS = 3;
const MAX_INTENTOS = 2;
/** Tope de lo que va al redactor por consulta (caracteres de JSON). */
const MAX_CARACTERES = 12_000;
const COLUMNAS_TTL_MS = 10 * 60_000;

const ESQUEMA = {
  type: 'object',
  properties: {
    estado: { type: 'string', enum: ['consultas', 'sin_permiso', 'no_cubierto', 'aclarar'] },
    explicacion: { type: 'string' },
    consultas: {
      type: 'array',
      items: {
        type: 'object',
        properties: { proposito: { type: 'string' }, sql: { type: 'string' } },
        required: ['proposito', 'sql'],
        additionalProperties: false,
      },
    },
  },
  required: ['estado', 'explicacion', 'consultas'],
  additionalProperties: false,
} as const;

type EstadoModelo = 'consultas' | 'sin_permiso' | 'no_cubierto' | 'aclarar';

interface SalidaModelo {
  estado: EstadoModelo;
  explicacion: string;
  consultas: Array<{ proposito: string; sql: string }>;
}

export interface ResultadoSql {
  proposito: string;
  sql: string;
  columnas: string[];
  filas: unknown[][];
  hay_mas: boolean;
  error?: string;
}

export interface ResultadoConsulta {
  estado: EstadoModelo | 'error';
  explicacion: string;
  resultados: ResultadoSql[];
  intentos: number;
  usos: Array<{ modelo: string; uso: UsoTokens }>;
}

@Injectable()
export class ConsultorService {
  private readonly logger = new Logger(ConsultorService.name);
  private columnas: { valor: Map<string, string[]>; vence: number } | null = null;

  constructor(
    private readonly llm: AnthropicService,
    private readonly erp: ErpReadonlyService,
    @Optional() private readonly config?: ConfigOperativaService,
  ) {}

  async consultar(entrada: { sesion: Sesion; pregunta: string; contexto?: string[] }): Promise<ResultadoConsulta> {
    const modelo = (await this.config?.modelo('consultor')) ?? envs.consultor.model;
    const { permitidas, sinPermiso } = vistasDelUsuario(entrada.sesion.privilegios);
    const usos: ResultadoConsulta['usos'] = [];
    const client = this.llm.client;
    if (!client) return { estado: 'error', explicacion: 'LLM no disponible', resultados: [], intentos: 0, usos };

    const columnas = await this.columnasPorVista();
    const nombres = new Set(permitidas.map((v) => v.vista).filter((v) => columnas.has(v)));
    const esquema = esquemaParaModelo(permitidas, sinPermiso, columnas, entrada.sesion.alcance);

    const mensajes: Anthropic.Beta.BetaMessageParam[] = [
      { role: 'user', content: pedidoInicial(entrada.pregunta, entrada.contexto ?? []) },
    ];

    let ultimo: ResultadoConsulta = { estado: 'error', explicacion: '', resultados: [], intentos: 0, usos };
    for (let intento = 1; intento <= MAX_INTENTOS; intento++) {
      const respuesta = await client.beta.messages.create({
        model: modelo,
        max_tokens: envs.consultor.maxTokens,
        ...FALLBACK,
        output_config: {
          effort: envs.consultor.effort,
          format: { type: 'json_schema', schema: ESQUEMA as unknown as Record<string, unknown> },
        },
        // Instrucciones fijas (caché compartida) + el esquema del usuario (caché por combinación de permisos).
        system: [
          { type: 'text', text: SISTEMA_CONSULTOR },
          { type: 'text', text: esquema, cache_control: { type: 'ephemeral' } },
        ],
        messages: mensajes,
      });
      usos.push({ modelo: respuesta.model, uso: respuesta.usage });
      if (respuesta.stop_reason === 'refusal') {
        return { estado: 'error', explicacion: 'rechazo del modelo', resultados: [], intentos: intento, usos };
      }
      const bloque = respuesta.content.find((b): b is Anthropic.Beta.BetaTextBlock => b.type === 'text');
      const salida = leerSalida(bloque?.text);
      if (!salida) {
        return {
          estado: 'error',
          explicacion: 'salida inválida del consultor',
          resultados: [],
          intentos: intento,
          usos,
        };
      }
      if (salida.estado !== 'consultas' || salida.consultas.length === 0) {
        return {
          estado: salida.estado === 'consultas' ? 'no_cubierto' : salida.estado,
          explicacion: salida.explicacion,
          resultados: [],
          intentos: intento,
          usos,
        };
      }

      const resultados = await this.ejecutar(entrada.sesion, salida.consultas.slice(0, MAX_CONSULTAS), nombres);
      ultimo = { estado: 'consultas', explicacion: salida.explicacion, resultados, intentos: intento, usos };
      const errores = resultados.filter((r) => r.error);
      if (errores.length === 0 || intento === MAX_INTENTOS) break;

      // Un reintento con el error a la vista: columnas mal escritas, un cast, un GROUP BY.
      mensajes.push({ role: 'assistant', content: bloque?.text ?? '' });
      mensajes.push({
        role: 'user',
        content:
          'Algunas consultas fallaron. Corregilas y devolvé el JSON completo otra vez (todas las consultas, no solo las que fallaron).\n' +
          resultados.map((r, i) => `Consulta ${i + 1}: ${r.error ? `ERROR: ${r.error}` : 'ok'}`).join('\n'),
      });
    }
    return ultimo;
  }

  private async ejecutar(
    sesion: Sesion,
    consultas: SalidaModelo['consultas'],
    permitidas: ReadonlySet<string>,
  ): Promise<ResultadoSql[]> {
    const validadas = consultas.map((c) => ({ ...c, v: validarConsulta(c.sql, permitidas) }));
    const ejecutables = validadas.filter((c) => c.v.ok);
    const salida = new Map<number, ResultadoSql>();

    if (ejecutables.length) {
      await this.erp.runScoped(alcanceDe(sesion), async (q) => {
        // Las fechas "de hoy" son las de Paraguay; cada consulta del modelo, con su propio tope.
        await q(`SET LOCAL TIME ZONE 'America/Asuncion'`);
        await q(`SET LOCAL statement_timeout = '4s'`);
        for (const [i, c] of validadas.entries()) {
          if (!c.v.ok) continue;
          await q('SAVEPOINT consulta');
          try {
            const filas = await q<Record<string, unknown>>(c.v.sql.replace(/LIMIT \d+$/, `LIMIT ${MAX_FILAS + 1}`));
            await q('RELEASE SAVEPOINT consulta');
            salida.set(i, { proposito: c.proposito, sql: c.sql, ...tabular(filas) });
          } catch (err) {
            await q('ROLLBACK TO SAVEPOINT consulta');
            salida.set(i, errorSql(c, err));
          }
        }
      });
    }

    return validadas.map(
      (c, i) =>
        salida.get(i) ?? {
          proposito: c.proposito,
          sql: c.sql,
          columnas: [],
          filas: [],
          hay_mas: false,
          error: c.v.ok ? 'no se ejecutó' : c.v.error,
        },
    );
  }

  private async columnasPorVista(): Promise<Map<string, string[]>> {
    if (this.columnas && this.columnas.vence > Date.now()) return this.columnas.valor;
    const filas = await this.erp.columnasVistas();
    const valor = new Map<string, string[]>();
    for (const f of filas) {
      const lista = valor.get(f.vista) ?? [];
      lista.push(`${f.columna} ${tipoCorto(f.tipo)}`);
      valor.set(f.vista, lista);
    }
    this.columnas = { valor, vence: Date.now() + COLUMNAS_TTL_MS };
    this.logger.log(`Esquema del consultor: ${valor.size} vistas`);
    return valor;
  }
}

function pedidoInicial(pregunta: string, contexto: string[]): string {
  const hoy = new Intl.DateTimeFormat('es-PY', {
    timeZone: 'America/Asuncion',
    weekday: 'long',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).format(new Date());
  const previo = contexto.length
    ? `Conversación anterior (para entender referencias):\n${contexto.join('\n')}\n\n`
    : '';
  return `Hoy es ${hoy} (Paraguay).\n\n${previo}<pregunta_cliente>\n${pregunta}\n</pregunta_cliente>`;
}

export function esquemaParaModelo(
  permitidas: readonly VistaCatalogo[],
  sinPermiso: readonly VistaCatalogo[],
  columnas: ReadonlyMap<string, string[]>,
  alcance: Sesion['alcance'] = null,
): string {
  const disponibles = permitidas
    .filter((v) => columnas.has(v.vista))
    .map((v) => `soporte_ro.${v.vista} (${v.modulo}): ${v.descripcion}\n  ${(columnas.get(v.vista) ?? []).join(', ')}`);
  const ajenas = sinPermiso.map((v) => `${v.vista} (${v.modulo})`);
  return (
    `Vistas que podés consultar:\n\n${disponibles.join('\n\n') || '(ninguna)'}` +
    `\n\nVistas que existen pero ESTE usuario no tiene permiso de ver: ${ajenas.join(', ') || '(ninguna)'}` +
    // Las vistas ya filtran por sucursal; el modelo lo tiene que saber para no presentar
    // las cifras como si fueran de toda la empresa.
    (alcance
      ? `\n\nAlcance: este usuario solo ve los datos de sus sucursales (establecimientos ${
          alcance.establecimientos.join(', ') || 'ninguno'
        }). Las cifras son de esas sucursales, no de toda la empresa: decilo en la explicación.`
      : '')
  );
}

function leerSalida(texto: string | undefined): SalidaModelo | null {
  if (!texto) return null;
  try {
    const j = JSON.parse(texto) as Partial<SalidaModelo>;
    const estados: EstadoModelo[] = ['consultas', 'sin_permiso', 'no_cubierto', 'aclarar'];
    if (!j || !estados.includes(j.estado as EstadoModelo)) return null;
    return {
      estado: j.estado as EstadoModelo,
      explicacion: typeof j.explicacion === 'string' ? j.explicacion.slice(0, 500) : '',
      consultas: Array.isArray(j.consultas)
        ? j.consultas
            .filter((c) => c && typeof c.sql === 'string')
            .map((c) => ({ proposito: String(c.proposito ?? '').slice(0, 200), sql: c.sql }))
        : [],
    };
  } catch {
    return null;
  }
}

/** Filas → columnas + matriz (menos tokens que objetos), recortadas a MAX_FILAS y a MAX_CARACTERES. */
export function tabular(filas: Record<string, unknown>[]): Pick<ResultadoSql, 'columnas' | 'filas' | 'hay_mas'> {
  const columnas = filas.length ? Object.keys(filas[0]) : [];
  let hayMas = filas.length > MAX_FILAS;
  const matriz: unknown[][] = [];
  let largo = 0;
  for (const f of filas.slice(0, MAX_FILAS)) {
    const fila = columnas.map((c) => valorPlano(f[c]));
    largo += JSON.stringify(fila).length;
    if (largo > MAX_CARACTERES) {
      hayMas = true;
      break;
    }
    matriz.push(fila);
  }
  return { columnas, filas: matriz, hay_mas: hayMas };
}

/**
 * Fechas: pg las parsea como hora LOCAL del proceso (timestamp sin zona); se
 * devuelven con los getters locales para no correrlas a UTC.
 */
function valorPlano(v: unknown): unknown {
  if (v instanceof Date) {
    const p = (n: number) => String(n).padStart(2, '0');
    const fecha = `${v.getFullYear()}-${p(v.getMonth() + 1)}-${p(v.getDate())}`;
    const hora = `${p(v.getHours())}:${p(v.getMinutes())}:${p(v.getSeconds())}`;
    return hora === '00:00:00' ? fecha : `${fecha} ${hora}`;
  }
  if (typeof v === 'string' && v.length > 300) return `${v.slice(0, 300)}…`;
  return v;
}

function errorSql(c: { proposito: string; sql: string }, err: unknown): ResultadoSql {
  const e = err as { message?: string; code?: string };
  const mensaje = e.code === '57014' ? 'la consulta tardó demasiado (más de 4 s): simplificala' : e.message;
  return {
    proposito: c.proposito,
    sql: c.sql,
    columnas: [],
    filas: [],
    hay_mas: false,
    error: String(mensaje ?? err).slice(0, 300),
  };
}

function tipoCorto(tipo: string): string {
  if (tipo.startsWith('timestamp')) return 'timestamp';
  if (tipo === 'character varying') return 'text';
  return tipo;
}
