/**
 * Zod schemas for `/api/v1/leads/*` endpoints (EPIC-04 waves 1-3).
 *
 * Contracts:
 *  - moveLeadSchema   → POST /api/v1/leads/[id]/move (P-01, P-05, P-08)
 *  - winLeadSchema    → POST /api/v1/leads/[id]/win  (P-02, idempotent)
 *  - loseLeadSchema   → POST /api/v1/leads/[id]/lose (P-02, P-03)
 *  - bulkLeadActionSchema → POST /api/v1/leads/bulk  (AT-06, max 50)
 */
import { z } from "zod";

/**
 * Accept either ISO 8601 (e.g. "2026-04-29T03:15:54.000Z") or Postgres-style
 * timestamptz (e.g. "2026-04-29 03:15:54.123456+00") since Supabase returns
 * the latter and the client passes it through. Both parse to the same Date.
 */
const flexibleTimestamp = z
  .string()
  .min(10)
  .refine((s) => !Number.isNaN(Date.parse(s)), "expected_updated_at deve ser um timestamp válido");

export const moveLeadSchema = z.object({
  stage_id: z.string().uuid(),
  /**
   * A POSIÇÃO É OPCIONAL (#4151).
   *
   * O arrasto do quadro sempre a manda (ele tem a coluna na tela e escolhe o
   * ponto exato entre dois cards). Quem abre o negócio em PÁGINA INTEIRA não
   * tem a coluna: só sabe a etapa de destino. Omitida, o servidor posiciona
   * pela MESMA régua de quem chega pelo menu ou pelo MCP — no topo da etapa de
   * destino (`posicaoDeEntradaNaEtapa`, min − 1000; #3461) —, lendo a etapa
   * inteira no banco em vez de adivinhar no cliente.
   *
   * Continua `finite`: posição explícita inválida não vira `NaN` no banco.
   */
  position_in_stage: z.number().finite().optional(),
  expected_updated_at: flexibleTimestamp,
  /**
   * O motivo da perda, quando a etapa de destino é de perda (issue #917). É o
   * caminho do ARRASTO: a decisão de exigir/gravar mora em
   * `lib/leads/motivo-da-perda.ts` — aqui só se aceita o campo, e um motivo em
   * branco é tratado lá como ausente (uma recusa de negócio, uma só, para os três
   * caminhos; string vazia morrendo no Zod daria uma mensagem de validação
   * diferente da que o /lose devolve para o mesmo caso).
   */
  lost_reason: z.string().max(500).optional(),
  /**
   * O motivo do ganho, quando a etapa de destino fecha o negócio como ganho
   * (issue #1536). Espelho do `lost_reason`: quem decide se é obrigatório é o
   * funil (`settings.won_reason_required`), e a decisão mora em
   * `lib/leads/campos-exigidos.ts` — aqui só se aceita o campo, e um motivo em
   * branco é tratado lá como ausente.
   */
  won_reason: z.string().max(500).optional(),
  /**
   * Os campos que o diálogo de "campos obrigatórios" coletou (issue #1536).
   *
   * Entram NA MESMA escrita que muda a etapa — o mesmo desenho do
   * `lost_reason` (#917): uma gravação separada teria janela (a etapa muda com
   * o campo ainda vazio) e uma segunda janela de OCC (o PATCH mudaria o
   * `updated_at` que o próprio arrasto acabou de usar). O servidor faz o merge
   * com o que o lead já tem e valida o VALOR COMBINADO — é por isso que a
   * segunda tentativa passa na mesma régua que a primeira recusou.
   */
  custom_fields: z.record(z.string(), z.unknown()).optional(),
});
export type MoveLeadInput = z.infer<typeof moveLeadSchema>;

/**
 * cloneLeadSchema → POST /api/v1/leads/[id]/clone (P-01).
 *
 * O caminho para OUTRO funil: `pipeline_id` é obrigatório, `stage_id` é opcional
 * (sem ele a primeira etapa aberta do funil destino recebe o negócio) e
 * `lost_reason` é o motivo do encerramento da ORIGEM — canônico ou estendido pelo
 * funil (o trigger do banco é a fonte de verdade, como em `loseLeadSchema`).
 */
export const cloneLeadSchema = z.object({
  pipeline_id: z.string().uuid(),
  stage_id: z.string().uuid().optional(),
  lost_reason: z.string().min(1).max(500).optional(),
});
export type CloneLeadInput = z.infer<typeof cloneLeadSchema>;

export const winLeadSchema = z.object({}).passthrough();
export type WinLeadInput = z.infer<typeof winLeadSchema>;

/**
 * Canonical lost reasons enforced by DB trigger fn_validate_lost_reason_required.
 * Pipeline.settings.lost_reasons (jsonb array) can extend this list per-tenant.
 */
export const CANONICAL_LOST_REASONS = [
  "requested_by_customer",
  "price",
  "no_response",
  "product_unavailable",
  "cancelled_by_store",
  "cancelled_by_customer",
  "payment_failed",
  "other",
  /**
   * Motivo do SISTEMA, não da lista do operador: é com ele que a troca de funil
   * encerra a origem (`lib/leads/motivo-da-perda.ts`, `MOTIVO_DA_TRANSFERENCIA`)
   * e é ele que `fn_attendant_metrics` NÃO conta como perda (migration 0266).
   * Consta aqui porque esta lista é o espelho do array canônico do trigger
   * `fn_validate_lost_reason_required`: um motivo aceito pelo banco e ausente
   * daqui é uma recusa de tela para uma escrita que funciona.
   */
  "moved_to_another_pipeline",
  /**
   * Motivo do SISTEMA para quem respondeu PARAR (#2049, migration 0513): a
   * ingestão fecha sozinha todo negócio aberto do contato com ele. Não é
   * `requested_by_customer` porque pedir silêncio não é cancelar. Diferente da
   * transferência, CONTA como perda — é oportunidade que foi embora.
   */
  "opted_out_of_messages",
] as const;

/**
 * O rótulo legível de cada motivo canônico (em pt-BR; a tela passa por `t()`).
 * Mora aqui, ao lado da lista, para o diálogo de perda, o filtro do quadro e o
 * relatório "Perdas" (#1537) mostrarem o MESMO texto — e nunca a chave crua.
 */
export const ROTULO_DO_MOTIVO_CANONICO: Record<(typeof CANONICAL_LOST_REASONS)[number], string> = {
  requested_by_customer: "Cliente solicitou cancelamento",
  price: "Preço",
  no_response: "Sem resposta do cliente",
  product_unavailable: "Produto indisponível",
  cancelled_by_store: "Cancelado pela loja",
  cancelled_by_customer: "Cancelado pelo cliente",
  payment_failed: "Falha no pagamento",
  other: "Outro motivo",
  moved_to_another_pipeline: "Levado para outro funil",
  opted_out_of_messages: "Pediu para não receber mensagens",
};

/** O rótulo pt-BR do motivo: o do canônico, ou o próprio texto do motivo do funil. */
export function rotuloDoMotivoDePerda(motivo: string): string {
  return (ROTULO_DO_MOTIVO_CANONICO as Record<string, string | undefined>)[motivo] ?? motivo;
}
export type CanonicalLostReason = (typeof CANONICAL_LOST_REASONS)[number];

/**
 * As categorias com que o relatório "Perdas" e o filtro do quadro agrupam
 * (issue #1537).
 *
 * São as do PRODUTO: o funil atribui categoria a cada motivo em
 * `settings.lost_reasons` (`{ label, categoria }`). `moved_to_another_pipeline` fica de fora de propósito — é motivo de
 * SISTEMA (a troca de funil encerra a origem) e a migration 0266 já o exclui
 * de `fn_attendant_metrics`; categoriá-lo seria abrir a porta de contar
 * transferência como perda comercial.
 */
export const CATEGORIAS_DE_PERDA = ["Cliente", "Concorrência", "Mérito", "Nós", "Ausência"] as const;
export type CategoriaDePerda = (typeof CATEGORIAS_DE_PERDA)[number];

/**
 * O teto de uma categoria (`settings.lost_reason_categories`, #4437) e o de
 * quantas o funil declara.
 *
 * Mora AQUI, ao lado da lista de fábrica, porque DOIS lados aplicam a mesma
 * régua: o schema do PATCH (`lib/schemas/settings`) e o leitor da tela/relatório
 * (`lib/leads/motivos-de-perda-do-funil`). O leitor não pode importar o schema
 * de settings — settings importa daqui —, então o número fica neste arquivo,
 * que não importa nada de settings e é o berço da lista.
 *
 * 40 é o mesmo teto da categoria de um motivo, e não é coincidência: são o
 * mesmo vocabulário, escrito uma vez no funil e usado no motivo.
 */
export const MAX_CARACTERES_DA_CATEGORIA_DE_PERDA = 40;
/** Acima disso a tela de categorias não é lista, é rolagem — o mesmo espírito do teto de motivos. */
export const MAX_CATEGORIAS_DE_PERDA = 30;

/**
 * A categoria PADRÃO de cada motivo canônico (issue #1537): o motivo já nasce
 * agrupado sem que ninguém cadastre nada, e o funil pode SOBRESCREVER gravando
 * `{ label, categoria }` em `settings.lost_reasons`.
 *
 * `other` não tem categoria de propósito: sem saber o que aconteceu, a tela
 * estaria inventando informação que o relatório repete como fato.
 *
 * Os rótulos são a pergunta "de quem foi a ação que perdeu o negócio":
 * `price` é "Nós" porque quem manda no preço é a casa (perdemos no preço que
 * pedimos), `no_response` é a ausência do outro lado, `cancelled_by_customer`
 * e `requested_by_customer` são o cliente.
 */
export const CATEGORIA_PADRAO_DO_MOTIVO: Partial<Record<CanonicalLostReason, CategoriaDePerda>> = {
  requested_by_customer: "Cliente",
  opted_out_of_messages: "Cliente",
  cancelled_by_customer: "Cliente",
  payment_failed: "Cliente",
  price: "Nós",
  product_unavailable: "Nós",
  cancelled_by_store: "Nós",
  no_response: "Ausência",
};

/** A categoria de um valor gravado em `lost_reason`, se o produto tem uma. */
export function categoriaPadraoDoMotivo(valor: string): CategoriaDePerda | undefined {
  return CATEGORIA_PADRAO_DO_MOTIVO[valor.trim() as CanonicalLostReason];
}

/**
 * loseLeadSchema accepts canonical reasons OR any string (pipeline-extended).
 * The server-side DB trigger is the source of truth; we keep the Zod schema
 * permissive here to not block tenant-specific extensions.
 */
export const loseLeadSchema = z.object({
  lost_reason: z.string().min(1, "lost_reason é obrigatório").max(500),
});
export type LoseLeadInput = z.infer<typeof loseLeadSchema>;

/**
 * createLeadSchema → POST /api/v1/leads
 * Status, source_metadata, position_in_stage are server-managed.
 */
export const createLeadSchema = z.object({
  pipeline_id: z.string().uuid(),
  stage_id: z.string().uuid(),
  title: z.string().min(2).max(200),
  description: z.string().max(2000).nullable().optional(),
  contact_id: z.string().uuid().nullable().optional(),
  value_cents: z.coerce.number().int().nonnegative().nullable().optional(),
  /**
   * O VALOR RECORRENTE, ao lado do valor único (fatia 1 da #3602).
   *
   * `nonnegative` é a MESMA régua do `value_cents`: mensalidade negativa é dado
   * impossível, e o CHECK da migration 9028 repete a regra no banco para quem
   * escreve por fora do schema (psql, import, integração).
   */
  recurring_value_cents: z.coerce.number().int().nonnegative().nullable().optional(),
  /**
   * A periodicidade. `literal("monthly")` e não `z.string()`: a v1 conhece UMA,
   * e aceitar texto livre deixaria "yearly" gravado sem nenhuma régua que o
   * multiplique — o painel da fatia 2 somaria mensalidade com anualidade.
   */
  recurring_period: z.literal("monthly").nullable().optional(),
  /**
   * A duração em meses; ausente/nula = sem prazo. Zero não é "sem prazo" — é
   * contrato que não dura nada, e quem divide pelo prazo dividiria por ele.
   */
  recurring_months: z.coerce.number().int().min(1).max(600).nullable().optional(),
  /**
   * Sem `default`, e isso É o conserto.
   *
   * Com `.default("BRL")` o campo nunca chegava ausente ao handler: quem
   * omitia a moeda recebia real, e uma organização que declarou peso ou dólar
   * em Configurações via cada lead novo nascer em BRL — o mesmo defeito que a
   * migration 0208 consertou no catálogo de produtos, repetido no funil. O
   * padrão não é do schema porque ele não sabe de que organização se trata; é
   * do handler, que resolve pela `moedaDaOrganizacao()`.
   */
  currency: z.string().length(3).optional(),
  owner_user_id: z.string().uuid().nullable().optional(),
  /** Dono agente já na criação (0070) — mesma regra do update: os dois é 422. */
  owner_agent_id: z.string().uuid().nullable().optional(),
  expected_close_date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .nullable()
    .optional(),
  tags: z.array(z.string()).default([]),
  source: z.string().min(1).default("manual"),
  /**
   * Os campos personalizados JÁ PREENCHIDOS no formulário de criação (issue
   * #3038).
   *
   * O `createLeadHandler` valida e grava `custom_fields` desde a criação — a
   * régua da etapa em que o negócio NASCE (#1710) lê exatamente este campo —,
   * mas a ROTA não aceitava a chave: `z.object` a descartava em silêncio, e a
   * recusa chegava como "Preencha os campos obrigatórios antes de continuar" para
   * um valor que o cliente tinha acabado de mandar. Com o `NewLeadDialog`
   * exibindo os campos exigidos da etapa, sem esta linha o formulário coletaria
   * e o servidor recusaria do mesmo jeito.
   *
   * Espelho do `updateLeadSchema` (mesmo `Record<string, unknown>`): o formato
   * do valor é do funil, e quem o julga é `obrigatorio_em`/`padrao` do campo,
   * nunca o schema.
   */
  custom_fields: z.record(z.string(), z.unknown()).optional(),
});
export type CreateLeadInput = z.infer<typeof createLeadSchema>;

/**
 * updateLeadSchema → PATCH /api/v1/leads/[id]
 * Stage/pipeline transitions go through /move /win /lose endpoints.
 */
export const updateLeadSchema = z.object({
  title: z.string().min(2).max(200).optional(),
  description: z.string().max(2000).nullable().optional(),
  contact_id: z.string().uuid().nullable().optional(),
  value_cents: z.coerce.number().int().nonnegative().nullable().optional(),
  /**
   * O VALOR RECORRENTE, ao lado do valor único (fatia 1 da #3602).
   *
   * `nonnegative` é a MESMA régua do `value_cents`: mensalidade negativa é dado
   * impossível, e o CHECK da migration 9028 repete a regra no banco para quem
   * escreve por fora do schema (psql, import, integração).
   */
  recurring_value_cents: z.coerce.number().int().nonnegative().nullable().optional(),
  /**
   * A periodicidade. `literal("monthly")` e não `z.string()`: a v1 conhece UMA,
   * e aceitar texto livre deixaria "yearly" gravado sem nenhuma régua que o
   * multiplique — o painel da fatia 2 somaria mensalidade com anualidade.
   */
  recurring_period: z.literal("monthly").nullable().optional(),
  /**
   * A duração em meses; ausente/nula = sem prazo. Zero não é "sem prazo" — é
   * contrato que não dura nada, e quem divide pelo prazo dividiria por ele.
   */
  recurring_months: z.coerce.number().int().min(1).max(600).nullable().optional(),
  currency: z.string().length(3).optional(),
  owner_user_id: z.string().uuid().nullable().optional(),
  /**
   * Dono agente (0070). Exclusivo com owner_user_id — mandar os dois não-nulos
   * é 422. `owner_kind` NÃO entra aqui: é derivado no handler a partir de qual
   * dos dois veio, para a constraint crm_leads_owner_kind_coherence nunca
   * depender do que o cliente mandou.
   */
  owner_agent_id: z.string().uuid().nullable().optional(),
  expected_close_date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .nullable()
    .optional(),
  tags: z.array(z.string()).optional(),
  custom_fields: z.record(z.string(), z.unknown()).optional(),
});
export type UpdateLeadInput = z.infer<typeof updateLeadSchema>;

export const bulkLeadActionSchema = z.discriminatedUnion("action", [
  z.object({
    action: z.literal("move"),
    lead_ids: z.array(z.string().uuid()).min(1).max(50),
    // Sem `position_in_stage`: quem posiciona o lote é o banco
    // (`fn_mover_leads_em_lote`, migration 0209), porque N cards precisam de N
    // posições distintas e um campo escalar só sabe dizer uma. Um cliente antigo
    // que ainda o mande não quebra — `z.object` descarta chave desconhecida —,
    // e é melhor que ele suma do que ficar aceito e ignorado.
    params: z.object({
      stage_id: z.string().uuid(),
      /**
       * O motivo da perda, quando a etapa de destino é de perda (issue #917):
       * o lote fecha N negócios de uma vez, então UM motivo vale para todos os
       * cards que ainda não têm um. A decisão (e a recusa de negócio) mora em
       * `lib/leads/motivo-da-perda.ts`.
       */
      lost_reason: z.string().max(500).optional(),
    }),
  }),
  z.object({
    action: z.literal("assign"),
    lead_ids: z.array(z.string().uuid()).min(1).max(50),
    params: z.object({ owner_user_id: z.string().uuid().nullable() }),
  }),
  z.object({
    action: z.literal("tag"),
    lead_ids: z.array(z.string().uuid()).min(1).max(50),
    params: z.object({
      add: z.array(z.string()).optional(),
      remove: z.array(z.string()).optional(),
    }),
  }),
  z.object({
    action: z.literal("delete"),
    lead_ids: z.array(z.string().uuid()).min(1).max(50),
    params: z.object({}).optional(),
  }),
]);
export type BulkLeadActionInput = z.infer<typeof bulkLeadActionSchema>;


/**
 * retomarLeadSchema → POST /api/v1/leads/{id}/retomar (issue #1538).
 *
 * Só a etapa da NOVA tentativa, e é opcional: sem ela o handler escolhe a
 * primeira etapa aberta do funil (a mesma decisão do clone sem `stage_id`).
 * O lead de ORIGEM vem no caminho da URL, nunca no corpo — quem retoma é quem
 * já apontou o dedo para aquele card.
 */
export const retomarLeadSchema = z.object({
  stage_id: z.string().uuid().optional(),
});
export type RetomarLeadBody = z.infer<typeof retomarLeadSchema>;
