import Anthropic from '@anthropic-ai/sdk';
import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { Prisma, tickets } from '@prisma/client';
import { Imagen, leerImagenes } from 'src/capturas/imagenes';
import { bloquesDeImagen, LectorCapturasService } from 'src/capturas/lector.service';
import { CatalogoService } from 'src/catalogo/catalogo.service';
import { envs } from 'src/config/envs';
import { ConfigOperativaService } from 'src/config-operativa/config-operativa.service';
import { ConsultorService } from 'src/consultas/consultor.service';
import { IncidenciasService } from 'src/incidencias/incidencias.service';
import { EnrutadorManualService } from 'src/manual/enrutador.service';
import { ManualService } from 'src/manual/manual.service';
import { AnthropicService } from 'src/llm/anthropic.service';
import { costoUsd, UsoTokens } from 'src/llm/precios';
import { extraccionDirecta } from 'src/pipeline/extraccion';
import { ExtractorService } from 'src/pipeline/extractor.service';
import { HerramientasTurno, mencionaOtroRuc, PROGRESO, RegistroTraza } from 'src/pipeline/herramientas';
import { decidirPlan } from 'src/pipeline/maquina-estados';
import { PROMPT_VERSION } from 'src/pipeline/prompts';
import { RedactorService, ResultadoRedactor } from 'src/pipeline/redactor.service';
import { resolverResultadoTurno } from 'src/pipeline/resultado';
import { EventoTurno, leerEstadoBot } from 'src/pipeline/tipos';
import { PrismaService } from 'src/prisma/prisma.service';
import { Sesion } from 'src/sesion/sesion.types';

type Mensaje = Anthropic.Beta.BetaMessageParam;

/** Cuántos mensajes anteriores del hilo ve el modelo. */
const HISTORIAL = 16;

const sinServicio = (telefono: string) =>
  `En este momento el asistente no está disponible. Si necesitás ayuda, comunicate con Novasis al ${telefono}.`;
const noPuedo = (telefono: string) => `No puedo resolver esto desde acá. Comunicate con Novasis al ${telefono}.`;

const delimitar = (texto: string) => `<mensaje_cliente>\n${texto}\n</mensaje_cliente>`;

/** El mensaje del cliente como lo ve el modelo: su texto y, si adjuntó capturas, lo que muestran. */
const mensajeCliente = (texto: string, lectura: string | null) =>
  delimitar(texto || '(sin texto: mandó solo la captura)') +
  (lectura ? `\n<captura_del_cliente>\n${lectura}\n</captura_del_cliente>` : '');

@Injectable()
export class TicketsService {
  private readonly logger = new Logger(TicketsService.name);
  /** Un turno a la vez por ticket (una instancia del servicio). */
  private readonly enCurso = new Set<string>();

  constructor(
    private readonly prisma: PrismaService,
    private readonly catalogo: CatalogoService,
    private readonly config: ConfigOperativaService,
    private readonly llm: AnthropicService,
    private readonly extractor: ExtractorService,
    private readonly redactor: RedactorService,
    private readonly consultor: ConsultorService,
    private readonly manual: ManualService,
    private readonly enrutador: EnrutadorManualService,
    private readonly lector: LectorCapturasService,
    private readonly incidencias: IncidenciasService,
  ) {}

  // ── Turno ────────────────────────────────────────────────────────────

  /**
   * Valida y reserva el ticket (los errores salen como HTTP normal, antes del
   * stream) y devuelve el generador de eventos del turno.
   */
  async iniciarTurno(
    sesion: Sesion,
    ticketId: string | null,
    texto: string,
    adjuntas: Array<{ tipo: string; datos: string }> = [],
  ): Promise<AsyncGenerator<EventoTurno>> {
    texto = texto.trim();
    const imagenes = leerImagenes(adjuntas);
    if (!texto && !imagenes.length) throw new BadRequestException('Escribí tu consulta o adjuntá una captura.');
    let ticket: tickets;
    let nuevo = false;
    if (ticketId) {
      ticket = await this.ticketPropio(sesion, ticketId);
      if (ticket.estado !== 'ABIERTO')
        throw new ConflictException('El ticket está cerrado. Iniciá una consulta nueva.');
    } else {
      ticket = await this.prisma.tickets.create({
        data: {
          empresa_id: sesion.empresaId,
          empresa_ruc: sesion.empresaRuc,
          empresa_razon_social: sesion.empresaRazonSocial,
          usuario_id: sesion.usuarioId,
          usuario_nombre: sesion.nombre,
          titulo: titulo(texto || 'Consulta con captura'),
          prompt_version: PROMPT_VERSION,
        },
      });
      nuevo = true;
    }
    if (this.enCurso.has(ticket.id)) throw new ConflictException('Ya hay una respuesta en curso en este ticket.');
    this.enCurso.add(ticket.id);
    return this.turno(sesion, ticket, texto, imagenes, nuevo);
  }

  private async *turno(
    sesion: Sesion,
    ticket: tickets,
    texto: string,
    imagenes: Imagen[],
    nuevo: boolean,
  ): AsyncGenerator<EventoTurno> {
    try {
      if (nuevo) yield { tipo: 'ticket', id: ticket.id, numero: ticket.numero };

      const anteriores = await this.prisma.mensajes.findMany({
        where: { ticket_id: ticket.id },
        orderBy: { orden: 'desc' },
        take: HISTORIAL,
      });
      anteriores.reverse();
      let orden = (anteriores.at(-1)?.orden ?? 0) + 1;
      const delCliente = await this.prisma.mensajes.create({
        data: {
          ticket_id: ticket.id,
          orden,
          autor: 'cliente',
          texto,
          adjuntos: {
            create: imagenes.map((i) => ({
              ticket_id: ticket.id,
              tipo: i.tipo,
              datos: new Uint8Array(i.datos),
              bytes: i.datos.length,
            })),
          },
        },
      });
      orden += 1;

      // Modo degradado: sin LLM, apagado para la empresa, o tope diario superado.
      if (
        !this.llm.disponible ||
        !(await this.config.iaActiva(sesion.empresaId)) ||
        (await this.topeSuperado(sesion))
      ) {
        const aviso = sinServicio(await this.config.telefonoContacto());
        await this.prisma.mensajes.create({ data: { ticket_id: ticket.id, orden, autor: 'sistema', texto: aviso } });
        await this.prisma.tickets.update({ where: { id: ticket.id }, data: { last_activity_at: new Date() } });
        yield { tipo: 'texto', delta: aviso };
        yield {
          tipo: 'fin',
          ticket_id: ticket.id,
          numero: ticket.numero,
          resultado: ticket.resultado,
          pedir_conformidad: false,
        };
        return;
      }

      yield* this.turnoConAgente(sesion, ticket, texto, imagenes, delCliente.id, anteriores, orden);
    } finally {
      this.enCurso.delete(ticket.id);
    }
  }

  private async *turnoConAgente(
    sesion: Sesion,
    ticket: tickets,
    texto: string,
    imagenes: Imagen[],
    mensajeId: string,
    anteriores: Array<{ autor: string; texto: string; lectura_capturas: string | null }>,
    orden: number,
  ): AsyncGenerator<EventoTurno> {
    const inicio = Date.now();
    const trazas: RegistroTraza[] = [];
    const estado = leerEstadoBot(ticket.estado_bot);
    const contexto = anteriores
      .filter((m) => m.autor !== 'sistema')
      .slice(-6)
      .map(
        (m) =>
          `${m.autor === 'cliente' ? 'Cliente' : 'Asistente'}: ${m.texto.slice(0, 500)}` +
          (m.lectura_capturas ? ` [captura: ${m.lectura_capturas.slice(0, 300)}]` : ''),
      );
    const herramientas = new HerramientasTurno(
      this.catalogo,
      sesion,
      estado,
      { ultimos: (s) => this.ultimos(s) },
      (t) => trazas.push(t),
      { consultor: this.consultor, contexto, manual: this.manual, enrutador: this.enrutador },
    );

    // Cable trampa (§6.6): no es la frontera (las vistas filtran igual), es el aviso para el reporte.
    if (mencionaOtroRuc(texto, sesion)) {
      trazas.push({
        herramienta: 'deteccion_otro_ruc',
        parametros: { texto: texto.slice(0, 300) },
        respuesta: null,
        proyeccion: null,
        duracionMs: 0,
        fueraDeAlcance: true,
      });
    }

    try {
      // Capturas: primero se transcriben, y la consulta pasa a ser lo escrito + lo que muestran.
      let lectura: Awaited<ReturnType<LectorCapturasService['leer']>> | null = null;
      if (imagenes.length) {
        yield { tipo: 'progreso', texto: imagenes.length > 1 ? 'Mirando tus capturas…' : 'Mirando tu captura…' };
        lectura = await this.lector.leer(imagenes, texto);
        if (lectura.texto) {
          await this.prisma.mensajes.update({ where: { id: mensajeId }, data: { lectura_capturas: lectura.texto } });
        }
      }
      const consulta = lectura?.texto ? `${texto}\n\n[Captura que adjuntó]\n${lectura.texto}` : texto;

      yield { tipo: 'progreso', texto: 'Leyendo tu consulta…' };
      const directa = extraccionDirecta(consulta);
      const ext = directa
        ? { extraccion: directa, uso: null, modelo: await this.config.modelo('extractor'), ms: 0 }
        : await this.extractor.extraer(contexto, consulta);
      const plan = decidirPlan(ext.extraccion, consulta, estado);
      if (plan.pideReferencia) estado.pedidosDeReferencia += 1;

      const mensajes = historialParaModelo(anteriores);

      // Consultas previas determinísticas: los datos (y el diagnóstico) los pone el código, no el modelo.
      let datos = '';
      for (const c of plan.consultas) {
        const etiqueta = PROGRESO[c.herramienta];
        if (etiqueta) yield { tipo: 'progreso', texto: etiqueta };
        const r = await herramientas.ejecutar(c.herramienta, c.input);
        datos += `\n\n<datos_del_sistema consulta="${c.herramienta}">\n${r.contenido}\n</datos_del_sistema>`;
      }
      const textoTurno = mensajeCliente(texto, lectura?.texto || null) + datos;
      // El redactor ve también la imagen en el turno en que llega; después, solo la lectura.
      mensajes.push({
        role: 'user',
        content: imagenes.length ? [...bloquesDeImagen(imagenes), { type: 'text', text: textoTurno }] : textoTurno,
      });
      mensajes.push({ role: 'system', content: plan.instruccion });

      const telefono = await this.config.telefonoContacto();
      const generador = this.redactor.redactar({ mensajes, herramientas, telefono });
      let red: ResultadoRedactor;
      for (;;) {
        const paso = await generador.next();
        if (paso.done) {
          red = paso.value;
          break;
        }
        yield paso.value;
      }

      let respuesta = red.texto.trim();
      if (!respuesta) {
        respuesta = noPuedo(telefono);
        yield { tipo: 'texto', delta: respuesta };
      }

      const res = resolverResultadoTurno({
        cierre: red.cierre,
        veredicto: herramientas.veredicto,
        huboConsultaRealAntes: ticket.es_consulta_real,
        rechazoDelModelo: red.rechazo,
      });

      // Lector de capturas + extractor + redactor + lo que gastaron las herramientas con modelo (el consultor).
      if (lectura?.uso) herramientas.usos.push({ modelo: lectura.modelo, uso: lectura.uso });
      const usoTotal = [...herramientas.usos.map((u) => u.uso), red.uso].reduce(
        (a, b) => sumar(a, b),
        ext.uso ?? cero(),
      );
      const costo =
        (ext.uso ? costoUsd(ext.modelo, ext.uso) : 0) +
        costoUsd(red.modelo, red.uso) +
        herramientas.usos.reduce((a, u) => a + costoUsd(u.modelo, u.uso), 0);
      const resultadoFinal = res.resultado ?? ticket.resultado;

      await this.prisma.$transaction([
        this.prisma.mensajes.create({
          data: {
            ticket_id: ticket.id,
            orden,
            autor: 'bot',
            texto: respuesta,
            tokens_in: usoTotal.input_tokens + (usoTotal.cache_creation_input_tokens ?? 0),
            tokens_out: usoTotal.output_tokens,
            tokens_cache_read: usoTotal.cache_read_input_tokens ?? 0,
            costo_usd: new Prisma.Decimal(costo.toFixed(6)),
            modelo: red.modelo,
            primer_token_ms: red.primerTokenMs,
            latencia_ms: Date.now() - inicio,
          },
        }),
        this.prisma.tickets.update({
          where: { id: ticket.id },
          data: {
            estado_bot: estado as unknown as Prisma.InputJsonValue,
            resultado: resultadoFinal,
            categoria: res.categoria ?? ticket.categoria,
            motivo_no_resuelto: resultadoFinal === 'NO_RESUELTO' ? (res.motivo ?? ticket.motivo_no_resuelto) : null,
            es_consulta_real: ticket.es_consulta_real || res.esConsultaReal || estado.huboConsultaReal,
            documento_tipo: estado.documentoActivo?.tipo ?? ticket.documento_tipo,
            documento_id: estado.documentoActivo?.id ?? ticket.documento_id,
            nro_documento: estado.documentoActivo?.nro ?? ticket.nro_documento,
            modelo: red.modelo,
            prompt_version: PROMPT_VERSION,
            primera_respuesta_at: ticket.primera_respuesta_at ?? new Date(),
            last_activity_at: new Date(),
          },
        }),
        this.prisma.trazas.createMany({ data: filasTrazas(trazas, ticket.id, orden, sesion) }),
        this.prisma.$executeRaw`
          INSERT INTO consumo_diario (empresa_id, fecha, tokens_in, tokens_out, costo_usd)
          VALUES (${sesion.empresaId}::uuid, CURRENT_DATE, ${usoTotal.input_tokens + (usoTotal.cache_creation_input_tokens ?? 0)},
                  ${usoTotal.output_tokens}, ${costo})
          ON CONFLICT (empresa_id, fecha) DO UPDATE SET
            tokens_in  = consumo_diario.tokens_in  + EXCLUDED.tokens_in,
            tokens_out = consumo_diario.tokens_out + EXCLUDED.tokens_out,
            costo_usd  = consumo_diario.costo_usd  + EXCLUDED.costo_usd`,
      ]);

      // Incidencia para Novasis (§6.8): solo si ESTE turno terminó no resuelto por un motivo
      // accionable. Decide el código; nunca corta la respuesta.
      if (res.resultado === 'NO_RESUELTO') {
        void this.incidencias.evaluar({
          numero: ticket.numero,
          empresaRuc: ticket.empresa_ruc,
          empresaRazonSocial: ticket.empresa_razon_social,
          motivo: res.motivo ?? null,
          categoria: res.categoria ?? ticket.categoria,
        });
      }

      yield {
        tipo: 'fin',
        ticket_id: ticket.id,
        numero: ticket.numero,
        resultado: resultadoFinal,
        pedir_conformidad: res.resultado === 'RESUELTO' || res.resultado === 'RESUELTO_AUTOSERVICIO',
      };
    } catch (err) {
      this.logger.error(
        `Turno del ticket SOP-${ticket.numero} falló: ${err instanceof Error ? err.stack : String(err)}`,
      );
      const aviso = noPuedo(await this.config.telefonoContacto().catch(() => envs.telefonoContacto));
      await this.prisma.mensajes
        .create({ data: { ticket_id: ticket.id, orden, autor: 'sistema', texto: aviso } })
        .catch(() => undefined);
      if (trazas.length) {
        await this.prisma.trazas
          .createMany({ data: filasTrazas(trazas, ticket.id, orden, sesion) })
          .catch(() => undefined);
      }
      yield { tipo: 'error', mensaje: aviso };
    }
  }

  private async topeSuperado(sesion: Sesion): Promise<boolean> {
    const [hoy] = await this.prisma.$queryRaw<Array<{ total: bigint | null }>>`
      SELECT (tokens_in + tokens_out)::bigint AS total FROM consumo_diario
       WHERE empresa_id = ${sesion.empresaId}::uuid AND fecha = CURRENT_DATE`;
    return Number(hoy?.total ?? 0) >= (await this.config.topeTokensDiarios(sesion.empresaId));
  }

  // ── Historial (la sección Tickets) ───────────────────────────────────

  async listar(sesion: Sesion, pagina = 1) {
    const tam = 20;
    const where = { usuario_id: sesion.usuarioId, empresa_id: sesion.empresaId };
    const [total, filas] = await Promise.all([
      this.prisma.tickets.count({ where }),
      this.prisma.tickets.findMany({
        where,
        orderBy: { last_activity_at: 'desc' },
        skip: (pagina - 1) * tam,
        take: tam,
        select: {
          id: true,
          numero: true,
          titulo: true,
          estado: true,
          resultado: true,
          categoria: true,
          conformidad: true,
          nro_documento: true,
          created_at: true,
          last_activity_at: true,
        },
      }),
    ]);
    return { total, pagina, tickets: filas };
  }

  async detalle(sesion: Sesion, id: string) {
    const t = await this.ticketPropio(sesion, id);
    const mensajes = await this.prisma.mensajes.findMany({
      where: { ticket_id: t.id },
      orderBy: { orden: 'asc' },
      select: {
        orden: true,
        autor: true,
        texto: true,
        created_at: true,
        adjuntos: { select: { id: true, tipo: true } },
      },
    });
    return {
      id: t.id,
      numero: t.numero,
      titulo: t.titulo,
      estado: t.estado,
      resultado: t.resultado,
      conformidad: t.conformidad,
      created_at: t.created_at,
      mensajes,
    };
  }

  async conformidad(sesion: Sesion, id: string, valor: 'POSITIVA' | 'NEGATIVA') {
    const t = await this.ticketPropio(sesion, id);
    if (t.estado !== 'ABIERTO') throw new ConflictException('El ticket ya está cerrado.');
    const ultimo = await this.prisma.mensajes.findFirst({ where: { ticket_id: t.id }, orderBy: { orden: 'desc' } });
    const orden = (ultimo?.orden ?? 0) + 1;
    const ahora = new Date();
    if (valor === 'NEGATIVA') {
      void this.incidencias.evaluar({
        numero: t.numero,
        empresaRuc: t.empresa_ruc,
        empresaRazonSocial: t.empresa_razon_social,
        motivo: 'cliente_disconforme',
        categoria: t.categoria,
      });
      await this.prisma.$transaction([
        this.prisma.mensajes.create({
          data: { ticket_id: t.id, orden, autor: 'sistema', texto: noPuedo(await this.config.telefonoContacto()) },
        }),
        this.prisma.tickets.update({
          where: { id: t.id },
          data: {
            conformidad: 'NEGATIVA',
            resultado: 'NO_RESUELTO',
            motivo_no_resuelto: 'cliente_disconforme',
            estado: 'CERRADO',
            cerrado_at: ahora,
            last_activity_at: ahora,
          },
        }),
      ]);
    } else {
      await this.prisma.tickets.update({
        where: { id: t.id },
        data: {
          conformidad: 'POSITIVA',
          resultado: t.resultado ?? 'RESUELTO',
          estado: 'CERRADO',
          cerrado_at: ahora,
          last_activity_at: ahora,
        },
      });
    }
    return this.detalle(sesion, id);
  }

  async adjunto(sesion: Sesion, ticketId: string, adjuntoId: string) {
    const t = await this.ticketPropio(sesion, ticketId);
    const a = await this.prisma.adjuntos.findFirst({ where: { id: adjuntoId, ticket_id: t.id } });
    if (!a) throw new NotFoundException('Captura no encontrada');
    return a;
  }

  /** El ticket es del usuario Y de su empresa actual: un ticket de otra empresa no se toca (§4.2). */
  private async ticketPropio(sesion: Sesion, id: string): Promise<tickets> {
    const t = await this.prisma.tickets.findUnique({ where: { id } });
    if (!t || t.usuario_id !== sesion.usuarioId) throw new NotFoundException('Ticket no encontrado');
    if (t.empresa_id !== sesion.empresaId) {
      throw new ForbiddenException('Este ticket es de otra empresa. Cambiá a esa empresa para verlo.');
    }
    return t;
  }

  private async ultimos(sesion: Sesion) {
    return this.prisma.tickets.findMany({
      where: { usuario_id: sesion.usuarioId, empresa_id: sesion.empresaId },
      orderBy: { created_at: 'desc' },
      take: 6,
      select: { numero: true, titulo: true, resultado: true, created_at: true },
    });
  }
}

function titulo(texto: string): string {
  const limpio = texto.replace(/\s+/g, ' ').trim();
  return limpio.length > 80 ? `${limpio.slice(0, 77)}…` : limpio || 'Consulta';
}

/** El historial va como texto: los bloques de razonamiento de turnos viejos no se reenvían. */
function historialParaModelo(
  anteriores: Array<{ autor: string; texto: string; lectura_capturas: string | null }>,
): Mensaje[] {
  const mensajes: Mensaje[] = [];
  for (const m of anteriores) {
    if (m.autor === 'cliente') mensajes.push({ role: 'user', content: mensajeCliente(m.texto, m.lectura_capturas) });
    else if (m.autor === 'bot' && mensajes.length > 0) mensajes.push({ role: 'assistant', content: m.texto });
  }
  return mensajes;
}

function cero(): UsoTokens {
  return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
}

function sumar(a: UsoTokens | null, b: UsoTokens): UsoTokens {
  return {
    input_tokens: (a?.input_tokens ?? 0) + b.input_tokens,
    output_tokens: (a?.output_tokens ?? 0) + b.output_tokens,
    cache_read_input_tokens: (a?.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0),
    cache_creation_input_tokens: (a?.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0),
  };
}

/** Las trazas registran el alcance que fijó el servidor (la sesión), no lo que pidió el modelo. */
function filasTrazas(
  trazas: RegistroTraza[],
  ticketId: string,
  orden: number,
  sesion: Sesion,
): Prisma.trazasCreateManyInput[] {
  return trazas.map((t) => ({
    ticket_id: ticketId,
    mensaje_orden: orden,
    herramienta: t.herramienta,
    parametros: json(t.parametros),
    empresa_id_efectivo: sesion.empresaId,
    usuario_id_efectivo: sesion.usuarioId,
    respuesta: json(t.respuesta),
    proyeccion: json(t.proyeccion),
    duracion_ms: t.duracionMs,
    error: t.error ?? null,
    fuera_de_alcance: t.fueraDeAlcance ?? false,
  }));
}

function json(v: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
  return v === null || v === undefined ? Prisma.JsonNull : (JSON.parse(JSON.stringify(v)) as Prisma.InputJsonValue);
}
