/**
 * G6-01 acceptance 6 — unit por tool nova de governança, contra os handlers REAIS
 * (ctx.supabase stub). Cobre: sucesso, cross-org negado, input inválido,
 * idempotência (assign) e números exatos (queue_status).
 */
import { describe, expect, it, vi } from "vitest";

import {
  crmAssignConversation,
  crmManageTags,
  crmGetQueueStatus,
} from "@/lib/mcp/tools/governance";
import { getQueueStatus } from "@/lib/routing/queue";
import type { McpContext } from "@/lib/mcp/types";

vi.mock("@/lib/audit", () => ({ audit: vi.fn().mockResolvedValue(undefined) }));

const ORG = "22222222-2222-4222-8222-222222222222";
const CONV = "44444444-4444-4444-8444-444444444444";
const USER_A = "11111111-1111-4111-8111-111111111111";
const USER_B = "33333333-3333-4333-8333-333333333333";

interface Query {
  table: string;
  select: string | null;
  count: boolean;
  terminal: "maybeSingle" | "then";
}
type Resolver = (q: Query) => { data?: unknown; count?: number; error?: unknown };

interface Captures {
  rpc: Array<{ fn: string; args: Record<string, unknown> }>;
  updates: Array<{ table: string; values: Record<string, unknown> }>;
  inserts: Array<{ table: string; values: Record<string, unknown> }>;
  rpcResult: { data: unknown; error: unknown };
  /** Resultados de rpc consumidos EM ORDEM (simula corrida); fallback rpcResult. */
  rpcResults?: Array<{ data: unknown; error: unknown }>;
}

function makeSupabase(resolve: Resolver, cap: Captures) {
  const from = (table: string) => {
    const q: Query = { table, select: null, count: false, terminal: "then" };
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const chain: any = {
      select: (cols: string, opts?: { head?: boolean }) => {
        q.select = cols;
        q.count = Boolean(opts?.head);
        return chain;
      },
      eq: () => chain,
      is: () => chain,
      in: () => chain,
      lte: () => chain,
      order: () => chain,
      limit: () => chain,
      update: (values: Record<string, unknown>) => {
        cap.updates.push({ table, values });
        return chain;
      },
      insert: (values: Record<string, unknown>) => {
        cap.inserts.push({ table, values });
        return Promise.resolve({ data: null, error: null });
      },
      maybeSingle: () => Promise.resolve(resolve({ ...q, terminal: "maybeSingle" })),
      then: (res: (v: unknown) => unknown) =>
        Promise.resolve(resolve({ ...q, terminal: "then" })).then(res),
    };
    return chain;
  };
  return {
    from,
    rpc: (fn: string, args: Record<string, unknown>) => {
      cap.rpc.push({ fn, args });
      const next = cap.rpcResults?.shift();
      return Promise.resolve(next ?? cap.rpcResult);
    },
  };
}

function makeCap(over: Partial<Captures> = {}): Captures {
  return {
    rpc: [],
    updates: [],
    inserts: [],
    rpcResult: { data: [{ id: CONV }], error: null },
    ...over,
  };
}

function makeCtx(resolve: Resolver, cap: Captures): McpContext {
  return {
    organizationId: ORG,
    role: "agent",
    actor: { type: "ai_agent", id: "run_1", role: "agent", api_token_id: "tok" },
    apiTokenId: "tok",
    requestId: "req",
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    supabase: makeSupabase(resolve, cap) as any,
  } as McpContext;
}

// ---------------------------------------------------------------------------
// crm_assign_conversation
// ---------------------------------------------------------------------------

describe("crm_assign_conversation", () => {
  const convAssigned = (owner: string | null): Resolver => (q) =>
    q.table === "conversations" && q.terminal === "maybeSingle"
      ? { data: { id: CONV, organization_id: ORG, assigned_to_user_id: owner }, error: null }
      : { data: null, error: null };

  it("sucesso: transfere e chama fn_conversation_assign (evento na fn)", async () => {
    const cap = makeCap();
    const res = (await crmAssignConversation.handler(
      { conversation_id: CONV, to_user_id: USER_A, reason: "transfer" },
      makeCtx(convAssigned(null), cap),
    )) as { assigned: boolean; assigned_to_user_id: string | null; idempotent: boolean };

    expect(res.assigned).toBe(true);
    expect(res.idempotent).toBe(false);
    expect(res.assigned_to_user_id).toBe(USER_A);
    expect(cap.rpc).toEqual([
      {
        fn: "fn_conversation_assign",
        args: {
          p_organization_id: ORG,
          p_conversation_id: CONV,
          p_to_user_id: USER_A,
          p_reason: "transfer",
          p_expected_assignee: null,
          p_enforce_expected: true,
          p_source_job_id: null,
        },
      },
    ]);
  });

  it("dentro do turno, informa o job de origem (é o que impede a revogação do próprio turno)", async () => {
    const cap = makeCap();
    const ctx = makeCtx(convAssigned(null), cap);
    // `sourceJobId` é do RUNTIME: o modelo não escreve este campo, e é por isso
    // que ele pode poupar o job da revogação sem abrir porta para a IA.
    await crmAssignConversation.handler(
      { conversation_id: CONV, to_user_id: USER_A, reason: "transfer" },
      { ...ctx, sourceJobId: "job-do-turno" },
    );
    expect(cap.rpc[0]!.args.p_source_job_id).toBe("job-do-turno");
  });

  it("fora do turno (rota MCP, Inbox), não informa origem: nulo", async () => {
    const cap = makeCap();
    await crmAssignConversation.handler(
      { conversation_id: CONV, to_user_id: USER_A, reason: "transfer" },
      makeCtx(convAssigned(null), cap),
    );
    expect(cap.rpc[0]!.args.p_source_job_id).toBeNull();
  });

  it("corrida: 2 assigns idênticos ⇒ optimistic lock deixa 1 evento (2º idempotente)", async () => {
    // Ambos leem o dono antigo (null). 1º rpc vence (1 row+evento); 2º rpc perde
    // o lock (0 rows) e, no recheck, vê o dono já = alvo ⇒ idempotente, SEM 2º evento.
    const raceResolve: Resolver = (q) => {
      if (q.table === "conversations" && q.terminal === "maybeSingle") {
        // fetch inicial lê dono antigo; recheck (select curto) lê o dono já aplicado.
        const owner = q.select === "assigned_to_user_id" ? USER_A : null;
        return { data: { id: CONV, organization_id: ORG, assigned_to_user_id: owner }, error: null };
      }
      return { data: null, error: null };
    };
    const input = { conversation_id: CONV, to_user_id: USER_A, reason: "transfer" as const };

    const capWin = makeCap({ rpcResults: [{ data: [{ id: CONV }], error: null }] });
    const first = (await crmAssignConversation.handler(input, makeCtx(raceResolve, capWin))) as {
      idempotent: boolean;
    };
    expect(first.idempotent).toBe(false);
    expect(capWin.rpc).toHaveLength(1); // 1 chamada ⇒ 1 evento (na fn)

    const capLose = makeCap({ rpcResults: [{ data: [], error: null }] }); // perdeu o lock
    const second = (await crmAssignConversation.handler(input, makeCtx(raceResolve, capLose))) as {
      idempotent: boolean;
      assigned_to_user_id: string | null;
    };
    expect(second.idempotent).toBe(true); // recheck: dono já = alvo ⇒ sem evento novo
    expect(second.assigned_to_user_id).toBe(USER_A);
  });

  it("idempotente: já está com o dono alvo ⇒ não duplica evento (sem rpc)", async () => {
    const cap = makeCap();
    const res = (await crmAssignConversation.handler(
      { conversation_id: CONV, to_user_id: USER_A, reason: "transfer" },
      makeCtx(convAssigned(USER_A), cap),
    )) as { idempotent: boolean };

    expect(res.idempotent).toBe(true);
    expect(cap.rpc).toEqual([]);
  });

  it("cross-org negado: fn RAISE (guard INB-06a) ⇒ erro", async () => {
    const cap = makeCap({ rpcResult: { data: null, error: { message: "destino não é membro" } } });
    await expect(
      crmAssignConversation.handler(
        { conversation_id: CONV, to_user_id: USER_B, reason: "transfer" },
        makeCtx(convAssigned(null), cap),
      ),
    ).rejects.toThrow(/membro/);
  });

  it("input inválido: to_user_id null com reason=transfer ⇒ ZodError", async () => {
    const cap = makeCap();
    await expect(
      crmAssignConversation.handler(
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        { conversation_id: CONV, to_user_id: null, reason: "transfer" } as any,
        makeCtx(convAssigned(null), cap),
      ),
    ).rejects.toThrow();
    expect(cap.rpc).toEqual([]);
  });

  it("conversa de outra org (não encontrada no filtro) ⇒ erro", async () => {
    const cap = makeCap();
    await expect(
      crmAssignConversation.handler(
        { conversation_id: CONV, to_user_id: USER_A, reason: "transfer" },
        makeCtx(() => ({ data: null, error: null }), cap),
      ),
    ).rejects.toThrow(/conversation_not_found/);
  });
});

// ---------------------------------------------------------------------------
// crm_manage_tags
// ---------------------------------------------------------------------------

describe("crm_manage_tags", () => {
  const withTags = (table: string, tags: string[] | null): Resolver => (q) =>
    q.terminal === "maybeSingle" && q.table === table
      ? { data: { id: CONV, tags }, error: null }
      : { data: null, error: null };

  it("add/remove normaliza (lowercase) e persiste; audit action por kind", async () => {
    const cap = makeCap();
    const res = (await crmManageTags.handler(
      { target_kind: "conversation", target_id: CONV, add: ["VIP"], remove: ["a"] },
      makeCtx(withTags("conversations", ["a", "b"]), cap),
    )) as { tags: string[] };

    expect(res.tags.sort()).toEqual(["b", "vip"]);
    expect(cap.updates).toContainEqual({ table: "conversations", values: { tags: ["b", "vip"] } });
  });

  it("contact: usa tabela contacts", async () => {
    const cap = makeCap();
    await crmManageTags.handler(
      { target_kind: "contact", target_id: CONV, add: ["novo"], remove: undefined },
      makeCtx(withTags("contacts", []), cap),
    );
    expect(cap.updates).toContainEqual({ table: "contacts", values: { tags: ["novo"] } });
  });

  // O que já estava gravado pode estar em caixa mista no banco (dado anterior à
  // #1224): sem normalizar o que está lá, `remove: ["vip"]` não alcança o "VIP" e
  // o marcador fica impossível de tirar pela MCP.
  it("contact: remove alcança o marcador gravado em caixa mista", async () => {
    const cap = makeCap();
    const res = (await crmManageTags.handler(
      { target_kind: "contact", target_id: CONV, add: undefined, remove: ["vip"] },
      makeCtx(withTags("contacts", ["VIP"]), cap),
    )) as { tags: string[] };

    expect(res.tags).toEqual([]);
    expect(cap.updates).toContainEqual({ table: "contacts", values: { tags: [] } });
  });

  it("tag > 40 chars rejeitada", async () => {
    const cap = makeCap();
    await expect(
      crmManageTags.handler(
        { target_kind: "conversation", target_id: CONV, add: ["x".repeat(41)], remove: undefined },
        makeCtx(withTags("conversations", []), cap),
      ),
    ).rejects.toThrow();
  });

  it("> 20 tags rejeitada", async () => {
    const cap = makeCap();
    const existing = Array.from({ length: 20 }, (_, i) => `t${i}`);
    await expect(
      crmManageTags.handler(
        { target_kind: "conversation", target_id: CONV, add: ["extra"], remove: undefined },
        makeCtx(withTags("conversations", existing), cap),
      ),
    ).rejects.toThrow();
  });

  it("alvo inexistente / cross-org ⇒ erro", async () => {
    const cap = makeCap();
    await expect(
      crmManageTags.handler(
        { target_kind: "lead", target_id: CONV, add: ["x"], remove: undefined },
        makeCtx(() => ({ data: null, error: null }), cap),
      ),
    ).rejects.toThrow(/target_not_found/);
  });

  it("sem add nem remove ⇒ erro", async () => {
    const cap = makeCap();
    await expect(
      crmManageTags.handler(
        { target_kind: "conversation", target_id: CONV, add: undefined, remove: undefined },
        makeCtx(withTags("conversations", []), cap),
      ),
    ).rejects.toThrow();
  });
});

// ---------------------------------------------------------------------------
// crm_manage_tags — o evento "ganhou tag", igual à tela
// ---------------------------------------------------------------------------

describe("crm_manage_tags emite tag_added como a tela", () => {
  const CONTACT = "55555555-5555-4555-8555-555555555555";
  const emitidos = (cap: Captures) =>
    cap.rpc.filter((c) => c.fn === "emit_event").map((c) => c.args);
  const linha = (table: string, row: Record<string, unknown>): Resolver => (q) =>
    q.terminal === "maybeSingle" && q.table === table
      ? { data: row, error: null }
      : { data: null, error: null };

  it("contact: tag nova emite contact.tag_added só com as novas, e a origem do atendimento", async () => {
    const cap = makeCap({ rpcResult: { data: { observado: true }, error: null } });
    await crmManageTags.handler(
      { target_kind: "contact", target_id: CONTACT, add: ["VCA", "antiga"], remove: undefined },
      makeCtx(linha("contacts", { id: CONTACT, tags: ["antiga"] }), cap),
    );

    const [evento, ...resto] = emitidos(cap);
    expect(resto).toEqual([]);
    expect(evento).toMatchObject({
      p_event_type: "contact.tag_added",
      p_entity_kind: "contact",
      p_entity_id: CONTACT,
      p_organization_id: ORG,
      p_payload: {
        added_tags: ["vca"],
        tags: ["antiga", "vca"],
        service_origin: { kind: "command", observed: { observado: true } },
      },
    });
    expect(cap.rpc).toContainEqual({
      fn: "fn_service_observe_command",
      args: { p_org: ORG, p_contact: CONTACT },
    });
  });

  it("lead: tag nova emite lead.tag_added com a origem do contato do card", async () => {
    const cap = makeCap({ rpcResult: { data: { observado: true }, error: null } });
    await crmManageTags.handler(
      { target_kind: "lead", target_id: CONV, add: ["vca"], remove: undefined },
      makeCtx(linha("crm_leads", { id: CONV, tags: [], contact_id: CONTACT }), cap),
    );

    expect(emitidos(cap)).toEqual([
      expect.objectContaining({
        p_event_type: "lead.tag_added",
        p_entity_kind: "crm_lead",
        p_entity_id: CONV,
        p_payload: expect.objectContaining({ added_tags: ["vca"], tags: ["vca"] }),
      }),
    ]);
    expect(cap.rpc).toContainEqual({
      fn: "fn_service_observe_command",
      args: { p_org: ORG, p_contact: CONTACT },
    });
  });

  it("tag que o contato já tinha não emite", async () => {
    const cap = makeCap();
    await crmManageTags.handler(
      { target_kind: "contact", target_id: CONTACT, add: ["vca"], remove: undefined },
      makeCtx(linha("contacts", { id: CONTACT, tags: ["vca"] }), cap),
    );
    expect(emitidos(cap)).toEqual([]);
  });

  it("só remover não emite", async () => {
    const cap = makeCap();
    await crmManageTags.handler(
      { target_kind: "contact", target_id: CONTACT, add: undefined, remove: ["vca"] },
      makeCtx(linha("contacts", { id: CONTACT, tags: ["vca"] }), cap),
    );
    expect(emitidos(cap)).toEqual([]);
  });

  it("conversa não tem evento de tag: não emite", async () => {
    const cap = makeCap();
    await crmManageTags.handler(
      { target_kind: "conversation", target_id: CONV, add: ["vca"], remove: undefined },
      makeCtx(linha("conversations", { id: CONV, tags: [] }), cap),
    );
    expect(emitidos(cap)).toEqual([]);
  });

  it("falha ao emitir não desfaz a tag gravada", async () => {
    const cap = makeCap({ rpcResult: { data: null, error: { message: "fora do ar" } } });
    const res = (await crmManageTags.handler(
      { target_kind: "contact", target_id: CONTACT, add: ["vca"], remove: undefined },
      makeCtx(linha("contacts", { id: CONTACT, tags: [] }), cap),
    )) as { tags: string[] };
    expect(res.tags).toEqual(["vca"]);
    expect(cap.updates).toContainEqual({ table: "contacts", values: { tags: ["vca"] } });
  });
});

// ---------------------------------------------------------------------------
// crm_get_queue_status
// ---------------------------------------------------------------------------

describe("crm_get_queue_status", () => {
  const now = new Date("2026-07-18T12:00:00.000Z");
  // Fila: 3 conversas esperando 10/20/30s ⇒ avg 20s. 2 atendentes elegíveis.
  const resolve: Resolver = (q) => {
    if (q.table === "conversations" && q.select === "awaiting_since") {
      return {
        data: [
          { awaiting_since: new Date(now.getTime() - 10_000).toISOString() },
          { awaiting_since: new Date(now.getTime() - 20_000).toISOString() },
          { awaiting_since: new Date(now.getTime() - 30_000).toISOString() },
        ],
        error: null,
      };
    }
    if (q.table === "user_organizations") {
      return { data: [{ user_id: USER_A }, { user_id: USER_B }], error: null };
    }
    if (["channel_sessions", "channel_routing_policies", "channel_routing_responsibles"].includes(q.table)) {
      throw new Error("organization_summary must not resolve an individual channel policy");
    }
    if (q.table === "attendant_availability") {
      return {
        data: [
          { user_id: USER_A, capacity: 5, schedule: {} },
          { user_id: USER_B, capacity: 5, schedule: {} },
        ],
        error: null,
      };
    }
    return { data: [], error: null };
  };

  it("números exatos: queue_size=3, avg_wait_seconds=20, online_eligible_count=2", async () => {
    const cap = makeCap();
    const res = await getQueueStatus(
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      makeSupabase(resolve, cap) as any,
      ORG,
      now,
    );
    expect(res).toEqual({ queue_size: 3, avg_wait_seconds: 20, online_eligible_count: 2 });
  });

  it("disponibilidade sem membership ativa não conta como elegível", async () => {
    const onlyA: Resolver = (q) => q.table === "user_organizations"
      ? { data: [{ user_id: USER_A }], error: null } : resolve(q);
    const res = await getQueueStatus(
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      makeSupabase(onlyA, makeCap()) as any, ORG, now,
    );
    expect(res).toEqual({ queue_size: 3, avg_wait_seconds: 20, online_eligible_count: 1 });
  });

  it("erro ao ler membership não é publicado como zero elegíveis", async () => {
    const failed: Resolver = (q) => q.table === "user_organizations"
      ? { data: null, error: { message: "membership unavailable" } } : resolve(q);
    await expect(getQueueStatus(
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      makeSupabase(failed, makeCap()) as any, ORG, now,
    )).rejects.toThrow("membership unavailable");
  });

  it("tool handler retorna o shape documentado", async () => {
    const cap = makeCap();
    const res = (await crmGetQueueStatus.handler({}, makeCtx(resolve, cap))) as Record<string, unknown>;
    expect(Object.keys(res).sort()).toEqual([
      "avg_wait_seconds",
      "online_eligible_count",
      "queue_size",
    ]);
  });
});
