import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { toast } from "sonner";

import { CRMSidePanel } from "@/components/inbox/CRMSidePanel";
import { ApiError } from "@/lib/api/types";

/**
 * A PORTA DE FOLLOW-UP no painel da conversa (#3178).
 *
 * O painel dizia quem é o contato, o que ele pediu (demanda), o que foi
 * combinado (tarefa) — e não dizia se um follow-up está rodando nele, nem dava
 * como armá-lo ou desarmá-lo sem sair da tela. O efeito era a ausência da seção
 * "Follow-up" no painel. Estes casos guardam:
 *
 *  1. **O enrollment vivo aparece** — o GET `?status=active` é da ORGANIZAÇÃO;
 *     a seção só pode mostrar o do CONTATO desta conversa. Um enrollment de
 *     outro contato na lista é o caso que separa "leu" de "filtrou".
 *  2. **Iniciar manda pointer_id e contact_id** — sem os dois o POST não inscreve
 *     ninguém; a rota cobra o par em `createFollowupEnrollmentSchema`.
 *  3. **Parar chama o cancel do enrollment mostrado** — não é um "desmarcar"
 *     local: a escrita vai para `POST .../:id/cancel`, que aceita os vivos e o
 *     `dormente` (o caso novo da #3375).
 *  4. **Auto-ocultação** — sem enrollment vivo e sem fluxo ativo a seção não
 *     existe; é o contrato de `RoteirosDoContato` (módulo/estado sem dado não
 *     muda a tela de quem não usa).
 *  5. **Readonly esconde a escrita, não a leitura** — o painel expõe `readonly`
 *     (`support_readonly`); quem não pode escrever ainda enxerga o que roda e
 *     não ganha botão que levaria 403.
 */

const CONTACT = "c0000000-0000-4000-8000-000000000001";

const conversation = {
  id: "cv-1",
  organization_id: "org-1",
  contact_id: CONTACT,
  tags: [],
  contacts: { id: CONTACT, display_name: "Fulana", name: null, phone_number: "5511999", tags: [] },
} as unknown as React.ComponentProps<typeof CRMSidePanel>["conversation"];

const RESPOSTA = { leads: [], orders: [], activities: [], demandas: [] };

const FLUXO = {
  id: "f0000000-0000-4000-8000-0000000000aa",
  name: "Recuperação de carrinho",
  status: "active",
  active_version_id: "v-1",
  handoff_policy: "pause",
  updated_at: "2026-08-01T00:00:00Z",
};

const ENROLLMENT = {
  id: "e0000000-0000-4000-8000-0000000000bb",
  pointer_id: FLUXO.id,
  version_id: "v-1",
  contact_id: CONTACT,
  status: "active",
  current_node_id: "n-1",
  next_eval_at: null,
  outcome: null,
  started_at: "2026-08-01T00:00:00Z",
  completed_at: null,
  updated_at: "2026-08-01T00:00:00Z",
};

/** Mutable: cada caso decide se a sessão é de leitura ou de escrita. */
let authUser: Record<string, unknown> = { support: null };
/** Mutable: o papel na organização (iniciar/parar são manager+ no servidor). */
let authRole = "manager";

const get = vi.fn();
const post = vi.fn();
const patch = vi.fn();
vi.mock("@/lib/api/client", () => ({
  apiClient: {
    get: (...args: unknown[]) => get(...args),
    post: (...args: unknown[]) => post(...args),
    patch: (...args: unknown[]) => patch(...args),
  },
}));
vi.mock("@/hooks/pipelines/useDefaultPipeline", () => ({
  useDefaultPipeline: () => ({ data: null, isError: false }),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
// O bloco Carteira (#3026) busca a própria rota; o dublê de `get` deste arquivo
// devolve o resumo do painel para TODA URL fora do follow-up, então o cartão sai da cena aqui.
vi.mock("@/components/contacts/CartaoDaCarteira", () => ({ CartaoDaCarteira: () => null }));
vi.mock("@/hooks/inbox/useConversationTags", () => ({
  useUpdateConversationTags: () => ({ mutate: vi.fn(), isPending: false }),
  useConversationTagVocabulary: () => ({ data: [] }),
}));
vi.mock("@/hooks/contacts/useContactTagVocabulary", () => ({
  useContactTagVocabulary: () => ({ data: [] }),
}));
vi.mock("@/hooks/contacts/useUpdateContact", () => ({
  useUpdateContact: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/auth/AuthProvider", () => ({
  useAuth: () => ({ user: authUser, activeOrg: { role: authRole } }),
}));

function renderPainel() {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={client}>
      <CRMSidePanel conversation={conversation} />
    </QueryClientProvider>,
  );
}

/**
 * O GET do follow-up é por URL; o crm-summary do painel continua sendo o default.
 *
 * `podeInscrever` é o `meta.pode_inscrever` da resposta (#3746): a capacidade que
 * o servidor afirma para ESTE contato. `true` por padrão porque a maioria dos
 * casos aqui mede outras coisas; quem mede a capacidade passa o valor.
 */
function servirApi(opts: {
  enrollments?: unknown[];
  flows?: unknown[];
  podeInscrever?: boolean;
}) {
  get.mockImplementation((url: string) => {
    if (url.includes("/followups/enrollments")) {
      return Promise.resolve({
        data: opts.enrollments ?? [],
        meta: { pode_inscrever: opts.podeInscrever ?? true },
      });
    }
    if (url.includes("/followup-flows")) return Promise.resolve({ data: opts.flows ?? [] });
    return Promise.resolve({ data: RESPOSTA });
  });
}

beforeEach(() => {
  authUser = { support: null };
  authRole = "manager";
  get.mockReset();
  post.mockReset();
  patch.mockReset();
  post.mockResolvedValue({ data: { id: "e-novo" } });
  // O aviso é por chamada: sem limpar, o caso da frase de canal veria o toast
  // que o caso anterior disparou.
  vi.mocked(toast.error).mockClear();
  vi.mocked(toast.success).mockClear();
});

describe("painel do inbox — seção de follow-up", () => {
  it("com fluxo ativo, oferece iniciar: manda pointer_id e contact_id", async () => {
    servirApi({ flows: [FLUXO] });
    renderPainel();

    const secao = await screen.findByTestId("inbox-followup");
    expect(secao.textContent).toMatch(/follow-up/i);

    await userEvent.selectOptions(screen.getByLabelText("Fluxo de follow-up"), FLUXO.id);
    await userEvent.click(screen.getByTestId("iniciar-followup"));

    await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
    // A CONVERSA vai no payload (#3746): é o escopo de posse que o servidor
    // confere — o Atendente inscreve o cliente cuja conversa está com ele.
    expect(post).toHaveBeenCalledWith("/api/v1/ai/followups/enrollments", {
      pointer_id: FLUXO.id,
      contact_id: CONTACT,
      conversation_id: "cv-1",
    });
  });

  it("o enrollment vivo do CONTATO aparece e o parar chama o cancel", async () => {
    servirApi({ enrollments: [ENROLLMENT] });
    renderPainel();

    const secao = await screen.findByTestId("inbox-followup");
    // O status é dito por escrito, não só por cor.
    expect(secao.textContent).toMatch(/ativo/i);

    await userEvent.click(screen.getByTestId("parar-followup"));
    await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
    expect(post).toHaveBeenCalledWith(`/api/v1/ai/followups/enrollments/${ENROLLMENT.id}/cancel`, {});
  });

  it("enrollment de OUTRO contato não é o desta conversa — filtro pelo contato", async () => {
    servirApi({ enrollments: [{ ...ENROLLMENT, contact_id: "outro-contato" }] });
    renderPainel();

    // Sem vivo DESTE contato e sem fluxo ativo, a seção se auto-oculta.
    await waitFor(() => expect(get).toHaveBeenCalled());
    expect(screen.queryByTestId("inbox-followup")).toBeNull();
  });

  it("sem enrollment vivo e sem fluxo ativo, a seção não existe", async () => {
    servirApi({ enrollments: [], flows: [] });
    renderPainel();

    await waitFor(() => expect(get).toHaveBeenCalled());
    expect(screen.queryByTestId("inbox-followup")).toBeNull();
  });

  it("fluxo inativo não oferta iniciar — nada a fazer", async () => {
    servirApi({ flows: [{ ...FLUXO, status: "draft", active_version_id: null }] });
    renderPainel();

    await waitFor(() => expect(get).toHaveBeenCalled());
    expect(screen.queryByTestId("inbox-followup")).toBeNull();
  });

  it("em readonly a LEITURA fica e a ESCRITA some", async () => {
    authUser = { support: { access_mode: "support_readonly" } };
    servirApi({ enrollments: [ENROLLMENT] });
    renderPainel();

    const secao = await screen.findByTestId("inbox-followup");
    expect(secao.textContent).toMatch(/ativo/i);
    expect(screen.queryByTestId("parar-followup")).toBeNull();
  });

  it("em readonly, sem enrollment vivo, não sobra porta de escrita vazia", async () => {
    authUser = { support: { access_mode: "support_readonly" } };
    servirApi({ flows: [FLUXO] });
    renderPainel();

    await waitFor(() => expect(get).toHaveBeenCalled());
    expect(screen.queryByTestId("inbox-followup")).toBeNull();
  });

  it("a leitura vai pelo contato no servidor (?contact_id=), sem recortar por status", async () => {
    servirApi({ enrollments: [ENROLLMENT] });
    renderPainel();

    await screen.findByTestId("inbox-followup");
    const urls = get.mock.calls.map((c) => String(c[0])).filter((u) => u.includes("/followups/enrollments"));
    expect(urls).toEqual([`/api/v1/ai/followups/enrollments?contact_id=${CONTACT}`]);
  });

  it.each(["waiting_reply", "paused_manual"])(
    "enrollment em %s ocupa a vaga: mostra o fluxo e o Parar, sem oferecer Iniciar",
    async (status) => {
      servirApi({ enrollments: [{ ...ENROLLMENT, status }], flows: [FLUXO] });
      renderPainel();

      const secao = await screen.findByTestId("inbox-followup");
      expect(secao.textContent).toContain(FLUXO.name);
      expect(screen.getByTestId("parar-followup")).toBeTruthy();
      expect(screen.queryByTestId("escolher-fluxo-followup")).toBeNull();
      expect(screen.queryByTestId("iniciar-followup")).toBeNull();
    },
  );

  it("dormente aparece com o status e com o Parar (o cancel o aceita desde #3292), e Iniciar segue oferecido", async () => {
    servirApi({ enrollments: [{ ...ENROLLMENT, status: "dormente" }], flows: [FLUXO] });
    renderPainel();

    const secao = await screen.findByTestId("inbox-followup");
    expect(secao.textContent).toContain(FLUXO.name);
    expect(secao.textContent).toMatch(/aguardando a data do retorno/i);
    // O dormente não ocupa a vaga, mas é cancelável: as DUAS portas ficam de pé.
    expect(screen.getByTestId("parar-followup")).toBeTruthy();
    expect(screen.getByTestId("iniciar-followup")).toBeTruthy();
  });

  it("dormente: o Parar manda o cancel, o enrollment sai de cena e a seção volta ao select (#3375)", async () => {
    // O cancel grava `cancelled` no servidor; o refetch da seção por isso não
    // devolve mais a inscrição e só a porta de iniciar sobra.
    let encerrado = false;
    servirApi({ enrollments: [{ ...ENROLLMENT, status: "dormente" }], flows: [FLUXO] });
    get.mockImplementation((url: string) => {
      if (url.includes("/followups/enrollments"))
        return Promise.resolve({
          data: encerrado ? [] : [{ ...ENROLLMENT, status: "dormente" }],
          // A capacidade (#3746) é do servidor e não muda no cancel: o que muda
          // é a vaga, que fica livre para outro fluxo.
          meta: { pode_inscrever: true },
        });
      if (url.includes("/followup-flows")) return Promise.resolve({ data: [FLUXO] });
      return Promise.resolve({ data: RESPOSTA });
    });
    post.mockImplementation((url: string) => {
      if (String(url).endsWith("/cancel")) {
        encerrado = true;
        return Promise.resolve({ data: { status: "cancelled" } });
      }
      return Promise.resolve({ data: { id: "e-novo" } });
    });

    renderPainel();
    const secao = await screen.findByTestId("inbox-followup");
    expect(secao.textContent).toMatch(/aguardando a data do retorno/i);

    await userEvent.click(screen.getByTestId("parar-followup"));

    // A escrita vai para a rota que grava `cancelled` (cancel_reason manual).
    await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
    expect(post).toHaveBeenCalledWith(`/api/v1/ai/followups/enrollments/${ENROLLMENT.id}/cancel`, {});

    // Sem a inscrição, a seção volta ao select — e o Parar some com ela.
    await waitFor(() => expect(screen.queryByTestId("parar-followup")).toBeNull());
    expect(screen.getByTestId("escolher-fluxo-followup")).toBeTruthy();
    expect(screen.getByTestId("iniciar-followup")).toBeTruthy();
  });

  it("papel agent vê o fluxo vivo: Parar continua manager+ (fora do recorte do #3746)", async () => {
    authRole = "agent";
    servirApi({ enrollments: [ENROLLMENT], flows: [FLUXO] });
    renderPainel();

    const secao = await screen.findByTestId("inbox-followup");
    expect(secao.textContent).toContain(FLUXO.name);
    expect(screen.queryByTestId("parar-followup")).toBeNull();
    // A vaga está ocupada: Iniciar não é oferecido a ninguém, com ou sem posse.
    expect(screen.queryByTestId("iniciar-followup")).toBeNull();
  });

  it("#3746 — o Atendente DONO do cliente vê Iniciar (a capacidade vem do servidor)", async () => {
    authRole = "agent";
    servirApi({ flows: [FLUXO], podeInscrever: true });
    renderPainel();

    await screen.findByTestId("inbox-followup");
    expect(screen.getByTestId("iniciar-followup")).toBeTruthy();
  });

  it("#3746 — o Atendente de FORA do cliente não vê Iniciar (o servidor diria 403)", async () => {
    authRole = "agent";
    servirApi({ flows: [FLUXO], podeInscrever: false });
    renderPainel();

    // Sem enrollment vivo e sem a capacidade, não sobra nada para mostrar.
    await waitFor(() => expect(get).toHaveBeenCalled());
    expect(screen.queryByTestId("inbox-followup")).toBeNull();
    expect(screen.queryByTestId("iniciar-followup")).toBeNull();
  });

  it("#3923 — sem número conectado, a recusa vira a frase de CONECTAR NÚMERO, não a genérica", async () => {
    servirApi({ flows: [FLUXO] });
    // O que o servidor devolve depois da correção: 409 com o código estável.
    post.mockRejectedValue(
      new ApiError(
        409,
        "service_channel_not_found",
        undefined,
        "req-3923",
        "Conecte um número de WhatsApp antes de iniciar o follow-up.",
      ),
    );
    renderPainel();

    await screen.findByTestId("inbox-followup");
    await userEvent.selectOptions(screen.getByLabelText("Fluxo de follow-up"), FLUXO.id);
    await userEvent.click(screen.getByTestId("iniciar-followup"));

    // A frase diz o que FAZER — é o pedido da issue ("uma frase que o leigo
    // entenda"). O "não foi possível" continua reservado ao resto.
    await waitFor(() =>
      expect(toast.error).toHaveBeenCalledWith("Conecte um número de WhatsApp antes de iniciar o follow-up."),
    );
    expect(toast.error).not.toHaveBeenCalledWith("Não foi possível iniciar o follow-up.");
  });

  it("CONTROLE: falha de outro tipo continua na frase genérica", async () => {
    servirApi({ flows: [FLUXO] });
    post.mockRejectedValue(new ApiError(500, "internal_error", undefined, "req-2", "boom"));
    renderPainel();

    await screen.findByTestId("inbox-followup");
    await userEvent.selectOptions(screen.getByLabelText("Fluxo de follow-up"), FLUXO.id);
    await userEvent.click(screen.getByTestId("iniciar-followup"));

    await waitFor(() =>
      expect(toast.error).toHaveBeenCalledWith("Não foi possível iniciar o follow-up."),
    );
  });
});
