import { ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { inicioMesUTC } from './ai-common.util';

/** Error específico de cuota de IA excedida (para distinguirlo de otros fallos). */
export class LimiteTokensExcedidoError extends ForbiddenException {
  constructor(message: string) {
    super(message);
  }
}

/**
 * Origen funcional de una llamada a un LLM. Sirve para desglosar el consumo por
 * módulo dentro de cada empresa. Se guarda como texto libre (VarChar) para no
 * acoplar el schema; usá estos valores canónicos.
 */
export type AiFeature =
  | 'CHAT'
  | 'INSIGHTS'
  | 'AYUDA'
  | 'CARTERA'
  | 'TESORERIA'
  | 'CONCILIACION_IA'
  | 'RECALCULO'
  | 'TEST'
  | 'DESCONOCIDO';

export interface AiUsageEntry {
  empresa_id: string;
  feature: AiFeature | string;
  proveedor: string;
  /** Modelo que devolvió la API (es el que se guarda: refleja lo que realmente respondió). */
  modelo: string;
  /**
   * Modelo configurado para la empresa, si difiere del que responde la API. Algunos
   * proveedores contestan con un alias que no está en `ai_tarifas` (DeepSeek responde
   * "deepseek-flash" con "deepseek-v4-flash" configurado): sirve de respaldo para
   * encontrar la tarifa y no perder el costo.
   */
  modelo_configurado?: string | null;
  tokens_in: number;
  tokens_out: number;
  usuario_id?: string | null;
  referencia_id?: string | null;
  latencia_ms?: number | null;
  /** true = key propia de la empresa; false = credencial de la plataforma. */
  credencial_propia?: boolean | null;
  ok?: boolean;
  error?: string | null;
}

interface TarifaCache {
  data: Map<string, { in: number; out: number }>;
  loadedAt: number;
}

/**
 * Medición centralizada del consumo de IA (fuente única de verdad de tokens
 * por empresa). Todo proveedor de LLM debe llamar a `registrar()` tras cada
 * llamada real. Hoy SOLO mide (no bloquea); el diseño deja lista la base para
 * límites/cuotas y facturación por tokens.
 *
 * `registrar()` NUNCA lanza: un fallo de auditoría jamás debe romper la
 * funcionalidad IA que la originó (mismo criterio que AiAuditService).
 */
@Injectable()
export class AiUsageService {
  private readonly logger = new Logger(AiUsageService.name);
  private static readonly TARIFA_TTL_MS = 5 * 60 * 1000;
  private tarifas: TarifaCache | null = null;
  /** Combinaciones ya avisadas, para no repetir el WARN en cada llamada. */
  private readonly tarifasFaltantes = new Set<string>();

  constructor(private readonly prisma: PrismaService) {}

  async registrar(entry: AiUsageEntry): Promise<void> {
    try {
      const tokensIn = Math.max(0, Math.round(entry.tokens_in || 0));
      const tokensOut = Math.max(0, Math.round(entry.tokens_out || 0));
      const costo = await this.calcularCosto(entry.proveedor, entry.modelo, tokensIn, tokensOut, entry.modelo_configurado);

      await this.prisma.ai_consumo.create({
        data: {
          empresa_id: entry.empresa_id,
          feature: (entry.feature || 'DESCONOCIDO').substring(0, 30),
          proveedor: (entry.proveedor || '').substring(0, 20),
          modelo: (entry.modelo || '').substring(0, 60),
          tokens_in: tokensIn,
          tokens_out: tokensOut,
          tokens_total: tokensIn + tokensOut,
          costo_usd: costo,
          credencial_propia: entry.credencial_propia ?? true,
          usuario_id: entry.usuario_id ?? null,
          referencia_id: entry.referencia_id ?? null,
          latencia_ms: entry.latencia_ms ?? null,
          ok: entry.ok ?? true,
          error: entry.error ? entry.error.substring(0, 2000) : null,
        },
      });
    } catch (err) {
      this.logger.error(
        `No se pudo registrar consumo IA (empresa=${entry.empresa_id}, feature=${entry.feature}): ${
          err instanceof Error ? err.message : 'unknown'
        }`,
      );
    }
  }

  /**
   * Verifica la cuota mensual de tokens de la empresa ANTES de llamar al LLM.
   * Lanza LimiteTokensExcedidoError si ya se alcanzó el tope. Si no hay tope
   * configurado (0), no hace nada. A diferencia de registrar(), este SÍ lanza:
   * es el gate que corta el gasto.
   */
  async verificarCuota(empresaId: string): Promise<void> {
    const cfg = await this.prisma.ai_empresa_config.findUnique({
      where: { empresa_id: empresaId },
      select: { limite_tokens_mensual: true, credencial_modo: true },
    });
    // En modo PROPIA la empresa paga su propia key: no aplica el tope de plataforma.
    if ((cfg?.credencial_modo ?? 'PROPIA') === 'PROPIA') return;

    const limite = cfg?.limite_tokens_mensual ?? 0;
    if (!limite || limite <= 0) return; // sin tope

    const consumido = await this.consumoMes(empresaId);
    if (consumido >= limite) {
      throw new LimiteTokensExcedidoError(
        `Límite mensual de IA alcanzado (${consumido.toLocaleString('es-PY')}/${limite.toLocaleString('es-PY')} tokens). ` +
          'Se renueva el 1° del próximo mes. Podés ajustar el tope en Configuración → IA.',
      );
    }
  }

  /** Estado de consumo del mes en curso para mostrar al usuario. */
  async estadoConsumo(empresaId: string) {
    const inicio = inicioMesUTC();
    const limite = await this.getLimite(empresaId);

    const [total, porFeature] = await Promise.all([
      this.prisma.ai_consumo.aggregate({
        where: { empresa_id: empresaId, creado_en: { gte: inicio } },
        _sum: { tokens_total: true, costo_usd: true },
      }),
      this.prisma.ai_consumo.groupBy({
        by: ['feature'],
        where: { empresa_id: empresaId, creado_en: { gte: inicio } },
        _sum: { tokens_total: true, costo_usd: true },
      }),
    ]);

    const consumido = total._sum.tokens_total ?? 0;
    const ilimitado = !limite || limite <= 0;
    return {
      periodo_desde: inicio.toISOString().slice(0, 10),
      limite_tokens_mensual: limite,
      ilimitado,
      consumido_mes: consumido,
      restante: ilimitado ? null : Math.max(0, limite - consumido),
      porcentaje_usado: ilimitado || limite === 0 ? 0 : Math.min(100, Math.round((consumido / limite) * 100)),
      costo_usd_mes: Number(total._sum.costo_usd ?? 0),
      por_feature: porFeature
        .map((f) => ({
          feature: f.feature,
          tokens: f._sum.tokens_total ?? 0,
          costo_usd: Number(f._sum.costo_usd ?? 0),
        }))
        .sort((a, b) => b.tokens - a.tokens),
    };
  }

  private async getLimite(empresaId: string): Promise<number> {
    const cfg = await this.prisma.ai_empresa_config.findUnique({
      where: { empresa_id: empresaId },
      select: { limite_tokens_mensual: true },
    });
    return cfg?.limite_tokens_mensual ?? 0;
  }

  private async consumoMes(empresaId: string): Promise<number> {
    const agg = await this.prisma.ai_consumo.aggregate({
      where: { empresa_id: empresaId, creado_en: { gte: inicioMesUTC() } },
      _sum: { tokens_total: true },
    });
    return agg._sum.tokens_total ?? 0;
  }

  /**
   * Costo en USD derivado de ai_tarifas (precio por 1M tokens). Busca la tarifa por el
   * modelo que respondió la API y, si ese alias no está tarifado, por el modelo
   * configurado para la empresa. Si no hay tarifa por ninguno devuelve null (los tokens
   * siguen medidos) y deja un WARN: antes fallaba en silencio y el costo quedaba en 0.
   */
  private async calcularCosto(
    proveedor: string,
    modelo: string,
    tokensIn: number,
    tokensOut: number,
    modeloConfigurado?: string | null,
  ): Promise<number | null> {
    const tarifas = await this.getTarifas();
    const candidatos = [modelo, modeloConfigurado].filter((m): m is string => !!m && !!m.trim());
    for (const candidato of candidatos) {
      const tarifa = tarifas.get(this.key(proveedor, candidato));
      if (!tarifa) continue;
      const costo = (tokensIn / 1_000_000) * tarifa.in + (tokensOut / 1_000_000) * tarifa.out;
      // 8 decimales, alineado a la precisión de la columna.
      return Math.round(costo * 1e8) / 1e8;
    }
    this.avisarTarifaFaltante(proveedor, modelo, modeloConfigurado);
    return null;
  }

  /** Un WARN por combinación proveedor/modelo: sirve para detectarlo sin inundar el log. */
  private avisarTarifaFaltante(proveedor: string, modelo: string, modeloConfigurado?: string | null): void {
    const clave = this.key(proveedor, modelo);
    if (this.tarifasFaltantes.has(clave)) return;
    this.tarifasFaltantes.add(clave);
    const alias = modeloConfigurado && modeloConfigurado !== modelo ? ` (configurado: "${modeloConfigurado}")` : '';
    this.logger.warn(
      `Sin tarifa en ai_tarifas para ${proveedor}/"${modelo}"${alias}: el consumo se mide en tokens pero queda sin costo. ` +
        `Cargá la tarifa para que el costo y el tope por plan salgan bien.`,
    );
  }

  private async getTarifas(): Promise<Map<string, { in: number; out: number }>> {
    const now = Date.now();
    if (this.tarifas && now - this.tarifas.loadedAt < AiUsageService.TARIFA_TTL_MS) {
      return this.tarifas.data;
    }
    const rows = await this.prisma.ai_tarifas.findMany({ where: { activo: true } });
    const data = new Map<string, { in: number; out: number }>();
    for (const r of rows) {
      data.set(this.key(r.proveedor, r.modelo), {
        in: Number(r.precio_in_por_1m),
        out: Number(r.precio_out_por_1m),
      });
    }
    this.tarifas = { data, loadedAt: now };
    // Con el catálogo recién leído, lo que faltaba puede haberse cargado: se vuelve a avisar si sigue faltando.
    this.tarifasFaltantes.clear();
    return data;
  }

  private key(proveedor: string, modelo: string): string {
    return `${(proveedor || '').toLowerCase()}::${(modelo || '').toLowerCase()}`;
  }
}
