import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { ImpersonationBanner } from "../portal/ImpersonationBanner.js";
import { AuditLogViewer } from "../portal/AuditLogViewer.js";
import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types";

// Spy on the RescheduleFlow so we can assert the sessionId prop it receives
// from CustomerPortal without rendering the full flow UI. The real module is
// still loaded via importActual; only RescheduleFlow is swapped.
const rescheduleFlowSpy = vi.hoisted(() =>
  vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null)
);
vi.mock("../portal/sections/Appointments.js", async () => {
  const actual = await vi.importActual<typeof import("../portal/sections/Appointments.js")>(
    "../portal/sections/Appointments.js"
  );
  return {
    ...actual,
    RescheduleFlow: rescheduleFlowSpy,
  };
});

const SESSION: ImpersonationSession = {
  id: "sess-1",
  staffId: "staff-1",
  clientId: "client-1",
  reason: "Customer reported missing appointment",
  status: "active",
  startedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
  endedAt: null,
  expiresAt: new Date(Date.now() + 25 * 60_000).toISOString(),
  createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
};

const AUDIT_LOGS: ImpersonationAuditLog[] = [
  {
    id: "log-1",
    sessionId: "sess-1",
    action: "session_started",
    pageVisited: null,
    metadata: { reason: "Customer reported missing appointment" },
    createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
  },
  {
    id: "log-2",
    sessionId: "sess-1",
    action: "page_view",
    pageVisited: "appointments",
    metadata: null,
    createdAt: new Date(Date.now() - 3 * 60_000).toISOString(),
  },
];

// ─── ImpersonationBanner ────────────────────────────────────────────────────

describe("ImpersonationBanner", () => {
  it("renders STAFF VIEW label", () => {
    render(
      <ImpersonationBanner
        session={SESSION}
        isExtended={false}
        onEnd={vi.fn()}
        onExtend={vi.fn()}
        onShowAudit={vi.fn()}
      />
    );
    expect(screen.getByText("STAFF VIEW")).toBeInTheDocument();
  });

  it("displays the session reason", () => {
    render(
      <ImpersonationBanner
        session={SESSION}
        isExtended={false}
        onEnd={vi.fn()}
        onExtend={vi.fn()}
        onShowAudit={vi.fn()}
      />
    );
    expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument();
  });

  it("calls onEnd when End Session is clicked", () => {
    const onEnd = vi.fn();
    render(
      <ImpersonationBanner
        session={SESSION}
        isExtended={false}
        onEnd={onEnd}
        onExtend={vi.fn()}
        onShowAudit={vi.fn()}
      />
    );
    fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
    expect(onEnd).toHaveBeenCalledOnce();
  });

  it("calls onShowAudit when Audit is clicked", () => {
    const onShowAudit = vi.fn();
    render(
      <ImpersonationBanner
        session={SESSION}
        isExtended={false}
        onEnd={vi.fn()}
        onExtend={vi.fn()}
        onShowAudit={onShowAudit}
      />
    );
    fireEvent.click(screen.getByRole("button", { name: /Audit/i }));
    expect(onShowAudit).toHaveBeenCalledOnce();
  });

  it("shows Extend button when less than 5 minutes remain and not yet extended", async () => {
    const nearlyExpiredSession: ImpersonationSession = {
      ...SESSION,
      expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), // 3 min left
    };
    render(
      <ImpersonationBanner
        session={nearlyExpiredSession}
        isExtended={false}
        onEnd={vi.fn()}
        onExtend={vi.fn()}
        onShowAudit={vi.fn()}
      />
    );
    await waitFor(() => {
      expect(screen.getByRole("button", { name: /Extend/i })).toBeInTheDocument();
    });
  });

  it("does not show Extend button when already extended", async () => {
    const nearlyExpiredSession: ImpersonationSession = {
      ...SESSION,
      expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(),
    };
    render(
      <ImpersonationBanner
        session={nearlyExpiredSession}
        isExtended={true}
        onEnd={vi.fn()}
        onExtend={vi.fn()}
        onShowAudit={vi.fn()}
      />
    );
    await waitFor(() => {
      expect(screen.queryByRole("button", { name: /Extend/i })).not.toBeInTheDocument();
    });
  });
});

// ─── AuditLogViewer ─────────────────────────────────────────────────────────

describe("AuditLogViewer", () => {
  beforeEach(() => {
    global.fetch = vi.fn();
  });

  it("fetches and displays audit log entries", async () => {
    vi.mocked(global.fetch).mockResolvedValue({
      ok: true,
      json: async () => [...AUDIT_LOGS].reverse(), // API returns newest-first
    } as Response);

    render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);

    await waitFor(() => {
      // "session started" appears in both the filter dropdown option and the log entry span
      expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
    });
    expect(screen.getByText("appointments")).toBeInTheDocument();
    expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1/audit-log");
  });

  it("shows error state when fetch fails", async () => {
    vi.mocked(global.fetch).mockResolvedValue({
      ok: false,
      status: 403,
    } as Response);

    render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);

    await waitFor(() => {
      expect(screen.getByText(/Failed to load audit log/i)).toBeInTheDocument();
    });
  });

  it("shows loading state initially", () => {
    vi.mocked(global.fetch).mockReturnValue(new Promise(() => {}));

    render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);

    expect(screen.getByText(/Loading audit log/i)).toBeInTheDocument();
  });

  it("calls onClose when X button is clicked", async () => {
    vi.mocked(global.fetch).mockResolvedValue({
      ok: true,
      json: async () => [],
    } as Response);

    const onClose = vi.fn();
    render(<AuditLogViewer sessionId="sess-1" onClose={onClose} />);

    await waitFor(() => {
      expect(screen.getByText(/No audit entries/i)).toBeInTheDocument();
    });

    fireEvent.click(screen.getByRole("button", { name: "" }));
    expect(onClose).toHaveBeenCalledOnce();
  });

  it("filters entries by action type", async () => {
    vi.mocked(global.fetch).mockResolvedValue({
      ok: true,
      json: async () => [...AUDIT_LOGS].reverse(),
    } as Response);

    render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);

    await waitFor(() => {
      expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
    });

    // Filter to page_view only
    const select = screen.getByRole("combobox");
    fireEvent.change(select, { target: { value: "page_view" } });

    expect(screen.getByText("appointments")).toBeInTheDocument();
    // After filtering, the "session started" span (log entry) should be gone
    // The option in the select still has the text but the log entry span does not
    const spans = document.querySelectorAll("span.inline-block");
    expect(Array.from(spans).every((s) => s.textContent !== "session started")).toBe(true);
  });
});

// ─── CustomerPortal — session loading ──────────────────────────────────────

describe("CustomerPortal session loading", () => {
  beforeEach(() => {
    global.fetch = vi.fn((url: string) => {
      if (url === "/api/branding") {
        return Promise.resolve({
          ok: true,
          json: async () => ({
            businessName: "GroomBook",
            primaryColor: "#4f8a6f",
            accentColor: "#8b7355",
            logoBase64: null,
            logoMimeType: null,
          }),
        } as Response);
      }
      if (url.startsWith("/api/impersonation/sessions/")) {
        return Promise.resolve({
          ok: true,
          json: async () => SESSION,
        } as Response);
      }
      return Promise.resolve({ ok: true, json: async () => [] } as Response);
    }) as unknown as typeof fetch;
  });

  it("loads and displays impersonation banner when sessionId is in URL", async () => {
    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    // Wait for the session fetch and banner to appear
    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1");
    });
    // Banner "End Session" button is unique to the active impersonation banner
    await waitFor(() => {
      expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
    });
  });

  it("does not show banner when no sessionId in URL", async () => {
    vi.mocked(global.fetch).mockClear();
    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    // No impersonation session fetch should happen
    await new Promise((r) => setTimeout(r, 50));
    const impersonationFetches = vi.mocked(global.fetch).mock.calls.filter(
      ([url]) => typeof url === "string" && url.startsWith("/api/impersonation/")
    );
    expect(impersonationFetches).toHaveLength(0);
    expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
  });

  it("redirects to /admin/clients after ending impersonation session", async () => {
    // Mock window.location.href
    const originalLocation = window.location;
    Object.defineProperty(window, "location", {
      value: { href: "" },
      writable: true,
    });

    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    // Wait for banner to appear
    await waitFor(() => {
      expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
    });

    // Click "End Session" — this triggers handleEnd which calls the API then redirects
    fireEvent.click(screen.getByRole("button", { name: /End Session/i }));

    await waitFor(() => {
      expect(window.location.href).toBe("/admin/clients");
    });

    // Restore
    Object.defineProperty(window, "location", { value: originalLocation, writable: true });
  });
});

// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────

describe("CustomerPortal SSO bridge", () => {
  beforeEach(() => {
    // Make sure no dev-user leaks across tests
    window.localStorage.clear();
  });

  const brandingResponse = {
    ok: true,
    json: async () => ({
      businessName: "GroomBook",
      primaryColor: "#4f8a6f",
      accentColor: "#8b7355",
      logoBase64: null,
      logoMimeType: null,
    }),
  } as Response;

  it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
    global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
      const url = typeof input === "string" ? input : input.toString();
      if (url === "/api/branding") return Promise.resolve(brandingResponse);
      if (url === "/api/auth/get-session") {
        return Promise.resolve({
          ok: true,
          json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
        } as Response);
      }
      if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
        return Promise.resolve({
          ok: true,
          status: 201,
          json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
        } as Response);
      }
      // Subsequent portal API calls — surface them so we can assert the header
      return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
    }) as unknown as typeof fetch;

    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
    });
    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith(
        "/api/portal/session-from-auth",
        expect.objectContaining({ method: "POST", credentials: "include" })
      );
    });
    // Client greeting reflects the bridged customer name (proof the response was consumed)
    await waitFor(() => {
      expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
    });
    // The impersonation banner must NOT appear — this is the customer themselves
    expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
  });

  it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
    global.fetch = vi.fn((input: RequestInfo) => {
      const url = typeof input === "string" ? input : input.toString();
      if (url === "/api/branding") return Promise.resolve(brandingResponse);
      if (url === "/api/auth/get-session") {
        return Promise.resolve({
          ok: true,
          json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
        } as Response);
      }
      if (url === "/api/portal/session-from-auth") {
        return Promise.resolve({
          ok: false,
          status: 404,
          json: async () => ({ error: "No client record found for this user" }),
        } as Response);
      }
      return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
    }) as unknown as typeof fetch;

    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    await waitFor(() => {
      expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
    });
    expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
    // Sign-out escape hatch is present so the user is not stuck in a loop
    expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
  });

  it("does not call session-from-auth when there is no Better Auth session", async () => {
    global.fetch = vi.fn((input: RequestInfo) => {
      const url = typeof input === "string" ? input : input.toString();
      if (url === "/api/branding") return Promise.resolve(brandingResponse);
      if (url === "/api/auth/get-session") {
        return Promise.resolve({
          ok: true,
          json: async () => null,
        } as Response);
      }
      return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
    }) as unknown as typeof fetch;

    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
    });
    // Wait one tick to ensure no subsequent bridge call is queued
    await new Promise((r) => setTimeout(r, 30));
    const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
      ([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
    );
    expect(bridgeCalls).toHaveLength(0);
  });

  it("skips the bridge for staff Better Auth sessions", async () => {
    global.fetch = vi.fn((input: RequestInfo) => {
      const url = typeof input === "string" ? input : input.toString();
      if (url === "/api/branding") return Promise.resolve(brandingResponse);
      if (url === "/api/auth/get-session") {
        return Promise.resolve({
          ok: true,
          json: async () => ({ user: { email: "staff@example.com", role: "staff" } }),
        } as Response);
      }
      return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
    }) as unknown as typeof fetch;

    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
    });
    await new Promise((r) => setTimeout(r, 30));
    const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
      ([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
    );
    expect(bridgeCalls).toHaveLength(0);
  });

  it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => {
    rescheduleFlowSpy.mockClear();

    global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
      const url = typeof input === "string" ? input : input.toString();
      if (url === "/api/branding") return Promise.resolve(brandingResponse);
      if (url === "/api/auth/get-session") {
        return Promise.resolve({
          ok: true,
          json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
        } as Response);
      }
      if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
        return Promise.resolve({
          ok: true,
          status: 201,
          json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
        } as Response);
      }
      // Dashboard data — return an upcoming appointment so the Reschedule
      // button is rendered on the dashboard card.
      if (url === "/api/portal/appointments") {
        return Promise.resolve({
          ok: true,
          json: async () => ({
            appointments: [
              {
                id: "appt-1",
                date: "2099-01-01",
                time: "10:00",
                petName: "Buddy",
                serviceName: "Bath & Brush",
                status: "confirmed",
              },
            ],
          }),
        } as Response);
      }
      if (url === "/api/portal/pets") {
        return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response);
      }
      if (url === "/api/portal/invoices") {
        return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response);
      }
      return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
    }) as unknown as typeof fetch;

    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    // Wait for the Reschedule button to appear on the dashboard card
    const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i });
    fireEvent.click(rescheduleBtn);

    // RescheduleFlow should have been invoked with the bridged portalSessionId,
    // NOT null. Pre-fix, the call would be sessionId={null} for SSO customers.
    await waitFor(() => {
      expect(rescheduleFlowSpy).toHaveBeenCalled();
    });
    const lastProps = rescheduleFlowSpy.mock.lastCall?.[0];
    expect(lastProps).toBeDefined();
    expect(lastProps!.sessionId).toBe("sso-sess-1");
    expect(lastProps!.appointment.id).toBe("appt-1");
  });

  // GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard)
  // must NOT render before the SSO bridge resolves. A loading state must be
  // shown instead. Previously, the Dashboard's redirect-to-/login guard fired
  // mid-bootstrap, leaving the user with a blank page after sign-in.
  it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => {
    // Slow bridge: resolve get-session and session-from-auth after a tick so
    // we can observe the loading state mid-bootstrap.
    let resolveBridge!: (value: Response) => void;
    const bridgePromise = new Promise<Response>((resolve) => {
      resolveBridge = resolve;
    });

    global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
      const url = typeof input === "string" ? input : input.toString();
      if (url === "/api/branding") return Promise.resolve(brandingResponse);
      if (url === "/api/auth/get-session") {
        return Promise.resolve({
          ok: true,
          json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
        } as Response);
      }
      if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
        return bridgePromise;
      }
      return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
    }) as unknown as typeof fetch;

    const { CustomerPortal } = await import("../portal/CustomerPortal.js");
    render(
      <MemoryRouter initialEntries={["/"]}>
        <CustomerPortal />
      </MemoryRouter>
    );

    // Loading state is visible while the bridge is in flight. The portal nav
    // (Home / Appointments / etc.) must NOT be present — its presence would
    // indicate the chrome is rendering with a null session, which is the
    // pre-GRO-2099 bug.
    expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i);
    expect(screen.queryByText("Home")).not.toBeInTheDocument();
    expect(screen.queryByText("Appointments")).not.toBeInTheDocument();

    // Resolve the bridge and confirm the portal renders normally.
    resolveBridge({
      ok: true,
      status: 201,
      json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
    } as Response);

    await waitFor(() => {
      expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
    });
  });
});
