import { z } from 'zod';
import { variaveisDoModeloSchema } from '@/lib/channels/meta/variaveis-do-fluxo';

import { PRIORIDADES_DA_TAREFA } from '@/lib/tarefas/tipos';
import { ehOpDeData, OPS_DE_DATA } from './condicao-por-data';

/**
 * Flow graph schema for the follow-up automation system.
 * Defines types and Zod validators for nodes, edges, and complete graphs.
 */

export const NODE_TYPES = [
  'trigger',
  'wait',
  'condition',
  'ai_classify',
  'match_reply',
  'repeat',
  'collect',
  'skill',
  'action',
  // Lembrete interno (#1540): grava `crm_tasks` e NÃO envia mensagem. É a caixa
  // que advocacia, saúde e serviços regulados precisam — o sistema lembra a
  // equipe, a mensagem sai de uma pessoa.
  'internal_task',
  // #2065: dois tipos de ACAO que não falam com o cliente — o fluxo deixa de
  // só mandar mensagem. `move_lead` move o card para outra etapa (o mesmo
  // escritor de etapa do board/automação), `edit_lead_tag` grava tag no lead
  // (a MESMA `add_tag` do motor de automação). "Disparar campanha" ficou de
  // fora desta fatia e é o passo seguinte da issue.
  'move_lead',
  'edit_lead_tag',
  'end',
] as const;
export type NodeType = (typeof NODE_TYPES)[number];

/**
 * ---------------------------------------------------------------------------
 * Named branches (graph v2)
 * ---------------------------------------------------------------------------
 * A fan-out node used to collapse N rules into ONE boolean (`condition`) or to
 * address its outputs by a mutable human string (`ai_classify` + `class_match`),
 * so the builder could only ever draw a single source handle and a rename
 * silently detached the edge. v2 gives every output of a node a STABLE ID —
 * the branch — and lets an edge reference `branch_id` instead.
 *
 * The branch belongs to the NODE, never to the edge: replicating the rule
 * inside the edge would duplicate the truth and break on reorder.
 *
 * Every v2 field below is OPTIONAL and every v1 field keeps its exact type, so
 * a graph written by the previous version parses byte-identical (no default is
 * injected, nothing is rewritten) and keeps routing through the v1 conditions.
 * Read the storage shape through `nodeBranches()` — it is the only place that
 * knows which dialect a node speaks.
 */

/** The catch-all output every node has. Spelled `{ type: 'always' }` on the wire — never as a `branch`. */
export const FALLBACK_BRANCH_ID = 'else';
/** `ai_classify` grace timeout expired without a classification. */
export const NO_REPLY_BRANCH_ID = 'no_reply';
/** The two outputs of a `condition` node evaluating its checks together (`branching: 'combined'`). */
export const CONDITION_TRUE_BRANCH_ID = 'true';
export const CONDITION_FALSE_BRANCH_ID = 'false';
/** Saída do `repeat` enquanto ainda faltam voltas. */
export const REPEAT_BODY_BRANCH_ID = 'body';
/** Saída do `repeat` quando o contador chegou a zero. */
export const REPEAT_DONE_BRANCH_ID = 'done';

/** Branch ids the contract owns — a user-declared branch may not claim one. */
export const RESERVED_BRANCH_IDS = [
  FALLBACK_BRANCH_ID,
  NO_REPLY_BRANCH_ID,
  CONDITION_TRUE_BRANCH_ID,
  CONDITION_FALSE_BRANCH_ID,
  REPEAT_BODY_BRANCH_ID,
  REPEAT_DONE_BRANCH_ID,
] as const;

/** Id of a branch the user declared (a check, an AI class) — opaque, stable across renames. */
export const declaredBranchIdSchema = z
  .string()
  .min(1)
  .max(64)
  .refine((id) => !(RESERVED_BRANCH_IDS as readonly string[]).includes(id), {
    message: `branch id is reserved: ${RESERVED_BRANCH_IDS.join(', ')}`,
  });

/** Id an edge may reference. Excludes the fallback, which has exactly one spelling: `{ type: 'always' }`. */
export const edgeBranchIdSchema = z
  .string()
  .min(1)
  .max(64)
  .refine((id) => id !== FALLBACK_BRANCH_ID, {
    message: `the fallback branch "${FALLBACK_BRANCH_ID}" is spelled { type: 'always' } on an edge`,
  });

/**
 * O horário em que uma espera fixa pode terminar (`wait.ate_o_horario`).
 *
 * ⚠️ FROUXO AQUI DE PROPÓSITO, como `lead_date.key` mais abaixo: o rascunho é
 * validado só estruturalmente e o painel grava a cada clique. Exigir aqui "fim
 * depois do início" faria o painel recusar o estado intermediário (quem troca
 * 08:00 por 18:00 passa por um horário torto) e a tela mostraria um valor que
 * não foi gravado. Quem exige um horário que dá para obedecer é o publish
 * (`horario_da_espera_invalido` em `validate-publish.ts`).
 */
export const horarioDaEsperaSchema = z.strictObject({
  start: z.string().max(5),
  end: z.string().max(5),
  weekdays: z.array(z.number().int().min(0).max(6)).max(7),
});

/**
 * Wait node configuration schema.
 * Supports two modes:
 * - fixed: absolute wait duration in milliseconds (5 min to 90 days)
 * - smart: adaptive wait with min/max range and optional guidance
 */
export const waitConfigSchema = z
  .discriminatedUnion('mode', [
    z.strictObject({
      mode: z.literal('fixed'),
      duration_ms: z.number().int().min(300_000).max(7_776_000_000),
      /**
       * A espera NÃO é encurtada nem cancelada quando o contato manda mensagem.
       *
       * Existe para a cadência longa — o retorno de manutenção de 28 dias — em
       * que o cliente falar hoje não é motivo para antecipar um toque de daqui a
       * um mês. Sem isto, `lib/followup/reactivity.ts` ou cancela a inscrição
       * (`cancel_on_reply`) ou grava `inbound_woke` e corta o timer: os dois
       * desfechos matam a cadência, e era por isso que a regra de retorno vivia
       * no prompt do agente chamando `crm_schedule_followup`.
       *
       * Só em `fixed`: `smart` é "a IA escolhe dentro de uma faixa", e faixa
       * adaptativa com imunidade é combinação que ninguém pediu. O `strictObject`
       * do outro membro já recusa a chave — vira teste, não código.
       *
       * Em runtime isto vira o status `dormente` da inscrição (quem projeta é o
       * handler do nó, em `node-handlers.ts`), e é o status que tira a inscrição
       * do alcance da reatividade e libera o slot único anti-spam.
       */
      immune_to_reply: z.boolean().optional(),
      /**
       * A espera só TERMINA dentro deste horário (dias + horas, no fuso da
       * organização). Se a duração vencer fora dele — de noite, no domingo —, a
       * espera continua até a próxima abertura. Ver `espera-no-horario.ts`.
       *
       * Opcional: os grafos publicados não têm a chave e seguem válidos byte a
       * byte. Só em `fixed`, como `immune_to_reply`: a espera adaptativa é a IA
       * escolhendo dentro de uma faixa, e o `strictObject` dela recusa a chave.
       */
      ate_o_horario: horarioDaEsperaSchema.optional(),
    }),
    z.strictObject({
      mode: z.literal('smart'),
      min_ms: z.number().int().min(300_000),
      max_ms: z.number().int().max(7_776_000_000),
      guidance: z.string().max(500).optional(),
    }),
  ])
  .refine((c) => c.mode !== 'smart' || c.min_ms <= c.max_ms, {
    message: 'min_ms must be <= max_ms',
    path: ['min_ms'],
  });

/** A declared output of an `ai_classify` node: opaque id + the class name the LLM is asked to pick. */
export const aiClassBranchSchema = z.strictObject({
  id: declaredBranchIdSchema,
  label: z.string().min(1).max(40),
});

export type AiClassBranch = z.infer<typeof aiClassBranchSchema>;

/** Declared output of a `match_reply` node: opaque id + the text rule (no LLM). */
export const matchReplyBranchSchema = z.strictObject({
  id: declaredBranchIdSchema,
  label: z.string().min(1).max(40),
  op: z.enum(['eq', 'contains']),
  pattern: z.string().min(1).max(200),
});

export type MatchReplyBranch = z.infer<typeof matchReplyBranchSchema>;

/**
 * Where to write the contact's reply. `{{volta}}` in a custom key is replaced
 * with the current `repeat` index when the node sits inside a loop.
 */
export const replySaveToSchema = z.discriminatedUnion('kind', [
  z.strictObject({ kind: z.literal('contact_name') }),
  z.strictObject({
    kind: z.literal('lead_custom'),
    key: z
      .string()
      .min(1)
      .max(60)
      .regex(/^[a-z][a-z0-9_{}]*$/i, 'Use letras, números, underscore ou {{volta}}'),
  }),
]);
export type ReplySaveTo = z.infer<typeof replySaveToSchema>;

/** O que fazer quando o destino de `save_to` já tem valor (captação, ficha…). */
export const ifExistsSchema = z.enum(["skip", "overwrite", "confirm"]);
export type IfExists = z.infer<typeof ifExistsSchema>;

/**
 * Text-match node: parks in `waiting_reply` like `ai_classify`, then routes on
 * the last inbound body without calling a model. v2 `branches` only.
 */
export const matchReplyConfigSchema = z
  .strictObject({
    branches: z.array(matchReplyBranchSchema).min(1).max(8),
    grace_timeout_ms: z.number().int().min(900_000),
    save_to: replySaveToSchema.optional(),
    if_exists: ifExistsSchema.optional(),
  })
  .refine((c) => new Set(c.branches.map((b) => b.id)).size === c.branches.length, {
    message: "branches[].id must be unique within the node",
    path: ["branches"],
  });

/**
 * Repete o caminho `body` N vezes, onde N vem da última resposta do contato
 * (um número, ou palavras tipo "nenhum"/"dois"), limitado por `max_count`.
 * Estado mora nos eventos do enrollment — voltar ao nó não relê a resposta.
 */
export const repeatConfigSchema = z.strictObject({
  max_count: z.number().int().min(1).max(20),
});

/**
 * AI classification node configuration.
 * Classifies incoming messages into one of several predefined classes.
 *
 * `branches` (v2) is the source of truth for identity AND label; `classes`
 * stays the ordered class vocabulary the engine hands the LLM
 * (`engine.ts` buildClassifyPayload) and is a validated projection of it — the
 * refine below rejects any graph where the two drift, so the mirror cannot rot.
 * A node without `branches` is a v1 node: the class string is its own branch id
 * (which is exactly the rename bug — v1 nodes keep the old behaviour until the
 * builder migrates them, they just don't get stable ids for free).
 */
export const aiClassifyConfigSchema = z
  .strictObject({
    classes: z
      .array(z.string().min(1).max(40))
      .min(1)
      .max(8),
    branches: z.array(aiClassBranchSchema).min(1).max(8).optional(),
    grace_timeout_ms: z.number().int().min(900_000), // 15 min minimum
    target: z.enum(['last_reply', 'summary']).default('last_reply'),
    hint: z.string().max(500).optional(),
  })
  .refine((c) => !c.branches || new Set(c.branches.map((b) => b.id)).size === c.branches.length, {
    message: 'branches[].id must be unique within the node',
    path: ['branches'],
  })
  .refine(
    (c) =>
      !c.branches ||
      (c.branches.length === c.classes.length &&
        c.branches.every((b, i) => b.label === c.classes[i])),
    {
      message: 'classes must mirror branches[].label in the same order (branches is the source of truth)',
      path: ['classes'],
    }
  );

/**
 * #4429 — quantas redações EXTRAS um passo de texto aceita (além do `body`).
 * Teto de configuração, e não de motor: a tela usa o mesmo número para parar de
 * oferecer "adicionar". Mora aqui (e não em `variacoes-do-passo.ts`, que
 * importa `node:crypto`) porque o formulário é componente de cliente.
 */
export const MAX_VARIACOES_DO_PASSO = 5;

/**
 * Action node configuration schema.
 * - text: send this body as-is (no model)
 * - ai_message: generate a message using AI with a prompt hint
 * - template: send a canned message from Ajustes → Modelos
 */
export const actionConfigSchema = z.discriminatedUnion('mode', [
  z.strictObject({
    mode: z.literal('text'),
    body: z.string().min(1).max(4000),
    /**
     * #4429 — OUTRAS REDAÇÕES DO MESMO PASSO.
     *
     * O `body` continua sendo a PRIMEIRA redação (é o que todo leitor antigo — a
     * tela, o cartão do nó, a prévia do fluxo — mostra, e o que um grafo já
     * publicado tem). `variacoes` são redações ADICIONAIS, e o motor alterna
     * entre `[body, ...variacoes]` a cada envio (`lib/followup/engine.ts` →
     * `redacaoDaVez` em `lib/followup/variacoes-do-passo.ts`).
     *
     * Opcional de propósito: um passo de texto único — o de hoje — não tem a
     * chave e continua válido byte a byte, sem migration e sem reescrita do
     * grafo. Ausente ou vazia = comportamento de sempre.
     */
    variacoes: z.array(z.string().trim().min(1).max(4000)).max(MAX_VARIACOES_DO_PASSO).optional(),
    /**
     * #2734 — MÍDIA OPCIONAL (imagem, vídeo, áudio ou documento, #2972) por URL.
     * Presente, o `body` vira a
     * LEGENDA. Opcional de propósito: os grafos publicados não têm a chave e
     * continuam válidos byte a byte (o `strictObject` só recusa o que ELE não
     * conhece; um campo novo e opcional é aceito pelos antigos).
     */
    media_url: z.string().url().max(2000).optional(),
  }),
  z.strictObject({
    mode: z.literal('ai_message'),
    prompt_hint: z.string().min(1).max(1000),
    fallback_template_id: z.string().uuid().optional(),
  }),
  z.strictObject({
    mode: z.literal('template'),
    template_id: z.string().uuid(),
    /**
     * De onde sai cada variável (`{{1}}`, `{{2}}`…) de um modelo APROVADO:
     * nome do contato ou campo do negócio, resolvido na hora do envio
     * (`lib/channels/meta/variaveis-do-fluxo.ts`). Ausente = modelo sem variável.
     */
    template_values: variaveisDoModeloSchema.optional(),
  }),
]);

/**
 * Internal task node configuration (#1540) — os MESMOS campos da ação de
 * automação `create_task`, de propósito: as duas portas criam a mesma tarefa,
 * e campos diferentes virariam duas telas ensinando duas verdades.
 *
 * `atribuir_a` é nominal (`dono_do_lead`) porque o fluxo é publicado sem saber
 * quem vai atender amanhã — o dono muda, o fluxo não.
 */
export const internalTaskConfigSchema = z.strictObject({
  /** Título com `{{lead.title}}` e `{{contact.name}}`. */
  titulo: z.string().min(1).max(200),
  /** De quantos dias o prazo cai a partir do disparo. */
  vence_em_dias: z.number().int().min(0).max(365),
  atribuir_a: z.union([
    z.literal('dono_do_lead'),
    z.strictObject({ usuario_id: z.string().uuid() }),
  ]),
  prioridade: z.enum(PRIORIDADES_DA_TAREFA),
});

/**
 * Nó `move_lead` (#2065) — mover o card para outra etapa do MESMO funil.
 *
 * Só a etapa, sem `pipeline_id`: quem escolhe o destino é o SELETOR de etapas
 * da tela, e quem RECUSA troca de funil é a casa — `moveLeadHandler`
 * (`app/api/v1/leads/_handler`) devolve `pipeline_immutable_use_clone` quando a
 * etapa não é do funil do lead, a mesma régua do board, das automações e da
 * tool MCP. Repetir aqui a pergunta de funil seria uma segunda régua para a
 * mesma regra.
 *
 * `stage_id` aceita vazio porque o rascunho é validado SÓ estruturalmente
 * (`flowGraphSchema`) e o nó nasce sem destino — quem exige etapa escolhida é o
 * `validate-publish` (`etapa_destino_ausente`), como a condição com regra em
 * branco.
 *
 * `lost_reason` é opcional para não invalidar grafo já publicado: quem montou
 * o fluxo antes desta régua continua válido, e quem publica com destino em
 * etapa de perda precisa preenchê-lo (`motivo_da_perda_ausente`).
 */
export const moveLeadConfigSchema = z.strictObject({
  stage_id: z.string().max(64),
  lost_reason: z.string().max(500).optional(),
});

/**
 * Nó `edit_lead_tag` (#2065) — grava tag no lead, o merge idempotente da ação
 * `add_tag` do motor de automação (`lib/automation/actions/add-tag.ts`), com os
 * MESMOS limites do schema de webhook (`lib/schemas/webhooks.ts`): até 10 tags
 * de até 60 caracteres.
 *
 * Lista vazia é rascunho em construção (mesma degradação do `stage_id`); o
 * publish recusa (`tag_ausente`) — um nó que não grava nada não pode ser
 * publicado como se gravasse.
 */
export const editLeadTagConfigSchema = z.strictObject({
  tags: z
    .array(z.string().max(60, "Tag muito longa: máximo de 60 caracteres."))
    .max(10, "No máximo 10 tags por caixa."),
});

/**
 * One rule of a `condition` node. In `branching: 'per_check'` it IS a branch,
 * so it carries the stable id an edge references and the label the handle shows
 * — identity lives on the rule itself, never in a parallel array that would
 * desync the moment the user reorders the rules.
 */
export const conditionCheckSchema = z
  .strictObject({
    id: declaredBranchIdSchema.optional(),
    label: z.string().min(1).max(60).optional(),
    field: z.enum([
      'lead_stage',
      'tag',
      'steps_taken',
      'last_outcome',
      // Data relativa sobre um campo personalizado do negócio — ver
      // `condicao-por-data.ts`. É o que permite perguntar "o período de teste
      // terminou?", que nenhum dos quatro campos acima alcança.
      'lead_date',
    ]),
    op: z.enum(['eq', 'neq', 'gte', 'lte', 'contains', ...OPS_DE_DATA]),
    value: z.union([z.string(), z.number()]),
    /**
     * A chave em `crm_leads.custom_fields` que a regra lê (ex.: `fim_teste`).
     * Só `lead_date` usa, e para ele é OBRIGATÓRIA no PUBLISH: regra de data
     * sem campo não tem o que comparar, e deixá-la passar daria uma condição
     * que responde sempre `false` sem dizer por quê. Aqui, no rascunho, a
     * chave VAZIA passa — ver o aviso logo abaixo.
     */
    key: z.string().max(60, 'Nome do campo muito longo: máximo de 60 caracteres.').optional(),
  })
  // ⚠️ `key` VAZIA PASSA AQUI, de propósito, e isso foi achado pela prova de
  // tela. O rascunho é validado SÓ estruturalmente — a mesma razão pela qual
  // `move_lead.stage_id` e `edit_lead_tag.tags` aceitam vazio: quem escolhe o
  // campo de data ainda não digitou o nome dele, e o painel grava a cada
  // tecla (`commit` → `safeParse`). Exigir a chave aqui fazia aparecer um erro
  // no instante em que a pessoa escolhia «Data de um campo do negócio», antes
  // de ela ter chance de preencher — e a regra nem era persistida, então o nó
  // continuava com a regra ANTERIOR enquanto a tela mostrava a nova.
  //
  // Quem exige é o publish (`campo_de_data_ausente` em `validate-publish.ts`),
  // como exige etapa de destino e tag. O mesmo vale para o prazo: limpar o
  // campo para redigitar passa por um valor vazio, e o publish é que recusa
  // (`check_value_not_number`).
  .refine((c) => c.field !== 'lead_date' || ehOpDeData(c.op), {
    message: `uma regra de data usa ${OPS_DE_DATA.join(' ou ')}`,
    path: ['op'],
  })
  .refine((c) => c.field === 'lead_date' || !ehOpDeData(c.op), {
    message: 'passou_ha e falta_para existem só para regra de data',
    path: ['op'],
  });

export type ConditionCheck = z.infer<typeof conditionCheckSchema>;

/**
 * Condition node configuration.
 * Evaluates multiple checks against lead state using boolean logic.
 *
 * `branching` picks how the checks reach the canvas:
 * - `combined` (also: absent — every published v1 flow) — `combinator` folds the
 *   checks into one boolean and the node has the two fixed outputs Sim/Não.
 * - `per_check` — one output per rule, plus the mandatory fallback. `combinator`
 *   is not consulted in this mode (a rule no longer votes, it routes).
 */
export const conditionConfigSchema = z
  .strictObject({
    combinator: z.enum(['and', 'or']).default('and'),
    branching: z.enum(['combined', 'per_check']).optional(),
    checks: z.array(conditionCheckSchema).min(1).max(10),
  })
  .refine(
    (c) => {
      const ids = c.checks.flatMap((chk) => (chk.id === undefined ? [] : [chk.id]));
      return new Set(ids).size === ids.length;
    },
    { message: 'checks[].id must be unique within the node', path: ['checks'] }
  )
  .refine((c) => c.branching !== 'per_check' || c.checks.every((chk) => chk.id !== undefined), {
    message: "branching 'per_check' requires an id on every check — it is what the edge references",
    path: ['checks'],
  });

/**
 * Collect node (`collect`) — uma pergunta do fluxo de ATENDIMENTO.
 *
 * O nó NÃO envia nada sozinho: ele declara o CAMPO que precisa ser preenchido
 * (chave, rótulo, tipo) e o executor in-turn injeta a pergunta no contexto do
 * agente. Quando o valor entra (`flow_collect`, extrator ou resposta do cliente),
 * o campo deixa de ser pendente e o fluxo avança. `key` casa 1:1 com
 * `contact_flow_data.field_key` (migration 0236) — a regex espelha o CHECK do
 * banco (minúsculas, começa com letra).
 */
/**
 * `cpf` (PR 2 do port): o número confere pelo dígito verificador (mod-11 da
 * Receita) antes de ser aceito — na prova prática, um CPF com dígito errado foi
 * gravado como resposta porque o campo era "Número".
 */
export const contactFlowFieldTypeSchema = z.enum(['text', 'number', 'date', 'boolean', 'select', 'cpf']);
export type ContactFlowFieldType = z.infer<typeof contactFlowFieldTypeSchema>;

export const collectConfigSchema = z
  .strictObject({
    key: z
      .string()
      .min(1)
      .max(60)
      .regex(/^[a-z][a-z0-9_]*$/, 'Use letras minúsculas, números e underscore'),
    label: z.string().min(1).max(80),
    type: contactFlowFieldTypeSchema.default('text'),
    required: z.boolean().default(true),
    /**
     * Permite o cliente CORRIGIR o dado a qualquer momento: com `true` (padrão),
     * uma nova informação sobrescreve a anterior. Com `false`, o primeiro valor
     * fica travado.
     */
    permite_correcao: z.boolean().default(true),
    options: z.array(z.string().min(1).max(80)).max(20).optional(),
    /** Texto sugerido da pergunta; o agente pode reescrever (checklist guiado pela IA). */
    question: z.string().max(400).optional(),
    /**
     * ONDE A RESPOSTA É GRAVADA (#3363): `contato` em `contacts.custom_fields`
     * (o caminho de sempre) ou `negocio` no card aberto do contato
     * (`crm_leads.custom_fields`) — que é o que o card da tela, os campos
     * obrigatórios da etapa do funil e as automações por data do funil leem.
     *
     * A AUSÊNCIA é `contato`, e é o que todo grafo existente tem: sem `.default()`
     * de propósito (a mesma escolha do `escopo` do #2791, com o padrão invertido
     * — aqui o comportamento legado é o contato). Sem default, `safeParse` devolve
     * o grafo byte a byte e editar uma pergunta não reescreve a chave à toa.
     *
     * Com `negocio`, a régua "já respondido não se repergunta" vale só DENTRO do
     * negócio atual: o valor é lido do card, então um novo negócio pergunta de
     * novo. Um contato sem card aberto não tem onde gravar — a pergunta segue
     * pendente até o teto de tentativas.
     */
    destino: z.enum(['contato', 'negocio']).optional(),
  })
  .refine((c) => c.type !== 'select' || (c.options?.length ?? 0) > 0, {
    message: 'tipo "select" exige ao menos uma opção',
    path: ['options'],
  });

/** O destino da gravação de UMA pergunta (#3363) — ausência no grafo = `contato`. */
export type DestinoDaResposta = NonNullable<z.infer<typeof collectConfigSchema>['destino']>;

/**
 * Skill node (`skill`) — puxa uma skill instalada em paralelo ao passo do fluxo.
 * Ao entrar no nó, o executor in-turn inclui o corpo da skill no contexto do
 * turno (união com o matcher por keyword). O nome é validado contra as skills
 * instaladas no executor, não aqui (o grafo é portável entre organizações).
 */
export const skillConfigSchema = z.strictObject({
  skill_name: z.string().min(1).max(80),
});

/**
 * Ação ao finalizar um fluxo de atendimento — "quando finaliza, chama qual skill
 * ou manda pra IA". Opcional para não quebrar grafos existentes; ausente = `nada`
 * (comportamento anterior).
 */
export const endFinishSchema = z.discriminatedUnion('tipo', [
  z.strictObject({ tipo: z.literal('nada') }),
  z.strictObject({ tipo: z.literal('ia'), prompt: z.string().max(1000).optional() }),
  z.strictObject({ tipo: z.literal('skill'), skill_name: z.string().min(1).max(80) }),
  /**
   * Encadeia a venda: ao concluir, o motor inicia OUTRO fluxo de atendimento
   * (`fluxo` = id do `followup_flow_pointers`). A síntese do fluxo concluído
   * (`completion_note`) entra no contexto do próximo. O id é validado em runtime
   * contra os fluxos instalados da organização (o grafo é portável) — como
   * `skill_name`. Autoencadeamento (fluxo → ele mesmo) é ignorado pelo motor.
   */
  z.strictObject({ tipo: z.literal('proximo_fluxo'), fluxo: z.string().min(1).max(80) }),
]);
export type EndFinish = z.infer<typeof endFinishSchema>;

/**
 * End node configuration.
 * Marks the conclusion of a flow with an outcome.
 */
export const endConfigSchema = z.strictObject({
  outcome: z.enum(['converted', 'exhausted', 'custom']),
  note: z.string().max(200).optional(),
  ao_finalizar: endFinishSchema.optional(),
});

/**
 * Flow node schema — discriminated union based on node type.
 * Each node type has its specific config schema.
 */
export const flowNodeSchema = z.discriminatedUnion('type', [
  // Trigger node: entry point, no config
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('trigger'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: z.strictObject({}),
  }),
  // Wait node: pauses flow for a duration
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('wait'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: waitConfigSchema,
  }),
  // Condition node: branches flow based on criteria
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('condition'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: conditionConfigSchema,
  }),
  // AI Classify node: classifies messages
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('ai_classify'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: aiClassifyConfigSchema,
  }),
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('match_reply'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: matchReplyConfigSchema,
  }),
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('repeat'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: repeatConfigSchema,
  }),
  // Collect node: uma pergunta do fluxo de atendimento (coleta um campo)
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('collect'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: collectConfigSchema,
  }),
  // Skill node: puxa uma skill instalada em paralelo ao passo
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('skill'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: skillConfigSchema,
  }),
  // Action node: sends a message
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('action'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: actionConfigSchema,
  }),
  // Internal task node (#1540): creates a CRM task, never sends a message
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('internal_task'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: internalTaskConfigSchema,
  }),
  // Nó de ação (#2065): move o card para outra etapa do funil
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('move_lead'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: moveLeadConfigSchema,
  }),
  // Nó de ação (#2065): grava tag no lead, sem mensagem
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('edit_lead_tag'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: editLeadTagConfigSchema,
  }),
  // End node: terminal state
  z.strictObject({
    id: z.string().min(1),
    type: z.literal('end'),
    label: z.string().min(1).max(60),
    position: z.strictObject({
      x: z.number(),
      y: z.number(),
    }),
    config: endConfigSchema,
  }),
]);

export type FlowNode = z.infer<typeof flowNodeSchema>;

/**
 * Flow edge schema — connection between nodes.
 * Includes condition to determine when edge is traversed.
 *
 * `branch` is the v2 spelling: it names an output of the SOURCE node by its
 * stable id, so renaming what the user sees never detaches the edge.
 * `class_match` / `cond_result` are the v1 spellings — still valid, still
 * routed, emitted only by nodes that haven't declared branches. Which dialect
 * an edge should use is decided by the source node, not by the editor: see
 * `nodeBranches()`.
 */
export const flowEdgeSchema = z.strictObject({
  id: z.string(),
  source: z.string(),
  target: z.string(),
  priority: z.number().int().default(0),
  condition: z.discriminatedUnion('type', [
    z.strictObject({ type: z.literal('always') }),
    z.strictObject({
      type: z.literal('class_match'),
      value: z.string(), // e.g., 'hot', 'cold', 'no_reply'
    }),
    z.strictObject({
      type: z.literal('cond_result'),
      value: z.boolean(),
    }),
    z.strictObject({
      type: z.literal('branch'),
      branch_id: edgeBranchIdSchema,
    }),
  ]),
});

export type FlowEdge = z.infer<typeof flowEdgeSchema>;
export type FlowEdgeCondition = FlowEdge['condition'];

/**
 * Configurações do fluxo (nível do grafo, não de um nó). Opcional: um grafo
 * antigo sem `settings` continua válido e cai nos defaults.
 */
export const flowSettingsSchema = z.strictObject({
  /**
   * Quantas vezes uma pergunta pode ser feita SEM resposta antes de ser
   * encerrada como não respondida (deixa de ser feita e não bloqueia a
   * conclusão). Default 3.
   */
  max_tentativas_pergunta: z.number().int().min(1).max(10).default(3),
  /**
   * Palavras/expressões que LIGAM este fluxo: quando a mensagem do cliente
   * contém uma delas, o MOTOR inicia o fluxo (entrada por gatilho, sem depender
   * do modelo). Vazio/ausente = este fluxo só começa por `etapas_do_funil`, pelo
   * roteador ou encadeado no fim de outro — `flow_start` não existe no código,
   * e citá-lo aqui prometia uma entrada que ninguém podia configurar.
   */
  gatilhos: z.array(z.string().min(1).max(60)).max(30).optional(),
  /**
   * As ETAPAS DO FUNIL que LIGAM este roteiro: quando o card do contato está
   * numa delas, o MOTOR começa o roteiro no próximo turno daquela conversa —
   * a entrada por etapa, irmã da palavra-gatilho, e sem depender da mensagem.
   *
   * ⚠️ POR QUE AQUI (no `settings` do grafo) E NÃO EM `trigger_config`. Para o
   * follow-up, a entrada por etapa mora no gatilho (`kind: 'stage_change'`,
   * `lib/followup/gatilho-etapa.ts`, event-driven). No roteiro de atendimento
   * isso é impossível por construção: o CHECK
   * `followup_flow_pointers_roteiro_so_manual` (0394) obriga
   * `trigger_config->>'kind' = 'manual'` na superfície `atendimento`, e o
   * publicador devolve 422 a qualquer outro kind. O `settings` é o lugar do
   * vocabulário do roteiro (é onde já vivem `gatilhos`, `expira_em_horas` e
   * `pode_recomecar`), é versionado junto do grafo e passa pelo schema.
   *
   * O id é o da ETAPA (`crm_stages.id`) — o mesmo que `crm_leads.stage_id`, que
   * é o que o turno lê. A etapa já identifica o funil, então não há par
   * (funil, etapa) a guardar; o nome do funil existe na tela, para distinguir
   * etapas homônimas de funis diferentes. Vazio/ausente = nenhuma etapa liga.
   */
  etapas_do_funil: z.array(z.string().min(1).max(64)).max(30).optional(),
  /**
   * Em quantas horas SEM mensagem do cliente o roteiro em andamento expira
   * (0397, `fn_encerrar_roteiros_vencidos`). Ausente = 72 h. Sem prazo, um
   * roteiro abandonado voltava a perguntar semanas depois (prova do #1130).
   */
  expira_em_horas: z.number().int().min(1).max(720).optional(),
  /**
   * O roteiro pode começar de novo para um cliente que JÁ o concluiu (#1130,
   * decisão do doc 69: cada roteiro escolhe). Ausente = NÃO recomeça: repetir a
   * palavra-gatilho de um cadastro já feito reabria as mesmas perguntas.
   * Agendamento, que precisa repetir, liga.
   */
  pode_recomecar: z.boolean().optional(),
  /**
   * SOMENTE INTERNO (#1540): o fluxo inteiro não fala com o cliente. A
   * publicação (`validate-publish.ts`) recusa qualquer nó de ENVIO num fluxo
   * com esta marca — é a garantia de que "só lembrete" não é uma intenção que
   * alguém esquece de conferir antes de publicar.
   */
  somente_interno: z.boolean().optional(),
});
export type FlowSettings = z.infer<typeof flowSettingsSchema>;

/**
 * Complete flow graph schema.
 * Contains nodes and edges defining the flow automation.
 *
 * O `strictObject` valida cada nó e cada aresta SOZINHOS; o `superRefine`
 * abaixo é a catraca de INTEGRIDADE entre eles. Sem ela, um grafo com aresta
 * apontando para nó inexistente ou com ids repetidos (o defeito que o #586
 * produz no canvas) passava no `safeParse` de qualquer consumidor — salvar o
 * rascunho (`draft_graph`), carregar a versão (engine, turn-bridge, enroll,
 * silence-sweep, intervenção) e publicar. A porta é a mesma para todos: quem
 * JÁ tiver rascunho corrompido recebe o erro com o id a corrigir em vez de um
 * grafo que só quebra adiante, no meio de um disparo. Sem migração de
 * rascunho — decisão registrada na issue #699.
 */
export const flowGraphSchema = z
  .strictObject({
    nodes: z.array(flowNodeSchema).min(2).max(60),
    edges: z.array(flowEdgeSchema).max(120),
    settings: flowSettingsSchema.optional(),
  })
  .superRefine((grafo, ctx) => {
    const idsDeNo = new Set<string>();
    grafo.nodes.forEach((no, i) => {
      if (idsDeNo.has(no.id)) {
        ctx.addIssue({
          code: 'custom',
          message: `id de nó repetido: "${no.id}"`,
          path: ['nodes', i],
        });
      }
      idsDeNo.add(no.id);
    });

    const idsDeAresta = new Set<string>();
    grafo.edges.forEach((aresta, i) => {
      if (idsDeAresta.has(aresta.id)) {
        ctx.addIssue({
          code: 'custom',
          message: `id de aresta repetido: "${aresta.id}"`,
          path: ['edges', i],
        });
      }
      idsDeAresta.add(aresta.id);

      if (!idsDeNo.has(aresta.source)) {
        ctx.addIssue({
          code: 'custom',
          message: `aresta "${aresta.id}" aponta para nó inexistente: "${aresta.source}"`,
          path: ['edges', i],
        });
      }
      if (!idsDeNo.has(aresta.target)) {
        ctx.addIssue({
          code: 'custom',
          message: `aresta "${aresta.id}" aponta para nó inexistente: "${aresta.target}"`,
          path: ['edges', i],
        });
      }
    });
  });

export type FlowGraph = z.infer<typeof flowGraphSchema>;

/**
 * ---------------------------------------------------------------------------
 * Branch resolution — the single reader of the storage dialect
 * ---------------------------------------------------------------------------
 * Node configs differ per type and per version (v1 `classes: string[]` vs v2
 * `branches`, `combinator` vs `per_check`). Everything downstream — the canvas
 * handles, the edge panel, publish validation, the engine's routing — must go
 * through `nodeBranches()` and never read the raw shape, so the compatibility
 * cost is paid exactly once, here.
 *
 * ⚠️ MIGRAR UM NÓ DE v1 PARA v2 NÃO É SÓ TROCAR A CONFIG. `branchIdForCondition`
 * aceita, num nó v2, uma aresta legada `class_match` casando pelo RÓTULO — isso
 * existe para o canvas continuar desenhando um nó meio-migrado sem perder a
 * linha. O ROTEAMENTO não tem essa cortesia: `classEdgeMatch` (node-handlers)
 * devolve `{type:'branch'}` e `selectEdge` não casa a aresta antiga, caindo no
 * `always`. O resultado é tela correta com roteamento errado, e nada acusa.
 *
 * Portanto: dar `branches` a um nó que já tem arestas exige reescrever essas
 * arestas no MESMO instante — operação de canvas, atômica. É por isso que o
 * `ClassifyForm` ainda emite v1 de propósito; a ressalva está lá também.
 */

/**
 * What branch resolution actually reads off a node: its type and its config.
 * Written as a mapped union instead of `Pick<FlowNode, 'type' | 'config'>`
 * because `Pick` over a union flattens it and loses the correlation between the
 * two — and because the builder holds React Flow nodes, which have no
 * `position` in the shape this file expects. A full `FlowNode` is assignable.
 */
export type BranchableNode = {
  [K in NodeType]: Pick<Extract<FlowNode, { type: K }>, 'type' | 'config'>;
}[NodeType];

/** `fallback` is the mandatory catch-all: exactly one per node, never deletable in the builder. */
export type FlowBranchKind = 'match' | 'fallback';

export type FlowBranch = {
  /** Stable within the node. Reserved ids are contract-owned; the rest come from the user's config. */
  id: string;
  /**
   * Text that is ALREADY decided: what the user typed (the rule's label, the
   * class name) or a fixed term of the contract. `null` on a `per_check` branch
   * the user never named — there the sentence is COMPOSED from `check`, and
   * composing pt-br out of a rule is the vocabulary's job (`vocabulario.ts`,
   * `fraseDaCondicao`), not the contract's. This file must not grow a second
   * field/operator dictionary next to that one.
   */
  label: string | null;
  /** The rule behind a `per_check` branch, so the vocabulary can phrase it. `null` on every other branch. */
  check: ConditionCheck | null;
  kind: FlowBranchKind;
  /** The condition an edge leaving through this branch must carry — canonical for THIS node's dialect. */
  condition: FlowEdgeCondition;
};

/** Nó de saída ÚNICA: a aresta sai por ali sempre, e é isso que o rótulo diz. */
const FALLBACK_ALWAYS_LABEL = 'Sempre';
/**
 * Nó que JÁ tem saídas específicas. O motor só usa esta aresta quando nenhuma
 * outra serve (`selectEdge`, node-handlers), e nunca manda o lead por duas ao
 * mesmo tempo — "Sempre" ao lado de "Interessado" e "Sem resposta" prometia
 * justamente isso, e quem montava o fluxo ligava aqui a mensagem que queria
 * mandar a todo mundo.
 */
const FALLBACK_OTHERS_LABEL = 'Outros casos';
/** O mesmo escape num nó cujas saídas são REGRAS: "o resto", dito com a palavra das regras. */
const FALLBACK_NONE_LABEL = 'Nenhuma delas';
const NO_REPLY_LABEL = 'Sem resposta';

function fallbackBranch(label: string): FlowBranch {
  return {
    id: FALLBACK_BRANCH_ID,
    label,
    check: null,
    kind: 'fallback',
    condition: { type: 'always' },
  };
}

/**
 * Every output of `node`, in the order the builder should draw the handles,
 * with the mandatory fallback last. A v1 node yields v1 conditions and a v2
 * node yields `branch` conditions — a published flow is never rewritten just
 * because the contract grew.
 */
export function nodeBranches(node: BranchableNode): FlowBranch[] {
  switch (node.type) {
    case 'condition': {
      if (node.config.branching === 'per_check') {
        const branches: FlowBranch[] = node.config.checks.flatMap((check) =>
          check.id === undefined
            ? [] // unreachable for a parsed config: 'per_check' refines every check to have an id
            : [
                {
                  id: check.id,
                  label: check.label ?? null, // null: o vocabulário monta a frase a partir de `check`
                  check,
                  kind: 'match' as const,
                  condition: { type: 'branch' as const, branch_id: check.id },
                },
              ]
        );
        return [...branches, fallbackBranch(FALLBACK_NONE_LABEL)];
      }
      return [
        {
          id: CONDITION_TRUE_BRANCH_ID,
          label: 'Sim',
          check: null,
          kind: 'match',
          condition: { type: 'cond_result', value: true },
        },
        {
          id: CONDITION_FALSE_BRANCH_ID,
          label: 'Não',
          check: null,
          kind: 'match',
          condition: { type: 'cond_result', value: false },
        },
        fallbackBranch(FALLBACK_OTHERS_LABEL),
      ];
    }

    case 'ai_classify': {
      const declared = node.config.branches;
      const classBranches: FlowBranch[] = declared
        ? declared.map((b) => ({
            id: b.id,
            label: b.label,
            check: null,
            kind: 'match' as const,
            condition: { type: 'branch' as const, branch_id: b.id },
          }))
        : node.config.classes.map((cls) => ({
            id: cls, // v1: the class string is its own branch id
            label: cls,
            check: null,
            kind: 'match' as const,
            condition: { type: 'class_match' as const, value: cls },
          }));
      return [
        ...classBranches,
        {
          id: NO_REPLY_BRANCH_ID,
          label: NO_REPLY_LABEL,
          check: null,
          kind: 'match',
          condition: declared
            ? { type: 'branch', branch_id: NO_REPLY_BRANCH_ID }
            : { type: 'class_match', value: NO_REPLY_BRANCH_ID },
        },
        fallbackBranch(FALLBACK_OTHERS_LABEL),
      ];
    }

    case 'match_reply': {
      const classBranches: FlowBranch[] = node.config.branches.map((b) => ({
        id: b.id,
        label: b.label,
        check: null,
        kind: 'match' as const,
        condition: { type: 'branch' as const, branch_id: b.id },
      }));
      return [
        ...classBranches,
        {
          id: NO_REPLY_BRANCH_ID,
          label: NO_REPLY_LABEL,
          check: null,
          kind: 'match',
          condition: { type: 'branch', branch_id: NO_REPLY_BRANCH_ID },
        },
        fallbackBranch(FALLBACK_OTHERS_LABEL),
      ];
    }

    case 'repeat':
      return [
        {
          id: REPEAT_BODY_BRANCH_ID,
          label: 'Próxima volta',
          check: null,
          kind: 'match',
          condition: { type: 'branch', branch_id: REPEAT_BODY_BRANCH_ID },
        },
        {
          id: REPEAT_DONE_BRANCH_ID,
          label: 'Acabou',
          check: null,
          kind: 'match',
          condition: { type: 'branch', branch_id: REPEAT_DONE_BRANCH_ID },
        },
        fallbackBranch(FALLBACK_OTHERS_LABEL),
      ];

    default:
      return [fallbackBranch(FALLBACK_ALWAYS_LABEL)];
  }
}

/**
 * Which branch of `source` an edge condition serves, or `null` when it names
 * nothing on that node (a rule deleted while an edge still pointed at it —
 * publish validation's job to surface, not the parser's: rejecting it at parse
 * time would make a half-built draft unsaveable).
 * Accepts both dialects so a v2 node that still carries a leftover v1 edge
 * resolves instead of silently losing its route.
 */
export function branchIdForCondition(
  source: BranchableNode | undefined,
  condition: FlowEdgeCondition
): string | null {
  if (condition.type === 'always') return FALLBACK_BRANCH_ID;
  if (source === undefined) {
    return condition.type === 'branch'
      ? condition.branch_id
      : condition.type === 'class_match'
        ? condition.value
        : condition.value
          ? CONDITION_TRUE_BRANCH_ID
          : CONDITION_FALSE_BRANCH_ID;
  }

  const branches = nodeBranches(source);
  if (condition.type === 'branch') {
    return branches.some((b) => b.id === condition.branch_id) ? condition.branch_id : null;
  }
  if (condition.type === 'class_match') {
    // v1 spelling: the value is the branch id on a v1 node and the LABEL on a v2 one.
    const byId = branches.find((b) => b.id === condition.value);
    if (byId) return byId.id;
    const byLabel = branches.find((b) => b.kind === 'match' && b.label === condition.value);
    return byLabel?.id ?? null;
  }
  const id = condition.value ? CONDITION_TRUE_BRANCH_ID : CONDITION_FALSE_BRANCH_ID;
  return branches.some((b) => b.id === id) ? id : null;
}

/** The condition an edge must carry to leave `node` through `branchId` — `null` if no such branch. */
export function conditionForBranch(node: BranchableNode, branchId: string): FlowEdgeCondition | null {
  return nodeBranches(node).find((b) => b.id === branchId)?.condition ?? null;
}
