import { currentExecutionBoundary, guardServiceEffect } from "@/lib/atendimento/fronteira-server";
import { TIPOS_DE_CASO, type TipoDeCaso } from "@/lib/ai/case-copy";
/**
 * Casos humanos (spec 15) — o loop assíncrono IA↔humano quando o agente esbarra
 * num bloqueio que só um humano resolve (aprovar desconto, confirmar política,
 * decidir algo fora do playbook). Duas tools nativas do engine:
 *   - open_human_case: o agente abre um caso (status='awaiting_human') com o
 *     bloqueio explicado — vira item na inbox humana (wiring da Wave 3+).
 *   - provide_case_update: quando o caso está 'awaiting_lead' (humano pediu algo
 *     ao lead) e o lead responde, o agente repassa a info e o caso volta a
 *     'awaiting_human'.
 * As transições disparadas pela RESPOSTA do humano (resolver/pedir info ao
 * lead/escalar) são chamadas pela rota API da Wave 5 — aqui só a mecânica de
 * estado, sempre idempotente.
 *
 * Disciplina (mesma dos irmãos schedule-followup/human-handoff): ids SEMPRE do
 * closure/args confiáveis, nunca do payload do modelo; toda transição é UPDATE
 * condicional (WHERE status = <precondição>) que nunca pisa em estado terminal;
 * UPDATE + INSERT do(s) evento(s) na MESMA transação — aqui via um único
 * statement (WITH ... INSERT ... SELECT FROM <cte>), que é atômico por natureza
 * e dispensa BEGIN/COMMIT explícito, então funciona tanto com pg.Pool quanto
 * com pg.PoolClient (Queryable).
 *
 * `agent_cases.lead_id` é FK para `crm_leads` (o lead do pipeline do CRM) — uma
 * entidade DIFERENTE do `contact_id`/`leadId` usado no resto do agent-engine (ver
 * draft-reply.ts). O espelho de estágio (mirrorLeadStageToCrm) já resolve o
 * negócio do contato para MOVER o card, mas nada aqui lê esse id, então
 * `agent_cases.lead_id` fica NULL sempre — o caso ancora em `conversation_id`
 * (CaseIds não carrega o contact_id: nada aqui o lê).
 */
import { z } from 'zod';
import type pg from 'pg';

import type { Queryable } from '../queue/queue';

export interface CaseIds {
  tenantId: string;
  conversationId: string;
  agentId?: string | null;
}

/**
 * O vocabulário de `agent_case_events.kind`, do lado do TypeScript.
 *
 * Existe para ser comparado com o CHECK do banco em
 * `tests/invariants/vocabulario-banco-x-typescript.test.ts` — é a única classe de
 * divergência que o compilador não enxerga, e o sintoma seria um `23514` num
 * INSERT de caminho pouco exercitado (o registro do agente no chamado é
 * exatamente um desses).
 */
export type CaseEventKind =
  | 'opened'
  | 'human_replied'
  | 'lead_asked'
  | 'lead_provided'
  | 'lead_unresponsive'
  | 'resolved'
  | 'escalated'
  | 'cancelled'
  | 'agent_noted'
  // (migration 0292) A equipe foi avisada no WhatsApp de que este caso abriu.
  // Escrito pelo handler do aviso DEPOIS do envio, com `actor_kind='system'`.
  | 'alert_sent';

/**
 * A tupla que o `z.enum` exige, derivada de `TIPOS_DE_CASO` — a fonte única do
 * vocabulário. Escrever a lista de novo aqui criaria a segunda cópia, e é assim
 * que o seletor da tela e o que a IA pode escolher divergem.
 */
const TIPOS_DE_CASO_KEYS = Object.keys(TIPOS_DE_CASO) as [TipoDeCaso, ...TipoDeCaso[]];

/** Whitelist EXATA do payload de open_human_case — mesmo padrão .strict() da F2-10/F3-02. */
export const openHumanCaseInputSchema = z.strictObject({
  title: z.string().min(1).max(200),
  summary: z.string().min(1).max(4_000),
  blocker: z.string().min(1).max(1_000),
  /**
   * Do que o caso trata. OPCIONAL e com default: um modelo antigo, um clone com
   * prompt diferente ou o fail-safe do guardrail continuam abrindo caso sem ele,
   * e o caso cai em `outro` em vez de ser recusado. Classificação é conveniência
   * de triagem — nunca pode ser motivo para o pedido do cliente não chegar.
   */
  kind: z.enum(TIPOS_DE_CASO_KEYS).optional(),
});
export type OpenHumanCaseInput = z.infer<typeof openHumanCaseInputSchema>;

/** Whitelist EXATA do payload de provide_case_update. */
export const provideCaseUpdateInputSchema = z.strictObject({
  case_id: z.string().uuid(),
  info: z.string().min(1).max(4_000),
});
export type ProvideCaseUpdateInput = z.infer<typeof provideCaseUpdateInputSchema>;

const OPEN_STATUSES = ['awaiting_human', 'awaiting_lead'] as const;

/**
 * True se há um caso 'awaiting_human'|'awaiting_lead' aberto para a conversa.
 * Checa UMA conversa (conversation_id), não o contato inteiro — um contato pode
 * ter >1 conversa por channel_session; o caso pertence a uma conversa.
 */
export async function hasOpenCaseForContact(
  db: Queryable,
  tenantId: string,
  conversationId: string,
): Promise<boolean> {
  const { rows } = await db.query<{ open: boolean }>(
    `select exists (
       select 1 from agent_cases
        where organization_id = $1 and conversation_id = $2
          and status = any($3::text[])
     ) as open`,
    [tenantId, conversationId, OPEN_STATUSES],
  );
  return rows[0]?.open === true;
}

export interface CaseAwaitingLead {
  id: string;
  eventId: string;
  title: string;
  /** O que o HUMANO pediu ao lead (body do evento need_lead_info mais recente) —
   * NÃO `agent_cases.blocker` (motivo original da IA travar; `markAwaitingLead`
   * nunca o atualiza — usá-lo aqui daria ao modelo a pergunta ERRADA).
   * `null` = o pedido é de outro atendimento (`fn_caso_na_fronteira_atual`) e não
   * vai ao modelo; o caso segue achável para a resposta do cliente ficar nele. */
  ask: string | null;
}

/** O sufixo do turno com o caso que espera o cliente. Pedido de outro atendimento não entra no texto. */
export function blocoDoCasoAguardandoCliente(caso: CaseAwaitingLead | null): string {
  if (caso === null) return '';
  if (caso.ask === null) {
    return (
      `## Caso aberto de um atendimento anterior deste cliente\n` +
      `Há um caso aberto (case_id: ${caso.id}), sobre "${caso.title}", que esperava uma informação dele em um atendimento anterior. ` +
      `Aquele pedido não vale para a conversa de agora: não retome nem cobre nada desse caso. ` +
      `Só se a mensagem dele trouxer por conta própria informação sobre esse assunto, chame provide_case_update com este case_id e a informação recebida — ` +
      `NÃO diga que já repassou/avisou o responsável sem chamar a tool.`
    );
  }
  return (
    `## Caso aguardando resposta deste cliente\n` +
    `Há um caso aberto (case_id: ${caso.id}) esperando uma informação dele: "${caso.ask}". ` +
    `Se a mensagem dele responde a isso, chame provide_case_update com este case_id e a informação recebida — ` +
    `NÃO diga que já repassou/avisou o responsável sem chamar a tool.`
  );
}

/**
 * O caso 'awaiting_lead' da conversa (humano pediu algo ao lead), se houver.
 * Usado no turno normal (inbound_turn) para dar ao modelo o `case_id` real que
 * `provide_case_update` exige — sem isso o modelo nunca tem como chamar a tool
 * quando o lead simplesmente responde a mensagem (o caminho comum; case_reply_turn
 * só cobre a ação do HUMANO, não a resposta do lead). No máximo 1 por conversa
 * (mesma garantia de `openCase`/dedup).
 */
export async function getCaseAwaitingLead(
  db: Queryable,
  tenantId: string,
  conversationId: string,
): Promise<CaseAwaitingLead | null> {
  const { rows } = await db.query<CaseAwaitingLead>(
    `select c.id, e.id as "eventId", c.title,
            case when public.fn_caso_na_fronteira_atual(c.organization_id, c.id) then e.body end as ask
       from agent_cases c
       join lateral (
         select id, body from agent_case_events
          where organization_id = c.organization_id and case_id = c.id and kind = 'human_replied' and human_action = 'need_lead_info'
          order by created_at desc, id desc
          limit 1
       ) e on true
      where c.organization_id = $1 and c.conversation_id = $2 and c.status = 'awaiting_lead'
      limit 1`,
    [tenantId, conversationId],
  );
  return rows[0] ?? null;
}

export type OpenCaseResult =
  | { ok: true; caseId: string }
  | { ok: false; error: { code: string; message: string } };

/**
 * A FRASE AO CLIENTE quando o pedido novo cai num caso que JÁ está aberto (#3097).
 *
 * Ela existe porque a alternativa era a IA improvisar — e o que ela improvisava
 * era "já registrei com a equipe"/"a equipe foi acionada", que é FALSO neste
 * caminho. Medido no relato do defeito: `agent_noted` não gera `alert_sent` e
 * não nasce linha em `entregas_de_aviso_de_caso` — a única superfície desta
 * feature que avisa alguém. O cliente ficava esperando quem nunca foi chamado.
 *
 * A frase diz só o que é verdade em QUALQUER caso: o pedido foi anotado na
 * conversa. Ela não afirma o registro no caso aberto, porque o agente só consegue
 * registrar quando tem a ferramenta `crm_add_case_note`; sem ela, a frase ainda
 * seria verdadeira. Também não promete aviso novo, prazo, nem que uma pessoa vai
 * responder por causa dele.
 */
export const FRASE_AO_CLIENTE_EM_PEDIDO_SOBRE_CASO_ABERTO =
  'anotei seu pedido aqui na nossa conversa; por enquanto não consigo te dar um prazo.';

/**
 * A RECUSA da guarda de caso aberto — com a instrução que impede a promessa.
 *
 * ## Por que INSTRUÇÃO, e não reenvio do aviso (issue #3097)
 *
 * O autor aceitava as duas saídas. Quando #3097 foi fechada, o reenvio do aviso à
 * equipe (opção a) esbarrava na `unique (organization_id, case_id, destino)` de
 * `entregas_de_aviso_de_caso` (migration 0292): com a entrega já `enviado`, nenhuma
 * linha nascia. A migration 9240 trocou essa chave por dois índices parciais, um
 * da abertura e um por `update_event_id`, para o retorno do cliente. A chave
 * deixou de ser o impedimento: `agent_noted` segue sem aviso porque o que falta
 * para o segundo pedido avisar é a decisão de produto do #3097, e a instrução
 * continua sendo a saída vigente.
 *
 * ## O que a instrução faz
 *
 * Devolve ao agente a verdade do caminho (`ninguém foi avisado agora`), proíbe a
 * promessa que o relato flagrou ("a equipe foi acionada", "já registrei para a
 * equipe", prazo, "alguém vai responder") e entrega a frase que o cliente PODE
 * ouvir — para a mensagem ao cliente mudar junto, sem depender de o modelo
 * adivinhar o que é honesto dizer.
 *
 * O registro do pedido novo no caso aberto é o destino certo quando o agente tem
 * a ferramenta `crm_add_case_note`: a nota entra na linha do tempo que quem
 * continuar o caso vai ler. A recusa não traz o `case_id`, e a única ferramenta
 * nativa que o recebe (`provide_case_update`, no caso `awaiting_lead`) trataria o
 * pedido novo como a resposta que o caso esperava; por isso a instrução veta esse
 * uso. O registro é condicional, e a frase ao cliente só afirma o registro se ele
 * aconteceu. O que não pode é o cliente ouvir que alguém foi ACIONADO.
 *
 * Pura e exportada de propósito: é o texto exato que o modelo lê no resultado da
 * tool `open_human_case` (o caminho `if (!res.ok) return res`), e é ele que o
 * teste de `aviso-de-pedido-novo-no-caso.test.ts` prende.
 */
export function recusaDeCasoAberto(): OpenCaseResult {
  return {
    ok: false,
    error: {
      code: 'case_already_open',
      message:
        'já existe um caso humano aberto para esta conversa: a equipe NÃO foi avisada deste pedido novo, e ' +
        'nada novo saiu para ninguém agora. NÃO diga ao cliente que a equipe foi acionada, avisada ou que o ' +
        'pedido foi encaminhado, e não prometa prazo nem que alguém vai responder por causa dele. Se você ' +
        'tiver a ferramenta crm_add_case_note, registre o pedido novo no caso aberto (a nota entra na linha do ' +
        'tempo que quem continuar o caso vai ler). NÃO use provide_case_update para isso: ela é só para a ' +
        'informação que o caso pediu ao cliente. Só diga ao cliente que o pedido ficou registrado no caso se ' +
        'você registrou de fato. Responda ao cliente apenas neste espírito: ' +
        `"${FRASE_AO_CLIENTE_EM_PEDIDO_SOBRE_CASO_ABERTO}" Aguarde a resposta do atendente antes de abrir outro caso.`,
    },
  };
}

/**
 * Abre o caso: INSERT agent_cases(status='awaiting_human') + INSERT
 * agent_case_events(kind='opened') em UM statement atômico. A guarda de dedup
 * (não abrir 2º caso pra mesma conversa) mora no `where not exists` do próprio
 * INSERT — sem essa condição haveria corrida entre a checagem e a escrita;
 * aqui checagem e escrita são a MESMA operação. Essa atomicidade check-and-write
 * vale DENTRO do statement; a garantia de não-duplicação entre transações
 * CONCORRENTES vem da serialização per-contact do job_queue
 * (uniq_job_queue_one_running_per_contact), não deste statement sozinho.
 */
export async function openCase(
  db: pg.Pool,
  ids: CaseIds,
  input: {
    title: string;
    summary: string;
    blocker: string;
    contextSnapshot?: Record<string, unknown>;
    source?: 'agent' | 'guardrail_autofallback';
    kind?: string;
  },
): Promise<OpenCaseResult> {
  await guardServiceEffect();
  const source = input.source ?? 'agent';
  const actorKind = source === 'agent' ? 'agent' : 'system';

  const { rows } = await db.query<{ case_id: string }>(
    `with new_case as (
       insert into agent_cases
         (organization_id, conversation_id, agent_id, title, summary, blocker, context_snapshot, source, kind)
       select $1, $2, $3, $4, $5, $6, $7::jsonb, $8, $11
        where not exists (
          select 1 from agent_cases
           where organization_id = $1 and conversation_id = $2
             and status = any($9::text[])
        )
       returning id
     )
     insert into agent_case_events (organization_id, case_id, kind, actor_kind)
     select $1, id, 'opened', $10
       from new_case
     returning case_id`,
    [
      ids.tenantId,
      ids.conversationId,
      ids.agentId ?? null,
      input.title,
      input.summary,
      input.blocker,
      JSON.stringify({ ...(input.contextSnapshot ?? {}), ...(currentExecutionBoundary() ? { service_boundary: currentExecutionBoundary() } : {}) }),
      source,
      OPEN_STATUSES,
      actorKind,
      // O default mora aqui e no banco: se um caminho novo esquecer de passar, a
      // linha nasce classificada como 'outro' em vez de nula.
      input.kind ?? 'outro',
    ],
  );

  const caseId = rows[0]?.case_id;
  if (caseId === undefined) {
    // A recusa é uma INSTRUÇÃO (não só um erro): é o texto que o modelo lê no
    // resultado da tool, e é ele que impede a promessa de que a equipe foi
    // acionada quando ninguém foi avisado (#3097).
    return recusaDeCasoAberto();
  }
  return { ok: true, caseId };
}

export type ProvideCaseUpdateResult =
  | { ok: true; foraDaFronteira: boolean }
  | { ok: false; error: { code: string; message: string } };

/**
 * awaiting_lead -> awaiting_human (o lead respondeu o que o humano pediu) +
 * evento 'lead_provided' (actor_kind='lead' — a info veio do lead, não do
 * agente nem do humano). De qualquer outro estado é no-op: {ok:false,
 * error.code:'invalid_case_state'}.
 *
 * O caso tem de ser DA CONVERSA do turno (`ids.conversationId`): o `case_id`
 * vem do modelo, e caso de outra conversa cai na mesma resposta de estado
 * errado — sem alterar nada e sem distinguir os dois.
 */
export async function provideCaseUpdate(
  db: pg.Pool,
  ids: CaseIds,
  input: { caseId: string; info: string; requestEventId?: string; clientMessageId?: string; sourceJobId?: string },
): Promise<ProvideCaseUpdateResult> {
  await guardServiceEffect();
  // A transição, o fato persistido e a outbox são um único efeito atômico.
  // O id do pedido vem da fotografia do turno, nunca da saída do modelo.
  // A fronteira do atendimento não barra esta escrita: ela decide o aviso e o
  // comando (`fn_ler_retorno_de_caso`), nunca se a resposta do cliente fica no
  // caso. Com ela vencida (conversa reaberta, próximo passo da demanda mudou), o
  // caso volta a `awaiting_human` sem aviso, o evento fica marcado para a tela e
  // o comando da abertura é recusado, porque não é o da última informação; a
  // equipe responde pela tela do caso.
  const { rows } = await db.query<{ case_id: string; fora_da_fronteira: boolean }>(
    `with request as (
       select id from agent_case_events
        where organization_id = $1 and case_id = $2
          and kind = 'human_replied' and human_action = 'need_lead_info'
        order by created_at desc, id desc limit 1
     ), updated as (
       update agent_cases ac
          set status = 'awaiting_human', updated_at = now()
        where organization_id = $1 and id = $2 and status = 'awaiting_lead'
          and conversation_id = $4
          and exists (select 1 from request where $5::uuid is null or id = $5)
          and ($6::uuid is null or exists (
            select 1 from messages m join conversations c on c.organization_id=m.organization_id and c.id=m.conversation_id
            where m.organization_id=$1 and m.conversation_id=$4 and m.id=$6 and m.direction='inbound'
              and m.contact_id=c.contact_id and m.service_revision=c.service_revision
              and ($7::uuid is null or exists(select 1 from job_queue j where j.organization_id=$1
                and j.contact_id=c.contact_id and j.id=$7 and j.kind='inbound_turn'
                and j.payload->>'inbound_message_id'=m.id::text))))
        returning id
     ), provided as (
       insert into agent_case_events (organization_id, case_id, kind, actor_kind, body, metadata)
       select $1, id, 'lead_provided', 'lead', $3,
              jsonb_build_object('request_event_id', (select id from request), 'client_message_id', $6::uuid, 'source_job_id', $7::uuid,
                'fora_da_fronteira', not public.fn_caso_na_fronteira_atual($1, id))
         from updated returning id, case_id, (metadata->>'fora_da_fronteira')::boolean as fora_da_fronteira
     ), queued as (
       insert into event_log (organization_id, event_type, entity_kind, entity_id, payload)
       select $1, 'ai.case_updated', 'agent_case', p.case_id,
              jsonb_build_object('case_id', p.case_id, 'case_event_id', p.id,
                'conversation_id', c.id, 'contact_id', c.contact_id,
                'config_revision', cfg.updated_at::text)
         from provided p
         join conversations c on c.organization_id = $1 and c.id = $4
         left join config_aviso_de_caso cfg on cfg.organization_id = $1
       returning entity_id as case_id
     ) select q.case_id, p.fora_da_fronteira from queued q join provided p on p.case_id = q.case_id`,
    [ids.tenantId, input.caseId, input.info, ids.conversationId, input.requestEventId ?? null, input.clientMessageId ?? null, input.sourceJobId ?? null],
  );

  const registrado = rows[0];
  if (registrado === undefined) {
    return {
      ok: false,
      error: {
        code: 'invalid_case_state',
        message: 'o caso não está aguardando informação do lead (awaiting_lead); nada foi alterado.',
      },
    };
  }
  return { ok: true, foraDaFronteira: registrado.fora_da_fronteira };
}

/**
 * As transições abaixo guardam o estado de entrada no próprio `where`, então o
 * statement insere os 2 eventos APENAS quando o update casou. `rowCount` é o
 * número de eventos inseridos: 0 = a corrida foi perdida (outro atendente já
 * respondeu) e nada mudou. Quem chama precisa saber disso para não reportar
 * sucesso de um no-op — daí o boolean em vez de void.
 */
function transitioned(rowCount: number | null): boolean {
  return (rowCount ?? 0) > 0;
}

/**
 * awaiting_human -> resolved (closed_at=now). Dois eventos no mesmo statement:
 * 'human_replied' (human_action='resolved', o registro da AÇÃO do humano) e
 * 'resolved' (o registro da transição do CASO). De qualquer outro estado é
 * no-op — nunca pisa em caso já resolvido/escalado/cancelado.
 */
export async function resolveCaseFromHuman(
  db: Queryable,
  tenantId: string,
  caseId: string,
  actorUserId: string,
  note: string,
): Promise<boolean> {
  const { rowCount } = await db.query(
    `with updated as (
       update agent_cases
          set status = 'resolved', closed_at = now(), updated_at = now()
        where organization_id = $1 and id = $2 and status = 'awaiting_human'
        returning id
     )
     insert into agent_case_events (organization_id, case_id, kind, actor_kind, actor_user_id, human_action, body)
     select $1::uuid, id, 'human_replied', 'human', $3::uuid, 'resolved', $4::text from updated
     union all
     select $1::uuid, id, 'resolved', 'human', $3::uuid, null::text, null::text from updated`,
    [tenantId, caseId, actorUserId, note],
  );
  return transitioned(rowCount);
}

/**
 * awaiting_human -> awaiting_lead (o humano precisa de mais info do lead).
 * Eventos: 'human_replied' (human_action='need_lead_info') + 'lead_asked'.
 */
export async function markAwaitingLead(
  db: Queryable,
  tenantId: string,
  caseId: string,
  actorUserId: string,
  ask: string,
): Promise<boolean> {
  const { rowCount } = await db.query(
    `with updated as (
       update agent_cases
          set status = 'awaiting_lead', updated_at = now()
        where organization_id = $1 and id = $2 and status = 'awaiting_human'
        returning id
     )
     insert into agent_case_events (organization_id, case_id, kind, actor_kind, actor_user_id, human_action, body)
     select $1::uuid, id, 'human_replied', 'human', $3::uuid, 'need_lead_info', $4::text from updated
     union all
     select $1::uuid, id, 'lead_asked', 'human', $3::uuid, null::text, null::text from updated`,
    [tenantId, caseId, actorUserId, ask],
  );
  return transitioned(rowCount);
}

/**
 * awaiting_human -> escalated (closed_at=now). Eventos: 'human_replied'
 * (human_action='escalate') + 'escalated'. O handoff humano em si (force_human
 * etc.) é acionado pela ROTA (Wave 5), não aqui — este só fecha o caso.
 */
export async function escalateCase(
  db: Queryable,
  tenantId: string,
  caseId: string,
  actorUserId: string,
  reason: string,
): Promise<boolean> {
  const { rowCount } = await db.query(
    `with updated as (
       update agent_cases
          set status = 'escalated', closed_at = now(), updated_at = now()
        where organization_id = $1 and id = $2 and status = 'awaiting_human'
        returning id
     )
     insert into agent_case_events (organization_id, case_id, kind, actor_kind, actor_user_id, human_action, body)
     select $1::uuid, id, 'human_replied', 'human', $3::uuid, 'escalate', $4::text from updated
     union all
     select $1::uuid, id, 'escalated', 'human', $3::uuid, null::text, null::text from updated`,
    [tenantId, caseId, actorUserId, reason],
  );
  return transitioned(rowCount);
}

/**
 * O AGENTE registrando o que aconteceu num chamado ABERTO.
 *
 * Ator `agent` e kind `agent_noted` (0100) porque a alternativa seria reusar
 * 'lead_provided'/'human_replied' — e aí a linha do tempo do chamado diria que
 * quem falou foi o lead ou a pessoa. Timeline que mente sobre o autor é pior que
 * timeline curta: ela é OBEDECIDA.
 *
 * A guarda de estado mora no `where exists` do próprio INSERT (mesma disciplina
 * do `openCase`): registrar em chamado já fechado seria escrever história depois
 * do fato, e checar antes num statement separado abriria corrida entre a
 * checagem e a escrita.
 *
 * @returns false quando o chamado não existe, é de outra organização ou já fechou.
 */
export async function registrarNotaDoAgente(
  db: Queryable,
  tenantId: string,
  caseId: string,
  body: string,
): Promise<boolean> {
  const { rowCount } = await db.query(
    `insert into agent_case_events (organization_id, case_id, kind, actor_kind, body)
     select $1::uuid, c.id, 'agent_noted', 'agent', $3::text
       from agent_cases c
      where c.organization_id = $1 and c.id = $2 and c.status = any($4::text[])`,
    [tenantId, caseId, body, OPEN_STATUSES],
  );
  return transitioned(rowCount);
}

/** Como um chamado pode terminar pela mão do agente. */
export type DesfechoDoChamado = 'resolvido' | 'sem_necessidade';

const STATUS_DO_DESFECHO: Record<DesfechoDoChamado, string> = {
  resolvido: 'resolved',
  sem_necessidade: 'cancelled',
};

const EVENTO_DO_DESFECHO: Record<DesfechoDoChamado, CaseEventKind> = {
  resolvido: 'resolved',
  sem_necessidade: 'cancelled',
};

/**
 * awaiting_human|awaiting_lead -> resolved|cancelled, pela mão do AGENTE.
 *
 * Separado de `resolveCaseFromHuman` de propósito: aquela grava
 * `actor_kind='human'` + `human_action='resolved'`, que é a afirmação de que uma
 * PESSOA decidiu. Chamá-la a partir do agente colocaria uma decisão humana
 * inventada no registro do chamado — e é desse registro que sai o resumo entregue
 * ao próximo atendente.
 *
 * Aceita os DOIS estados abertos (diferente das transições do humano, que só
 * saem de `awaiting_human`): o caso comum é o chamado ficar `awaiting_lead`, o
 * lead resolver sozinho, e ninguém ter como fechar aquilo — chamado imortal na
 * fila de outra pessoa.
 */
export async function encerrarChamadoPeloAgente(
  db: Queryable,
  tenantId: string,
  caseId: string,
  input: { desfecho: DesfechoDoChamado; nota: string },
): Promise<boolean> {
  return encerrarChamadoComAutor(db, tenantId, caseId, {
    ...input,
    autor: { kind: 'agent' },
  });
}

/**
 * Quem assina o encerramento. `human` carrega o `auth.users.id`, porque a
 * timeline do chamado tem de dizer QUAL pessoa decidiu — `agent` não tem autor
 * humano (`actor_user_id` fica null, como sempre ficou).
 *
 * ⚠️ O valor do banco é `'human'`, não `'user'`: o CHECK de
 * `agent_case_events.actor_kind` aceita `('agent','human','system','lead')`
 * (supabase/baseline.sql:7887) e `fn_atrito_metrics` conta toque humano por
 * `actor_kind='human'` (migration 0281). Escrever `'user'` daria `23514` num
 * UPDATE que já teria fechado o caso — o pior desfecho possível, porque a
 * transição viaja no mesmo statement da nota.
 */
type AutorDoEncerramento = { kind: 'agent' } | { kind: 'human'; userId: string };

/**
 * O encerramento com AUTOR explícito — a mecânica única dos dois caminhos
 * (`crm_close_human_case` do agente e a porta da pessoa na rota API).
 *
 * Um statement por autor, e não um com o ator parametrizado: o caminho do
 * agente grava DOIS eventos (a nota em `agent_noted`, que a tela rotula "A IA
 * registrou o que aconteceu", e a transição), enquanto o da pessoa grava UM —
 * o `cancelled` com a nota no corpo. `agent_noted` assinado por gente faria a
 * timeline dizer que a IA agiu, e `human_replied` com `human_action` nulo
 * (o CHECK de `human_action` só conhece resolved/need_lead_info/escalate)
 * apareceria na tela como "Você respondeu" — mentira nos dois casos. A nota
 * continua em `agent_case_events`, que é o que o próximo atendente lê.
 */
async function encerrarChamadoComAutor(
  db: Queryable,
  tenantId: string,
  caseId: string,
  input: { desfecho: DesfechoDoChamado; nota: string; autor: AutorDoEncerramento },
): Promise<boolean> {
  if (input.autor.kind === 'agent') {
    const { rowCount } = await db.query(
      `with updated as (
         update agent_cases
            set status = $3, closed_at = now(), updated_at = now()
          where organization_id = $1 and id = $2 and status = any($6::text[])
          returning id
       )
       insert into agent_case_events (organization_id, case_id, kind, actor_kind, body)
       select $1::uuid, id, 'agent_noted', 'agent', $5::text from updated
       union all
       select $1::uuid, id, $4::text, 'agent', null::text from updated`,
      [
        tenantId,
        caseId,
        STATUS_DO_DESFECHO[input.desfecho],
        EVENTO_DO_DESFECHO[input.desfecho],
        input.nota,
        OPEN_STATUSES,
      ],
    );
    return transitioned(rowCount);
  }

  const { rowCount } = await db.query(
    `with updated as (
       update agent_cases
          set status = $3, closed_at = now(), updated_at = now()
        where organization_id = $1 and id = $2 and status = any($6::text[])
        returning id
     )
     insert into agent_case_events (organization_id, case_id, kind, actor_kind, actor_user_id, body)
     select $1::uuid, id, $4::text, 'human', $7::uuid, $5::text from updated`,
    [
      tenantId,
      caseId,
      STATUS_DO_DESFECHO[input.desfecho],
      EVENTO_DO_DESFECHO[input.desfecho],
      input.nota,
      OPEN_STATUSES,
      input.autor.userId,
    ],
  );
  return transitioned(rowCount);
}

/**
 * A PORTA DA PESSOA para o caso que a vida já resolveu (#3810).
 *
 * Até aqui o único caminho humano era `POST .../reply`, e as TRÊS ações dele
 * mandam mensagem ao cliente: `resolved`/`need_lead_info` enfileiram
 * `case_reply_turn` (a IA volta a falar) e `escalate` chama o handoff. Um caso
 * que perdeu o objeto — o cliente comprou em outro lugar, o problema se
 * resolveu sozinho — só podia ser fechado MENTINDO para o cliente, ou ficava
 * imortal na fila de alguém.
 *
 * O desfecho certo já existia para o agente (`crm_close_human_case` com
 * `outcome: 'sem_necessidade'` grava `cancelled` e não enfileira nada). Esta
 * função é o MESMO caminho — a mesma `encerrarChamadoComAutor`, mudando só o
 * ator — mais o que a pessoa espera que aconteça quando fecha um caso: os
 * avisos do sino que apontam para ele deixam de existir.
 *
 * Não enfileira job, não faz handoff e não avisa o cliente de propósito: quem
 * escolhe "encerrar sem avisar" está dizendo que o cliente NÃO precisa de
 * mensagem. Qualquer efeito colateral aqui seria o defeito de volta.
 *
 * @returns false quando o caso não é da organização ou já não está aberto (a
 * corrida com outro atendente é perdida sem efeito nenhum).
 */
export async function encerrarChamadoPelaPessoa(
  db: Queryable,
  tenantId: string,
  caseId: string,
  actorUserId: string,
  nota: string,
): Promise<boolean> {
  const encerrado = await encerrarChamadoComAutor(db, tenantId, caseId, {
    desfecho: 'sem_necessidade',
    nota,
    autor: { kind: 'human', userId: actorUserId },
  });
  if (!encerrado) return false;
  await resolverAvisosDoCaso(db, tenantId, caseId);
  return true;
}

/**
 * Fecha os avisos do sino (`agent_inbox_items`) que apontam para o caso.
 *
 * O fechamento normal passa pelo barramento (`ai.case_closed` →
 * `escalacao.caso-na-central`), que só resolve os itens `kind='other'` — e é
 * assíncrono: entre fechar o caso e o dreno rodar, o sino segue cobrando uma
 * decisão que já foi tomada. Os itens `case_stale` e
 * `aviso_de_caso_nao_entregue`, que também nascem com `ref_kind='agent_case'`,
 * nem passam por aquele consumidor. Aqui a pessoa vê o sino limpo no mesmo
 * commit em que fechou o caso.
 *
 * Casa por `ref_id` (o id do caso), que é o que o issue pede, e não por
 * `ref_kind`: o id é uuid de caso e nenhum outro aviso aponta para ele.
 */
export async function resolverAvisosDoCaso(
  db: Queryable,
  tenantId: string,
  caseId: string,
): Promise<number> {
  const { rowCount } = await db.query(
    `update agent_inbox_items
        set status = 'resolved', resolved_at = now()
      where organization_id = $1 and ref_id = $2 and status in ('open','ack')`,
    [tenantId, caseId],
  );
  return rowCount ?? 0;
}

/** Resumo curto do caso para o inbox de escalação (mesmo espírito de buildHandoffSummary). */
export function buildCaseSummary(caseRow: { title: string; summary: string; blocker: string }): string {
  return `${caseRow.title}\n${caseRow.summary}\nBloqueio: ${caseRow.blocker}`;
}
