import { beforeEach, describe, expect, it, vi } from "vitest";

// ---------------------------------------------------------------------------
// Hoisted mocks (must be declared before any imports that need them)
// ---------------------------------------------------------------------------

const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
  () => ({
    fetchMock: vi.fn(),
    getAuthHeadersMock: vi.fn(),
    handleApiResponseMock: vi.fn(),
  }),
);

// Real helpers/constants pulled from submodules that don't import server-only
// code, so the mock factory stays free of top-level variable hoisting issues
// and the vitest runtime doesn't choke on next-auth's `next/server` import.
import {
  includesMutedFindings,
  splitCsvFilterValues,
} from "@/lib/findings-filters";
import {
  composeSort,
  FG_FAIL_FIRST,
  FG_RECENT_LAST_SEEN,
  FG_SEVERITY_HIGH_FIRST,
  FINDING_GROUP_RESOURCES_DEFAULT_SORT,
} from "@/lib/findings-sort";

vi.mock("@/lib", () => ({
  apiBaseUrl: "https://api.example.com/api/v1",
  getAuthHeaders: getAuthHeadersMock,
  composeSort,
  FG_FAIL_FIRST,
  FG_RECENT_LAST_SEEN,
  FG_SEVERITY_HIGH_FIRST,
  FINDING_GROUP_RESOURCES_DEFAULT_SORT,
  includesMutedFindings,
  splitCsvFilterValues,
}));

vi.mock("@/lib/provider-filters", () => ({
  // Simulate real appendSanitizedProviderFilters: appends all non-undefined filters to the URL.
  appendSanitizedProviderFilters: vi.fn(
    (url: URL, filters: Record<string, string | string[] | undefined>) => {
      Object.entries(filters).forEach(([key, value]) => {
        if (value !== undefined) {
          url.searchParams.append(key, String(value));
        }
      });
    },
  ),
}));

vi.mock("@/lib/server-actions-helper", () => ({
  handleApiResponse: handleApiResponseMock,
}));

vi.mock("next/navigation", () => ({
  redirect: vi.fn(),
}));

// ---------------------------------------------------------------------------
// Imports (after vi.mock declarations)
// ---------------------------------------------------------------------------

import {
  getFindingGroupResources,
  getFindingGroups,
  getLatestFindingGroupResources,
  getLatestFindingGroups,
} from "./finding-groups";

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("getFindingGroups — default sort for muted and non-muted rows", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should prefer non-muted fail counters when include muted is not active", async () => {
    // When
    await getFindingGroups();

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at",
    );
  });

  it("should include muted counters when filter[muted]=include is active", async () => {
    // When
    await getFindingGroups({
      filters: { "filter[muted]": "include" },
    });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
    );
  });
});

describe("getLatestFindingGroups — default sort for muted and non-muted rows", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should prefer non-muted fail counters when include muted is not active", async () => {
    // When
    await getLatestFindingGroups();

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-new_fail_count,-changed_fail_count,-fail_count,-last_seen_at",
    );
  });

  it("should include muted counters when filter[muted]=include is active", async () => {
    // When
    await getLatestFindingGroups({
      filters: { "filter[muted]": "include" },
    });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-new_fail_count,-changed_fail_count,-new_fail_muted_count,-changed_fail_muted_count,-fail_count,-fail_muted_count,-last_seen_at",
    );
  });
});

describe("getFindingGroupResources — SSRF path traversal protection", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should encode a normal checkId without alteration", async () => {
    // Given
    const checkId = "s3_bucket_public_access";

    // When
    await getFindingGroupResources({ checkId });

    // Then — URL path must contain encoded checkId, not raw
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    expect(calledUrl).toContain(
      `/api/v1/finding-groups/${encodeURIComponent(checkId)}/resources`,
    );
  });

  it("should encode a checkId containing a forward slash (path traversal attempt)", async () => {
    // Given — checkId with embedded slash: attacker attempts path traversal
    const maliciousCheckId = "../../admin/secret";

    // When
    await getFindingGroupResources({ checkId: maliciousCheckId });

    // Then — the URL must NOT contain a raw slash from the checkId
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    // The path should NOT end in /resources with traversal segments between
    expect(url.pathname).not.toContain("/admin/secret/resources");
    // The encoded checkId must appear in the path
    expect(url.pathname).toContain(
      `/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`,
    );
  });

  it("should encode a checkId containing %2F (URL-encoded slash traversal attempt)", async () => {
    // Given — checkId with %2F: double-encoding traversal attempt
    const maliciousCheckId = "foo%2Fbar";

    // When
    await getFindingGroupResources({ checkId: maliciousCheckId });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.pathname).toContain(
      `/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`,
    );
    expect(url.pathname).not.toContain("/foo/bar/resources");
  });

  it("should encode a checkId containing special chars like ? and #", async () => {
    // Given
    const maliciousCheckId = "check?admin=true#fragment";

    // When
    await getFindingGroupResources({ checkId: maliciousCheckId });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    expect(calledUrl).not.toContain("?admin=true");
    expect(calledUrl.split("?")[0]).toContain(
      `/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`,
    );
  });
});

describe("getLatestFindingGroupResources — SSRF path traversal protection", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should encode a normal checkId without alteration", async () => {
    // Given
    const checkId = "iam_user_mfa_enabled";

    // When
    await getLatestFindingGroupResources({ checkId });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    expect(calledUrl).toContain(
      `/api/v1/finding-groups/latest/${encodeURIComponent(checkId)}/resources`,
    );
  });

  it("should encode a checkId containing a forward slash in the latest endpoint", async () => {
    // Given
    const maliciousCheckId = "../other-endpoint";

    // When
    await getLatestFindingGroupResources({ checkId: maliciousCheckId });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.pathname).not.toContain("/other-endpoint/resources");
    expect(url.pathname).toContain(
      `/finding-groups/latest/${encodeURIComponent(maliciousCheckId)}/resources`,
    );
  });
});

// ---------------------------------------------------------------------------
// Resources list keeps FAIL-first sort but no longer forces FAIL-only filtering
// ---------------------------------------------------------------------------

describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should include the composite sort so FAIL resources appear first, then severity", async () => {
    // Given
    const checkId = "s3_bucket_public_access";

    // When
    await getFindingGroupResources({ checkId });

    // Then — the URL must contain the composite sort
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-delta,-last_seen_at",
    );
  });

  it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
    // Given
    const checkId = "s3_bucket_public_access";

    // When
    await getFindingGroupResources({ checkId });

    // Then — the URL should not add a hardcoded status filter
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("filter[status]")).toBeNull();
  });
});

describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should include the composite sort so FAIL resources appear first, then severity", async () => {
    // Given
    const checkId = "iam_user_mfa_enabled";

    // When
    await getLatestFindingGroupResources({ checkId });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-delta,-last_seen_at",
    );
  });

  it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
    // Given
    const checkId = "iam_user_mfa_enabled";

    // When
    await getLatestFindingGroupResources({ checkId });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("filter[status]")).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// Triangulation: sort + filter coexist with pagination and caller filters
// ---------------------------------------------------------------------------

describe("getFindingGroupResources — triangulation: params coexist", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should send the composite sort alongside pagination params without forcing filter[status]", async () => {
    // Given
    const checkId = "s3_bucket_versioning";

    // When
    await getFindingGroupResources({ checkId, page: 2, pageSize: 50 });

    // Then — all four params present together
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("page[number]")).toBe("2");
    expect(url.searchParams.get("page[size]")).toBe("50");
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-delta,-last_seen_at",
    );
    expect(url.searchParams.get("filter[status]")).toBeNull();
  });
});

describe("getLatestFindingGroupResources — triangulation: params coexist", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should send the composite sort alongside pagination params without forcing filter[status]", async () => {
    // Given
    const checkId = "iam_root_mfa_enabled";

    // When
    await getLatestFindingGroupResources({ checkId, page: 3, pageSize: 20 });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("page[number]")).toBe("3");
    expect(url.searchParams.get("page[size]")).toBe("20");
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-delta,-last_seen_at",
    );
    expect(url.searchParams.get("filter[status]")).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// Caller filters should propagate unchanged to the drill-down resources endpoint
// ---------------------------------------------------------------------------

describe("getFindingGroupResources — caller filters are preserved", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should preserve caller filter[status] when explicitly provided", async () => {
    // Given
    const checkId = "s3_bucket_public_access";
    const filters = { "filter[status]": "PASS" };

    // When
    await getFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    const allStatusValues = url.searchParams.getAll("filter[status]");
    expect(allStatusValues).toHaveLength(1);
    expect(allStatusValues[0]).toBe("PASS");
  });

  it("should translate a single group status__in filter into filter[status] for resources", async () => {
    // Given
    const checkId = "s3_bucket_public_access";
    const filters = {
      "filter[status__in]": "PASS",
      "filter[severity__in]": "medium",
      "filter[provider_type__in]": "aws",
    };

    // When
    await getFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("filter[status]")).toBe("PASS");
    expect(url.searchParams.get("filter[status__in]")).toBeNull();
    expect(url.searchParams.get("filter[severity__in]")).toBe("medium");
    expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws");
  });

  it("should keep the composite sort when the resource search filter is applied", async () => {
    // Given
    const checkId = "s3_bucket_public_access";
    const filters = {
      "filter[name__icontains]": "bucket-prod",
      "filter[severity__in]": "high",
    };

    // When
    await getFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-delta,-last_seen_at",
    );
    expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
    expect(url.searchParams.get("filter[severity__in]")).toBe("high");
  });

  it("should strip scan filters that the group resources endpoint does not accept", async () => {
    // Given
    const checkId = "s3_bucket_public_access";
    const filters = {
      "filter[scan__in]": "scan-1",
      "filter[scan_id]": "scan-1",
      "filter[scan_id__in]": "scan-1,scan-2",
      "filter[region__in]": "eu-west-1",
    };

    // When
    await getFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("filter[scan__in]")).toBeNull();
    expect(url.searchParams.get("filter[scan_id]")).toBeNull();
    expect(url.searchParams.get("filter[scan_id__in]")).toBeNull();
    expect(url.searchParams.get("filter[region__in]")).toBe("eu-west-1");
  });
});

describe("getLatestFindingGroupResources — caller filters are preserved", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.stubGlobal("fetch", fetchMock);
    getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
    handleApiResponseMock.mockResolvedValue({ data: [] });
    fetchMock.mockResolvedValue(new Response("", { status: 200 }));
  });

  it("should preserve caller filter[status] when explicitly provided", async () => {
    // Given
    const checkId = "iam_user_mfa_enabled";
    const filters = { "filter[status]": "PASS" };

    // When
    await getLatestFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    const allStatusValues = url.searchParams.getAll("filter[status]");
    expect(allStatusValues).toHaveLength(1);
    expect(allStatusValues[0]).toBe("PASS");
  });

  it("should translate a single group status__in filter into filter[status] for latest resources", async () => {
    // Given
    const checkId = "iam_user_mfa_enabled";
    const filters = {
      "filter[status__in]": "PASS",
      "filter[severity__in]": "low",
      "filter[provider_type__in]": "aws",
    };

    // When
    await getLatestFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("filter[status]")).toBe("PASS");
    expect(url.searchParams.get("filter[status__in]")).toBeNull();
    expect(url.searchParams.get("filter[severity__in]")).toBe("low");
    expect(url.searchParams.get("filter[provider_type__in]")).toBe("aws");
  });

  it("should keep the composite sort when the resource search filter is applied", async () => {
    // Given
    const checkId = "iam_user_mfa_enabled";
    const filters = {
      "filter[name__icontains]": "instance-prod",
      "filter[status__in]": "PASS,FAIL",
    };

    // When
    await getLatestFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("sort")).toBe(
      "-status,-severity,-delta,-last_seen_at",
    );
    expect(url.searchParams.get("filter[name__icontains]")).toBe(
      "instance-prod",
    );
    expect(url.searchParams.get("filter[status__in]")).toBe("PASS,FAIL");
  });

  it("should strip scan filters before calling latest group resources", async () => {
    // Given
    const checkId = "iam_user_mfa_enabled";
    const filters = {
      "filter[scan__in]": "scan-1",
      "filter[scan_id]": "scan-1",
      "filter[region__in]": "us-east-1",
    };

    // When
    await getLatestFindingGroupResources({ checkId, filters });

    // Then
    const calledUrl = fetchMock.mock.calls[0][0] as string;
    const url = new URL(calledUrl);
    expect(url.searchParams.get("filter[scan__in]")).toBeNull();
    expect(url.searchParams.get("filter[scan_id]")).toBeNull();
    expect(url.searchParams.get("filter[region__in]")).toBe("us-east-1");
  });
});
