/**
 * Zod schemas + cross-reference validators for the AI Agents module (mcp_agent kind).
 * Spec 10 §3.2 + §4.3 + §4.5.
 *
 * Convention: "shape" schemas (no business rules) ship from here; cross-row
 * checks (credential.validated_at, channel_session.status, model exists) live
 * in `validateVersionReferences` and run BEFORE save AND inside the publish
 * Postgres function (defense in depth).
 */
import { z } from "zod";
import { normalizarTags, TAMANHO_MAXIMO_DA_TAG } from "@/lib/contacts/tag-normalizada";
import { ehToolIdRemoto } from "@/lib/mcp/servidor-externo/ids";
import { ESCOLHAS_NO_ENVIO } from "@/lib/agenda/escolha-no-envio";
import { VALID_TOOL_IDS } from "@/lib/mcp/tools/catalog";
import { TETO_TOOLS_POR_AGENTE } from "@/lib/mcp/tools/selecao-por-pacote";
import { TETO_DO_PUBLICO } from "@/lib/agent-engine/agent/publico-do-gatilho";
import { IDS_DE_PROVEDOR } from "@/lib/ai/pontos/provedores";

/**
 * Derivado de `lib/ai/pontos/provedores.ts` (a lista única desde a 0127). Como
 * cópia à mão, esta constante mantinha `agent_turn`/`operator_turn` fora do
 * alcance da OpenRouter — justamente os dois pontos que a abertura do
 * vocabulário existia para atender.
 */
export const PROVIDERS = IDS_DE_PROVEDOR;
export type Provider = (typeof PROVIDERS)[number];

const UUID = z.string().uuid();

const triggerConfigSchema = z
  .object({
    events: z.array(z.enum(["message"])).default(["message"]),
    filters: z
      .object({
        ignore_groups: z.boolean().default(true),
        ignore_self: z.boolean().default(true),
        keyword_regex: z.string().nullable().optional().default(null),
        business_hours: z
          .object({
            timezone: z.string(),
            start: z.string(),
            end: z.string(),
            weekdays: z.array(z.number().int().min(0).max(6)),
            // Aviso de fora do horário (#1926). Sem esta linha o Zod descarta o
            // campo ao salvar e o aviso nunca liga pela tela. O teto é o mesmo
            // de TAMANHO_MAXIMO_DO_TEXTO em aviso-fora-do-horario.ts.
            notice: z.string().max(1000).nullable().optional(),
            // Aviso PRÓPRIO de feriado nacional (#3877). Opcional e com o mesmo
            // teto: sem esta linha o Zod DESCARTA a chave ao salvar (objeto de
            // schema com strip, como o `notice`) e o texto de feriado nunca
            // chegaria ao aviso.
            notice_feriado: z.string().max(1000).nullable().optional(),
            // "Não atender em feriado nacional" (#3877, doc 316 A). DESLIGADA de
            // fábrica: ausente vale `false` na leitura (`lerFechadoEmFeriadoNacional`).
            // Sem esta linha o Zod descarta a chave ao salvar e a chave da tela
            // nunca chegaria ao turno.
            fechado_em_feriado_nacional: z.boolean().optional(),
          })
          .nullable()
          .optional()
          .default(null),
        // PÚBLICO DO AGENTE (#3722). Seis listas opcionais, no jsonb da
        // versão — sem coluna nova, sem migration. Sem estas linhas o schema
        // `.strict()` RECUSA o payload da tela (o campo chegaria como chave
        // desconhecida) e o operador não conseguiria salvar o público.
        // `include_` = só atende quem cumpre; `exclude_` = não atende quem
        // cumpre, e a exclusão vence. A régua de decisão mora em
        // `lib/agent-engine/agent/publico-do-gatilho.ts`.
        include_tags: z.array(z.string().trim().min(1).max(60)).max(TETO_DO_PUBLICO).default([]),
        exclude_tags: z.array(z.string().trim().min(1).max(60)).max(TETO_DO_PUBLICO).default([]),
        include_pipeline_ids: z.array(UUID).max(TETO_DO_PUBLICO).default([]),
        exclude_pipeline_ids: z.array(UUID).max(TETO_DO_PUBLICO).default([]),
        // Origem do contato (`contacts.source`). Vocabulário ABERTO, como a
        // coluna: a tela oferece as origens conhecidas, e a API aceita qualquer
        // valor que um integrador grave em `source`.
        include_sources: z.array(z.string().trim().min(1).max(60)).max(TETO_DO_PUBLICO).default([]),
        exclude_sources: z.array(z.string().trim().min(1).max(60)).max(TETO_DO_PUBLICO).default([]),
      })
      .default({
        ignore_groups: true,
        ignore_self: true,
        keyword_regex: null,
        business_hours: null,
        include_tags: [],
        exclude_tags: [],
        include_pipeline_ids: [],
        exclude_pipeline_ids: [],
        include_sources: [],
        exclude_sources: [],
      }),
    concurrency: z.enum(["one_per_conversation", "one_per_contact"]).default("one_per_conversation"),
  })
  .strict();

export type TriggerConfig = z.infer<typeof triggerConfigSchema>;

const HHMM = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;

/**
 * Janela PROATIVA do follow-up. O fuso não é configurável aqui: quem executa
 * sempre usa `organizations.timezone`, para a faixa acompanhar o relógio da
 * empresa e não o knob anti-ban do número.
 */
const followupSendWindowSchema = z
  .object({
    start: z.string().regex(HHMM),
    end: z.string().regex(HHMM),
    weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
  })
  .strict()
  .refine((window) => window.end > window.start, {
    path: ["end"],
    message: "followup_window_end_must_be_after_start",
  });

// Task 7.2 — vínculo do agente com fluxos de follow-up publicados. Aditivo:
// `.default(...)` faz agents/versions existentes (sem este campo no payload)
// continuarem válidos, lidos com enabled=false/[] (comportamento inalterado).
// `flow_pointer_ids` referencia `followup_flow_pointers.id` — sem FK real de
// array no Postgres (mesma doutrina de `tool_ids`: domínio validado em app,
// não em constraint de banco).
//
// #490 acrescenta `send_window`: `null` preserva o comportamento histórico; uma
// faixa limita SOMENTE envios proativos, sem tocar o horário do inbound.
const followupConfigObjectSchema = z
  .object({
    enabled: z.boolean().default(false),
    flow_pointer_ids: z.array(UUID).max(20).default([]),
    send_window: followupSendWindowSchema.nullable().optional().default(null),
    callback_enabled: z.boolean().optional(),
    /**
     * #3813 — "Quando o contato tiver horário marcado", por FLUXO de follow-up:
     * `adiar` (padrão), `enviar` ou `nao_enviar` (pulo registrado com o motivo
     * `compromisso_na_agenda`).
     *
     * Opcional de propósito: os agentes/versões já gravados não têm a chave, e
     * ausente é o padrão — a MESMA tolerância de `send_window`. O schema é
     * `.strict()`, então sem esta linha a tela receberia "campo desconhecido" e
     * não conseguiria salvar o follow-up de quem usa a escolha.
     */
    quando_ha_compromisso: z.enum(ESCOLHAS_NO_ENVIO).optional(),
  })
  .strict();

const followupConfigSchema = followupConfigObjectSchema.default({
  enabled: false,
  flow_pointer_ids: [],
  send_window: null,
});

const followupPatchSchema = followupConfigObjectSchema
  .extend({
    enabled: followupConfigObjectSchema.shape.enabled.removeDefault(),
    flow_pointer_ids: followupConfigObjectSchema.shape.flow_pointer_ids.removeDefault(),
    send_window: followupConfigObjectSchema.shape.send_window.removeDefault(),
    callback_enabled: followupConfigObjectSchema.shape.callback_enabled,
  })
  .partial();

export type FollowupConfig = z.infer<typeof followupConfigSchema>;

const versionShapeSchema = z
  .object({
    system_prompt: z.string().trim().min(10).max(20000),
    provider: z.enum(PROVIDERS),
    model: z.string().trim().min(1).max(120),
    /**
     * `null` = usar a chave que veio na INSTALAÇÃO.
     *
     * Era UUID obrigatório, e isso trancava a porta para o cenário mais comum do
     * produto: quem instala pelo kit cola a chave no `.env` e nunca abre a tela
     * de Credenciais — não existe uma única linha em `ai_provider_credentials`.
     * O runtime SEMPRE soube lidar com isso (`chaveDePlataforma` em
     * `lib/ai/runtime/agent.ts`, mesma precedência de `resolveOrgLlmConfig`); só
     * o editor não deixava salvar. O efeito: o agente do onboarding tinha de
     * nascer `rag_bot`, no editor legado, e as capacidades ficavam invisíveis
     * para o dono.
     *
     * ⚠️ QUEM ACEITA `null` PRECISA CONFERIR QUE A CHAVE EXISTE. O schema é de
     * FORMA, não de ambiente: nulo aqui significa "usa a da instalação", e se ela
     * não existir o agente é publicado para morrer em toda mensagem. A guarda
     * mora na rota de versões, que é quem conhece o `process.env` do servidor.
     */
    credential_id: UUID.nullable(),
    tool_ids: z
      .array(z.string().min(1).max(120))
      // O mesmo teto que a tela mostra ("13 de 20") é o que o servidor recusa —
      // ver `lib/mcp/tools/selecao-por-pacote.ts` para o porquê do número.
      .max(TETO_TOOLS_POR_AGENTE)
      .default([])
      // Catálogo compilado OU ferramenta remota escolhida pelo agente (item 6,
      // `mcp_externo:<leitura|escrita>:<nome>`): recusar a remota aqui faria a
      // tela salvar e a publicação devolver `tool_id_invalid`.
      .refine(
        (ids) =>
          ids.every((id) =>
            ehToolIdRemoto(id) || (VALID_TOOL_IDS as readonly string[]).includes(id),
          ),
        { message: "tool_id_invalid" },
      ),
    trigger_config: triggerConfigSchema.optional(),
    /**
     * Por qual número o agente atende. `null` = AINDA NÃO ESCOLHIDO.
     *
     * Era UUID obrigatório, e isso trancava o caminho mais comum de uma
     * instalação nova: o dono escreve o prompt do atendente ANTES de conectar o
     * WhatsApp (pareia o aparelho outro dia, com o celular na mão). Sem número
     * em `channel_sessions`, o editor não deixava salvar uma linha do que ele
     * acabou de escrever — a tela exigia escolher de uma lista vazia.
     *
     * ⚠️ NULO RASCUNHA, NÃO ATENDE DIRETAMENTE. Um agente comum precisa escolher
     * número antes de publicar. Um especialista sem número próprio SÓ pode ser
     * publicado se for membro comprovado de roteador da mesma organização, cujo
     * canal está WORKING (validado novamente na RPC). Nesse caso, o runtime
     * alcança a versão exclusivamente pelo ID selecionado pelo roteador; canal
     * sem roteador não encontra a versão com channel_session_id NULL.
     */
    channel_session_id: UUID.nullable(),
    max_steps: z.number().int().min(1).max(25).default(10),
    token_budget: z.number().int().min(1000).max(500000).default(50000),
    cost_budget_cents: z.number().int().min(1).max(10000).default(50),
    history_message_window: z.number().int().min(0).max(200).default(20),
    history_token_window: z.number().int().min(0).max(50000).default(8000),
    handoff_keywords: z
      .array(z.string().trim().min(1).max(60))
      .max(20)
      .default(["falar com humano", "atendente", "pessoa real"]),
    handoff_tool_enabled: z.boolean().default(true),
    // Passar para uma pessoa por ASSUNTO JURÍDICO — irmão da linha acima, com
    // o MESMO molde: booleano com default LIGADO (#2097, #2156). O default é
    // `true` porque quem não mexer tem de ficar com o resultado de hoje; o
    // efeito da chave (trocar a descrição da ferramenta) mora no turno.
    handoff_legal_enabled: z.boolean().default(true),
    // Ver as fotos que o cliente envia (migration 9108) — mesmo molde: ligado por
    // padrão, porque quem não mexer fica com o que tem hoje. O efeito mora no
    // turno (aviso no lugar da foto) e em `haLeitorDaDescricao` (quem paga a visão).
    image_vision_enabled: z.boolean().default(true),
    proposal_ai_draft_enabled: z.boolean().default(true),
    cases_enabled: z.boolean().default(false),
    // Onda 4 — quebra a resposta em bolhas curtas (splitIntoBubbles) espaçadas
    // pelo pacing anti-ban. Defaults espelham a migration 0059.
    split_messages: z.boolean().default(false),
    split_max_chars: z.number().int().min(80).max(4000).default(600),
    /**
     * Janela de coalescência de rajada inbound para ESTE agente (ms).
     *
     * `null`/ausente = usa o `INBOUND_DEBOUNCE_MS` da instalação (comportamento
     * de sempre — regressão zero). 0 desliga a coalescência de rajada para o
     * agente (job imediato). 1..60000 define a janela, com TETO de 60s para
     * ninguém travar o atendimento sem querer (#1856).
     */
    inbound_debounce_ms: z.number().int().min(0).max(60000).nullable().optional(),
    /**
     * Intervalo mínimo, em ms, entre uma resposta da IA e a próxima à MESMA
     * conversa. `null`/ausente/0 = sem intervalo (comportamento de sempre).
     * Teto de 10 min, para ninguém deixar o atendimento mudo sem querer.
     */
    reply_min_interval_ms: z.number().int().min(0).max(600000).nullable().optional(),
    followup: followupConfigSchema,
    // ── Papel OPERADOR (spec 16 §3.2) ───────────────────────────────────────
    // Todos com `.default(...)`, e é o que mantém retrocompatível: agent e
    // version que já existem, e qualquer payload que não conheça o papel,
    // seguem válidos e leem o papel como DESLIGADO.
    // ── Voz (Fase 6b/7 do plano de voz) ─────────────────────────────────────
    // Default FALSE/'espelhar' preserva toda versão existente — a mesma
    // doutrina de `operator_enabled` logo abaixo: feature que gasta dinheiro
    // não se liga por acidente.
    voz_enabled: z.boolean().default(false),
    voz_quando: z.enum(["nunca", "espelhar", "sempre"]).default("espelhar"),
    operator_enabled: z.boolean().default(false),
    // `.nullable()` e não opcional: null é o valor que SIGNIFICA "herda o modelo
    // do Conversador". Omitir seria indistinguível de "ainda não decidi".
    operator_model: z.string().trim().min(1).max(120).nullable().default(null),
    // Teto PRÓPRIO, não compartilhado com `tool_ids`: o Operador tem as 25 vagas
    // dele, o Conversador as dele, e nenhum come a lista do outro.
    //
    // ⚠️ A FRASE ANTERIOR VENCEU e está reescrita: ela dizia que separar os
    // papéis resolve o estouro "por divisão em vez de aumentar o número", e o
    // número FOI aumentado (20 → 25) quando o dono do produto ficou sem como
    // ligar as capacidades de agenda. Uma coisa não invalida a outra — a divisão
    // continua sendo o que impede os dois papéis de disputarem vaga —, mas
    // deixar escrito que o número nunca sobe faria a próxima sessão medir contra
    // uma régua que já não existe. O porquê do 25 está em
    // `lib/mcp/tools/selecao-por-pacote.ts`, junto da constante.
    operator_tool_ids: z
      .array(z.string().min(1).max(120))
      .max(TETO_TOOLS_POR_AGENTE)
      .default([])
      // Catálogo compilado OU ferramenta remota escolhida pelo agente (item 6,
      // `mcp_externo:<leitura|escrita>:<nome>`): recusar a remota aqui faria a
      // tela salvar e a publicação devolver `tool_id_invalid`.
      .refine(
        (ids) =>
          ids.every((id) =>
            ehToolIdRemoto(id) || (VALID_TOOL_IDS as readonly string[]).includes(id),
          ),
        { message: "tool_id_invalid" },
      ),
    /**
     * Funis em que este agente pode ESCREVER (spec 17 passo 3). Vazio = NENHUM.
     *
     * ⚠️ Sem `.refine()` de existência, ao contrário de `operator_tool_ids` logo
     * acima — e a diferença não é descuido. Aquele valida contra uma CONSTANTE
     * em código, que o cliente também tem; funil é linha de tabela, e checar
     * existência é consulta cross-row. Um schema compartilhado com o browser não
     * pode fazer isso, então a validação de que o funil existe (e é desta
     * organização) mora no servidor, junto do resto.
     */
    pipeline_ids: z.array(z.string().uuid()).default([]),
    /**
     * Materiais que este agente consulta (0181). Vazio = NENHUM.
     *
     * Sem `.refine()` de existência pelo mesmo motivo de `pipeline_ids` logo
     * acima: material é linha de tabela, e um schema compartilhado com o browser
     * não faz consulta cross-row. Quem confere que o material existe e é desta
     * organização é o servidor.
     */
    knowledge_source_ids: z.array(z.string().uuid()).default([]),
    /**
     * Skills que este agente usa (migration 9107). `null` = TODAS as da
     * organização e da plataforma — o comportamento de antes, e o default;
     * `[]` = nenhuma. Sem `.refine()` de existência pelo mesmo motivo dos dois
     * campos acima: quem confere que a skill existe é o servidor
     * (`validarEscopoDaVersao`). Repetidos viram um só.
     */
    skill_names: z
      .array(z.string().trim().min(1).max(120))
      .max(100)
      .transform((nomes) => [...new Set(nomes)])
      .nullable()
      .default(null),
  })
  .strict();

export type VersionInput = z.infer<typeof versionShapeSchema>;

export const versionCreateSchema = versionShapeSchema;

/** Edits permitted only on draft versions. Omitted fields never receive create defaults. */
export const versionPatchSchema = versionShapeSchema
  .extend({
    tool_ids: versionShapeSchema.shape.tool_ids.removeDefault(),
    max_steps: versionShapeSchema.shape.max_steps.removeDefault(),
    token_budget: versionShapeSchema.shape.token_budget.removeDefault(),
    cost_budget_cents: versionShapeSchema.shape.cost_budget_cents.removeDefault(),
    history_message_window: versionShapeSchema.shape.history_message_window.removeDefault(),
    history_token_window: versionShapeSchema.shape.history_token_window.removeDefault(),
    handoff_keywords: versionShapeSchema.shape.handoff_keywords.removeDefault(),
    handoff_tool_enabled: versionShapeSchema.shape.handoff_tool_enabled.removeDefault(),
    handoff_legal_enabled: versionShapeSchema.shape.handoff_legal_enabled.removeDefault(),
    image_vision_enabled: versionShapeSchema.shape.image_vision_enabled.removeDefault(),
    proposal_ai_draft_enabled: versionShapeSchema.shape.proposal_ai_draft_enabled.removeDefault(),
    cases_enabled: versionShapeSchema.shape.cases_enabled.removeDefault(),
    split_messages: versionShapeSchema.shape.split_messages.removeDefault(),
    split_max_chars: versionShapeSchema.shape.split_max_chars.removeDefault(),
    followup: followupPatchSchema,
    operator_enabled: versionShapeSchema.shape.operator_enabled.removeDefault(),
    operator_model: versionShapeSchema.shape.operator_model.removeDefault(),
    operator_tool_ids: versionShapeSchema.shape.operator_tool_ids.removeDefault(),
    pipeline_ids: versionShapeSchema.shape.pipeline_ids.removeDefault(),
    knowledge_source_ids: versionShapeSchema.shape.knowledge_source_ids.removeDefault(),
    skill_names: versionShapeSchema.shape.skill_names.removeDefault(),
    voz_enabled: versionShapeSchema.shape.voz_enabled.removeDefault(),
    voz_quando: versionShapeSchema.shape.voz_quando.removeDefault(),
  })
  .partial();

export const agentMcpCreateSchema = z
  .object({
    name: z.string().trim().min(1).max(120),
    description: z.string().trim().max(2000).optional(),
    priority: z.number().int().min(0).max(1000).default(0),
    version: versionShapeSchema,
  })
  .strict();

export const agentMcpPatchSchema = z
  .object({
    name: z.string().trim().min(1).max(120).optional(),
    description: z.string().trim().max(2000).nullable().optional(),
    priority: z.number().int().min(0).max(1000).optional(),
  })
  .strict();

export const publishSchema = z.object({ version_id: UUID }).strict();

/**
 * Limites do histórico do Testar como conversa (#4424).
 *
 * O histórico vive só no estado da tela e viaja no corpo do POST — não há
 * tabela nova. Os números são os mesmos dos dois lados: o painel recorta a
 * conversa para caber neles e esta rota RECUSA (400, com frase legível) o
 * corpo que passar. Sem teto, cada turno reenviaria a conversa inteira e o
 * custo por disparo cresceria sem limite.
 *
 * Depois deste recorte, o motor aplica ainda a janela da versão
 * (`history_message_window`, default 20, e `history_token_window`, default
 * 8.000 — ver `lib/agent-engine/agent/sandbox.ts`). Ou seja: 30 mensagens é o
 * teto do TRANSPORTE; o que chega ao modelo é o menor dos dois.
 */
export const LIMITE_DO_HISTORICO_EM_MENSAGENS = 30;
export const LIMITE_DO_HISTORICO_EM_CARACTERES = 20_000;

/** Uma mensagem anterior do teste — o cliente digitou ou a IA respondeu. */
export const testeHistoryMessageSchema = z
  .object({
    direction: z.enum(["inbound", "outbound"]),
    body: z.string().trim().min(1).max(4000),
  })
  .strict();

export const testRunSchema = z
  .object({
    sample_message: z.string().trim().min(1).max(4000),
    history: z
      .array(testeHistoryMessageSchema)
      .max(LIMITE_DO_HISTORICO_EM_MENSAGENS)
      .optional(),
    sample_contact: z
      .object({
        name: z.string().trim().min(1).max(120).optional(),
        phone: z.string().trim().min(3).max(40).optional(),
      })
      .optional(),
    // Etiquetas da conversa SIMULADA (spec 27, fatia 3): é o que deixa o Testar provar que a
    // skill entra pela etiqueta. Mesma normalização do CRM; até 20. Ausente = nenhuma.
    sample_tags: z
      .array(z.string().max(TAMANHO_MAXIMO_DA_TAG))
      .max(20)
      .transform((tags) => normalizarTags(tags))
      .optional(),
  })
  .strict();

export const runsListQuerySchema = z
  .object({
    cursor: z.string().optional(),
    limit: z.coerce.number().int().min(1).max(100).default(25),
    status: z
      .enum(["pending", "running", "completed", "failed", "aborted", "handoff"])
      .optional(),
  })
  .strict();

export type PublishErrorCode =
  | "agent_not_found"
  | "agent_archived"
  | "version_not_found"
  | "existing_version_requires_review"
  | "version_invalid_state"
  | "credential_missing"
  | "credential_not_found"
  | "credential_inactive"
  | "credential_not_validated"
  | "credential_provider_mismatch"
  | "channel_session_not_found"
  | "channel_session_offline"
  | "model_not_found"
  | "tool_id_invalid";

export const PUBLISH_ERROR_CODES: ReadonlySet<string> = new Set<PublishErrorCode>([
  "agent_not_found",
  "agent_archived",
  "version_not_found",
  "version_invalid_state",
  "existing_version_requires_review",
  "credential_missing",
  "credential_not_found",
  "credential_inactive",
  "credential_not_validated",
  "credential_provider_mismatch",
  "channel_session_not_found",
  "channel_session_offline",
  "model_not_found",
  "tool_id_invalid",
]);
