import { createHash } from "node:crypto";
import type pg from "pg";
import type { SupabaseClient } from "@supabase/supabase-js";
import { audit } from "@/lib/audit";
import { createMcpAgentDraft } from "@/lib/ai/agents/create-draft";
import { publishAgentVersion } from "@/lib/ai/agents/publish";
import { escolherModeloDoProvedor } from "@/lib/ai/agents/escolher-modelo";
import { listarModelosDaAssinaturaOuVazio } from "@/lib/ai/catalogo/modelos-da-assinatura";
import { capacidadesPadraoDoOnboarding } from "@/lib/ai/agents/capacidades-padrao";
import { versionCreateSchema } from "@/lib/ai/agents/validation";
import { lockRouter, writeRouterMembers } from "@/lib/ai/agents/router-members";
import { chaveDePlataforma } from "@/lib/ai/runtime/agent";
import { PROVIDERS } from "@/lib/ai/agents/validation";
import { provedorOferecido } from "@/lib/ai/pontos/provedores-oferecidos";
import { capabilitiesOf } from "@/lib/channels/capabilities";
import type { ChannelProvider } from "@/lib/channels/types";
import { prospectingAgentSetupSchema, type ProspectingAgentSetupInput } from "./agent-setup-schema";
import { agentSessionLock, agentSetupSessionSchema } from "./agent-session-schema";
import { IDIOMA_DA_CONVERSA, idiomaDaOrganizacao } from "./idioma";
import type { Idioma } from "@/lib/i18n/idiomas";

export class AgentSetupError extends Error {
  constructor(
    message: string,
    public status = 422,
    public agentId?: string,
  ) {
    super(message);
  }
}
const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
export function setupProposalHash(input: ProspectingAgentSetupInput) {
  return hash({ ...input, enable_router_continuity: false });
}
export function setupAgentId(orgId: string, campaignId: string, requestId: string) {
  const h = hash(["prospecting-agent", orgId, campaignId, requestId]);
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;
}
/**
 * `idioma` é o da ORGANIZAÇÃO (`idioma.ts`): antes a linha dizia "Converse em
 * português do Brasil" para toda organização, e numa em espanhol o agente da
 * campanha nascia escrevendo em português. O padrão segue português — quem não
 * passa idioma fica como antes.
 */
export function prospectingAgentPrompt(
  input: ProspectingAgentSetupInput,
  idioma: Idioma = "pt-BR",
) {
  const tone = {
    cordial: "cordial e acolhedor",
    professional: "profissional e consultivo",
    direct: "direto e objetivo",
  }[input.tone];
  return `Você é ${input.name}, assistente de IA comercial. Converse em ${IDIOMA_DA_CONVERSA[idioma]}, em tom ${tone}, com mensagens curtas e uma pergunta por vez.
Identifique-se com transparência. A origem do contato é uma pesquisa de informações comerciais públicas; nunca alegue cadastro, pedido ou consentimento que a pessoa não confirmou.
Oferta e objetivo definidos pelo responsável:
${input.instruction}
Critérios de qualificação:
${input.qualification}
Descubra a necessidade antes de propor a solução. Não invente preços, benefícios, resultados, disponibilidade nem informações sobre a empresa. Quando faltar informação, diga isso e encaminhe para uma pessoa.
Respeite recusa e pedido para parar: não insista e encaminhe para atendimento humano. Pedido de humano deve ser atendido imediatamente.
O contato e a oportunidade desta campanha já estão no CRM. Consulte a oportunidade existente, anote apenas fatos confirmados e não crie duplicatas. Use somente o funil ${input.pipeline_id}. A etapa inicial é ${input.stage_id}; mova para ${input.qualified_stage_id} SOMENTE quando a conversa comprovar os critérios acima. Mensagem enviada ou resposta recebida, sozinhas, não significam qualificação.
Use as ferramentas disponíveis para registrar fatos e mover a oportunidade; nunca afirme que registrou algo se a ferramenta não confirmou. As instruções da campanha no contexto da conversa detalham a abordagem atual.`;
}

export interface ModelChoice {
  provider: string;
  model: string;
  credential_id: string | null;
  label: string;
}
/**
 * `oferece` é `provedorOferecido` (#2458): o modelo escolhido aqui vira o provedor
 * GRAVADO na versão do agente de prospecção, então a assinatura do ChatGPT com o
 * módulo `login_codex` desligado é PULADA e a escolha segue para o próximo
 * provedor utilizável. Pular, e não recusar, porque aqui ninguém pediu aquele
 * provedor: é uma escolha automática, e a próxima chave válida é a resposta que
 * a pessoa esperaria.
 */
export async function resolveSetupModel(
  db: pg.PoolClient,
  orgId: string,
  channelId: string | null,
  oferece: (provider: string) => boolean,
): Promise<ModelChoice> {
  let modelosDaAssinatura:
    | Awaited<ReturnType<typeof listarModelosDaAssinaturaOuVazio>>
    | null = null;
  const catalogoDaAssinatura = async () => {
    modelosDaAssinatura ??= await listarModelosDaAssinaturaOuVazio(orgId);
    return modelosDaAssinatura;
  };
  const published = await db.query(
    `select v.provider,v.model,v.credential_id,m.display_name,m.supports_tools from ai_agents a
     join ai_agent_versions v on v.organization_id=a.organization_id and v.id=a.published_version_id
     left join ai_models m on m.provider=v.provider and m.model_id=v.model and m.deprecated_at is null
     left join ai_provider_credentials c on c.organization_id=a.organization_id and c.id=v.credential_id and c.provider=v.provider
     where a.organization_id=$1 and a.archived_at is null and a.paused_at is null and v.status='published'
     and (v.credential_id is null or (c.is_active and c.validated_at is not null))
     order by (v.channel_session_id=$2) desc,a.priority desc,a.created_at`,
    [orgId, channelId],
  );
  for (const row of published.rows) {
    if (
      !PROVIDERS.includes(row.provider) ||
      !oferece(row.provider) ||
      (!row.credential_id && !chaveDePlataforma(row.provider))
    ) {
      continue;
    }
    const modeloDaAssinatura =
      row.provider === "openai-assinatura"
        ? (await catalogoDaAssinatura()).find((m) => m.model_id === row.model)
        : null;
    const temFerramentas =
      row.provider === "openai-assinatura"
        ? modeloDaAssinatura?.supports_tools === true
        : row.supports_tools === true;
    if (temFerramentas) {
      return {
        provider: row.provider,
        model: row.model,
        credential_id: row.credential_id,
        label: `${row.provider} · ${row.display_name ?? row.model}`,
      };
    }
  }
  const org = await db.query("select settings from organizations where id=$1", [orgId]);
  const credentials = await db.query(
    "select id,provider from ai_provider_credentials where organization_id=$1 and is_active and validated_at is not null order by created_at,id",
    [orgId],
  );
  const preferred = org.rows[0]?.settings?.llm?.provider;
  const providers = [
    ...new Set([preferred, ...credentials.rows.map((c) => c.provider), ...PROVIDERS]),
  ];
  for (const provider of providers) {
    if (!PROVIDERS.includes(provider) || !oferece(provider)) continue;
    const credential = credentials.rows.find((c) => c.provider === provider);
    if (!credential && !chaveDePlataforma(provider)) continue;
    if (provider === "openai-assinatura") {
      const models = await catalogoDaAssinatura();
      const selected = escolherModeloDoProvedor(models);
      if (selected.escolhido) {
        const model = models.find((m) => m.model_id === selected.modelId);
        return {
          provider,
          model: selected.modelId,
          credential_id: credential?.id ?? null,
          label: `${provider} · ${model?.display_name ?? selected.modelId}`,
        };
      }
      continue;
    }
    const models = await db.query(
      "select model_id,display_name,is_default_for_provider,supports_tools,input_price_per_million_cents,output_price_per_million_cents from ai_models where provider=$1 and deprecated_at is null",
      [provider],
    );
    const selected = escolherModeloDoProvedor(models.rows);
    if (selected.escolhido)
      return {
        provider,
        model: selected.modelId,
        credential_id: credential?.id ?? null,
        label: `${provider} · ${models.rows.find((m) => m.model_id === selected.modelId)?.display_name ?? selected.modelId}`,
      };
  }
  throw new AgentSetupError(
    "Configure uma chave de IA válida e um modelo com ferramentas em Agente de IA → Provedores.",
  );
}

/** Must run in a transaction: locks channel and router through final membership write. */
async function preflight(
  db: pg.PoolClient,
  orgId: string,
  input: ProspectingAgentSetupInput,
  agentId: string,
  prepareOnly = false,
) {
  const campaign = await db.query(
    "select id from prospecting_campaigns where organization_id=$1 and id=$2",
    [orgId, input.campaign_id],
  );
  if (!campaign.rows.length)
    throw new AgentSetupError("Campanha não encontrada nesta organização.", 404);
  const channel = await db.query(
    "select id,provider,status from channel_sessions where organization_id=$1 and id=$2 and archived_at is null for update",
    [orgId, input.channel_session_id],
  );
  const c = channel.rows[0];
  if (
    !c ||
    c.status !== "WORKING" ||
    !capabilitiesOf(c.provider as ChannelProvider).freeformOutsideWindow
  )
    throw new AgentSetupError("Escolha uma conexão ativa que permita iniciar conversas de texto.");
  const stages = await db.query(
    `select s.id from crm_stages s join crm_pipelines p on p.organization_id=s.organization_id and p.id=s.pipeline_id
     where s.organization_id=$1 and s.pipeline_id=$2 and s.id=any($3::uuid[])
     and not s.is_archived and not s.is_won and not s.is_lost and not p.is_archived`,
    [orgId, input.pipeline_id, [input.stage_id, input.qualified_stage_id]],
  );
  if (input.stage_id === input.qualified_stage_id || stages.rows.length !== 2)
    throw new AgentSetupError("Escolha duas etapas abertas e diferentes do mesmo funil.");
  const routers = await db.query(
    "select id from ai_routers where organization_id=$1 and channel_session_id=$2 and is_active",
    [orgId, input.channel_session_id],
  );
  if (routers.rows[0]) {
    const router = await lockRouter(db, orgId, routers.rows[0].id);
    if (!router.is_active || router.channel_session_id !== input.channel_session_id)
      throw new AgentSetupError("O roteador mudou durante a configuração. Tente novamente.", 409);
    if (!prepareOnly && router.config?.sticky === false && !input.enable_router_continuity)
      throw new AgentSetupError(
        "Ative a continuidade do agente nesta tela para que ele acompanhe as respostas no canal.",
      );
    return router;
  }
  const incumbent = await db.query(
    `select a.id from ai_agents a join ai_agent_versions v on v.organization_id=a.organization_id and v.id=a.published_version_id
     where a.organization_id=$1 and a.archived_at is null and v.status='published' and v.channel_session_id=$2 and a.id<>$3 limit 1`,
    [orgId, input.channel_session_id, agentId],
  );
  if (incumbent.rows.length)
    throw new AgentSetupError(
      "Este canal já tem um agente e não tem roteador. Use o agente existente, escolha outro canal ou configure um roteador em Agente de IA → Roteadores.",
    );
  return null;
}

interface SetupMetadata {
  hash: string;
  version_id: string;
  model_label: string;
  state: "draft" | "ready";
  version_hash: string;
  paused_at: string;
  proposal_hash?: string;
  prepared_only?: boolean;
}
// Colunas em que `null` na linha do banco é o mesmo que ausente na versão montada: o hash
// gravado na criação parte do objeto parseado (sem a chave), e a conferência parte da
// linha lida (com `null`). Sem esta lista os dois hashes divergem e o setup recusa com
// "O rascunho foi alterado" — o que a 0498 (inbound_debounce_ms) fez nas 5 specs de
// tests/invariants/prospecting-agent-setup.test.ts.
// ponytail: lista explícita; coluna nullable nova em ai_agent_versions entra aqui (#2004).
const NULO_E_AUSENTE = new Set(["trigger_config", "inbound_debounce_ms", "reply_min_interval_ms"]);

function versionHash(row: Record<string, unknown>) {
  const keys = Object.keys(versionCreateSchema.shape);
  return hash(
    versionCreateSchema.parse(
      Object.fromEntries(
        keys
          .filter((key) => row[key] !== undefined && !(NULO_E_AUSENTE.has(key) && row[key] === null))
          .map((key) => [key, row[key]]),
      ),
    ),
  );
}

export async function setupProspectingAgent(
  pool: pg.Pool,
  admin: SupabaseClient,
  context: { orgId: string; userId: string; requestId: string },
  raw: unknown,
  options: { prepareOnly?: boolean } = {},
) {
  const input = prospectingAgentSetupSchema.parse(raw);
  const agentId = setupAgentId(context.orgId, input.campaign_id, input.request_id);
  const requestHash = hash(input);
  const db = await pool.connect();
  // #2624 — mesma marcação do #2621: o finally só libera COM erro quem falhou
  // dentro da transação (release(err) → _remove no pg-pool, cliente descartado).
  let erroNaTransacao: Error | undefined;
  let saved = false;
  const locked: string[] = [];
  let createdNow = false;
  try {
    // Keep the channel reservation across the canonical publish HTTP call.
    // Otherwise two distinct requests can each publish a paused incumbent and
    // then prevent one another from completing. Fixed order: channel, request.
    for (const key of [
      agentSessionLock(context.orgId, input.campaign_id),
      `prospecting-agent-channel:${context.orgId}:${input.channel_session_id}`,
      `prospecting-agent:${agentId}`,
    ]) {
      await db.query("select pg_advisory_lock(hashtextextended($1,0))", [key]);
      locked.push(key);
    }
    const savedSession = await db.query(
      "select agent_setup,agent_setup_revision from prospecting_campaigns where organization_id=$1 and id=$2",
      [context.orgId, input.campaign_id],
    );
    if (Number(savedSession.rows[0]?.agent_setup_revision ?? 0) > 0) {
      const session = agentSetupSessionSchema.parse(savedSession.rows[0].agent_setup);
      if (
        session.attempt_action &&
        session.attempt_action !== (options.prepareOnly ? "prepare" : "publish")
      )
        throw new AgentSetupError(
          "A ação mudou. Salve a confirmação atual antes de continuar.",
          409,
        );
      if (!session.attempt || hash(session.attempt) !== requestHash || session.input.trim())
        throw new AgentSetupError(
          "O resumo mudou. Salve a configuração atual antes de preparar o agente.",
          409,
        );
    }
    await db.query("begin");
    const existing = await db.query(
      "select id,name,config,published_version_id,archived_at from ai_agents where organization_id=$1 and id=$2 for update",
      [context.orgId, agentId],
    );
    let metadata: SetupMetadata;
    if (existing.rows[0]) {
      saved = true;
      const agent = existing.rows[0];
      metadata = agent.config?.prospecting_setup;
      const changedContinuityAfterPreview =
        metadata?.prepared_only &&
        !agent.published_version_id &&
        metadata.proposal_hash === setupProposalHash(input);
      if (
        !metadata ||
        (metadata.hash !== requestHash && !changedContinuityAfterPreview) ||
        agent.archived_at
      )
        throw new AgentSetupError(
          "Esta tentativa já foi usada com outra configuração. Revise o agente existente antes de criar outro.",
          409,
          agentId,
        );
      if (metadata.state === "ready") {
        await db.query("commit");
        return {
          agent: { id: agentId, name: agent.name },
          version_id: metadata.version_id,
          model_label: metadata.model_label,
        };
      }
      const version = await db.query(
        "select * from ai_agent_versions where organization_id=$1 and agent_id=$2 and id=$3",
        [context.orgId, agentId, metadata.version_id],
      );
      if (
        !version.rows[0] ||
        versionHash(version.rows[0]) !== metadata.version_hash ||
        (agent.published_version_id && agent.published_version_id !== metadata.version_id)
      )
        throw new AgentSetupError(
          "O rascunho foi alterado. Revise e publique no editor do agente.",
          409,
          agentId,
        );
      await preflight(db, context.orgId, input, agentId, options.prepareOnly);
      if (metadata.prepared_only && !options.prepareOnly) {
        metadata = { ...metadata, hash: requestHash, prepared_only: false };
        await db.query(
          "update ai_agents set config=jsonb_set(config,'{prospecting_setup}',$3::jsonb) where organization_id=$1 and id=$2",
          [context.orgId, agentId, JSON.stringify(metadata)],
        );
      }
    } else {
      await preflight(db, context.orgId, input, agentId, options.prepareOnly);
      const model = await resolveSetupModel(
        db,
        context.orgId,
        input.channel_session_id,
        await provedorOferecido(admin),
      );
      const version = versionCreateSchema.parse({
        system_prompt: prospectingAgentPrompt(input, await idiomaDaOrganizacao(db, context.orgId)),
        provider: model.provider,
        model: model.model,
        credential_id: model.credential_id,
        channel_session_id: input.channel_session_id,
        tool_ids: capacidadesPadraoDoOnboarding().filter((id) =>
          [
            "crm_get_lead",
            "crm_list_leads",
            "crm_list_pipelines",
            "crm_update_lead",
            "crm_move_lead_stage",
          ].includes(id),
        ),
        pipeline_ids: [input.pipeline_id],
        knowledge_source_ids: [],
        handoff_tool_enabled: true,
        trigger_config: {
          events: ["message"],
          filters: {
            ignore_groups: true,
            ignore_self: true,
            keyword_regex: null,
            business_hours: null,
          },
          concurrency: "one_per_conversation",
        },
      });
      metadata = {
        hash: requestHash,
        version_id: setupAgentId(context.orgId, agentId, "version"),
        model_label: model.label,
        state: "draft",
        version_hash: versionHash(version),
        paused_at: new Date().toISOString(),
        proposal_hash: setupProposalHash(input),
        prepared_only: options.prepareOnly === true,
      };
      await createMcpAgentDraft(
        db,
        context,
        { name: input.name, description: "Agente comercial criado pela Prospecção", version },
        {
          agentId,
          versionId: metadata.version_id,
          pausedAt: new Date(metadata.paused_at),
          config: { prospecting_setup: metadata },
        },
      );
      createdNow = true;
    }
    await db.query("commit");
    saved = true;
    if (createdNow)
      await audit({
        action: "ai_agent.created",
        actorUserId: context.userId,
        organizationId: context.orgId,
        resourceType: "ai_agent",
        resourceId: agentId,
        requestId: context.requestId,
        metadata: { source: "prospecting", campaign_id: input.campaign_id, draft: true },
      });
    if (options.prepareOnly)
      return {
        agent: { id: agentId, name: input.name },
        version_id: metadata.version_id,
        model_label: metadata.model_label,
      };
    // Publication is canonical; the agent remains paused until routing commits.
    const current = await db.query(
      "select published_version_id from ai_agents where organization_id=$1 and id=$2",
      [context.orgId, agentId],
    );
    if (
      current.rows[0]?.published_version_id &&
      current.rows[0].published_version_id !== metadata.version_id
    )
      throw new AgentSetupError(
        "Outra versão foi publicada. Revise o agente no editor.",
        409,
        agentId,
      );
    if (current.rows[0]?.published_version_id !== metadata.version_id) {
      const result = await publishAgentVersion(admin, {
        orgId: context.orgId,
        agentId,
        versionId: metadata.version_id,
      });
      if (!result.ok)
        throw new AgentSetupError(
          "O rascunho foi salvo, mas a publicação não foi concluída. Verifique a chave de IA e a conexão; tente novamente ou abra o editor do agente.",
          422,
          agentId,
        );
    }
    await db.query("begin");
    const router = await preflight(db, context.orgId, input, agentId);
    await db.query("select id from ai_agents where organization_id=$1 and id=$2 for update", [
      context.orgId,
      agentId,
    ]);
    const finalVersion = await db.query(
      "select * from ai_agent_versions where organization_id=$1 and agent_id=$2 and id=$3",
      [context.orgId, agentId, metadata.version_id],
    );
    if (!finalVersion.rows[0] || versionHash(finalVersion.rows[0]) !== metadata.version_hash)
      throw new AgentSetupError(
        "O conteúdo do agente mudou. Revise a versão no editor antes de ativá-la.",
        409,
        agentId,
      );
    let continuityChanged = false;
    if (router) {
      if (router.config?.sticky === false) {
        await db.query(
          "update ai_routers set config=config || '{\"sticky\":true}'::jsonb where organization_id=$1 and id=$2",
          [context.orgId, router.id],
        );
        continuityChanged = true;
      }
      await writeRouterMembers(
        db,
        context.orgId,
        router.id,
        [
          {
            agent_id: agentId,
            intent_name: `prospeccao_${agentId}`,
            intent_description: `Respostas à abordagem comercial: ${input.instruction}`.slice(
              0,
              2000,
            ),
            examples: [],
          },
        ],
        "append",
      );
    }
    const activated = await db.query(
      `update ai_agents set paused_at=null,operation_mode='automatic',config=jsonb_set(config,'{prospecting_setup,state}','"ready"'::jsonb)
       where organization_id=$1 and id=$2 and archived_at is null and published_version_id=$3
       and paused_at=$4::timestamptz and operation_mode='automatic' and config->'prospecting_setup'->>'hash'=$5 returning id`,
      [context.orgId, agentId, metadata.version_id, metadata.paused_at, requestHash],
    );
    if (!activated.rows.length)
      throw new AgentSetupError(
        "O agente mudou durante a configuração. Revise o rascunho no editor.",
        409,
        agentId,
      );
    await db.query(
      `insert into event_log(organization_id,event_type,entity_kind,payload)
      values($1,'ai_agent.published','ai_agent',$2::jsonb)`,
      [
        context.orgId,
        JSON.stringify({
          agent_id: agentId,
          version_id: metadata.version_id,
          previous_version_id: null,
          source: "prospecting",
        }),
      ],
    );
    await db.query("commit");
    await audit({
      action: "ai_agent.published",
      actorUserId: context.userId,
      organizationId: context.orgId,
      resourceType: "ai_agent",
      resourceId: agentId,
      requestId: context.requestId,
      metadata: { version_id: metadata.version_id },
    });
    if (router)
      await audit({
        action: "ai.router_members_updated",
        actorUserId: context.userId,
        organizationId: context.orgId,
        resourceType: "ai_router",
        resourceId: router.id,
        requestId: context.requestId,
        metadata: { added_agent_id: agentId },
      });
    if (continuityChanged)
      await audit({
        action: "ai.router_updated",
        actorUserId: context.userId,
        organizationId: context.orgId,
        resourceType: "ai_router",
        resourceId: router.id,
        requestId: context.requestId,
        metadata: { patch: ["config.sticky"], sticky: true },
      });
    return {
      agent: { id: agentId, name: input.name },
      version_id: metadata.version_id,
      model_label: metadata.model_label,
    };
  } catch (error) {
    // Marcado ANTES do rollback: se o próprio rollback estourar, o cliente ainda
    // sai com erro e o pg-pool o descarta (release(err) → _remove → client.end()).
    erroNaTransacao = error instanceof Error ? error : new Error(String(error));
    await db.query("rollback").catch(() => undefined);
    if (error instanceof AgentSetupError) {
      if (saved) error.agentId = agentId;
      throw error;
    }
    throw new AgentSetupError(
      saved
        ? "A configuração do agente foi salva. Tente concluir novamente ou revise a configuração no editor."
        : "Não foi possível preparar o agente. Confira a configuração e tente novamente.",
      500,
      saved ? agentId : undefined,
    );
  } finally {
    let destroy = false;
    for (const key of locked.reverse()) {
      try {
        await db.query("select pg_advisory_unlock(hashtextextended($1,0))", [key]);
      } catch {
        destroy = true;
        break;
      }
    }
    // #2624: quem falhou dentro da transação sai COM erro; quem só não conseguiu
    // destravar o lock continua saindo com destroy explícito, como antes.
    db.release(erroNaTransacao ?? destroy);
  }
}
