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

// ---------------------------------------------------------------------------
// Mock fetch globally for Google token endpoint calls
// ---------------------------------------------------------------------------
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

// ---------------------------------------------------------------------------
// Mock secretService behavior via a mock db object
// ---------------------------------------------------------------------------
let mockSecretStore: Record<string, { id: string; companyId: string; name: string; latestVersion: number; value: string }> = {};

function createMockDb() {
  const secrets = {
    getByName: vi.fn(async (companyId: string, name: string) => {
      const key = `${companyId}:${name}`;
      return mockSecretStore[key] ?? null;
    }),
    create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string }) => {
      const key = `${companyId}:${input.name}`;
      const secret = { id: `secret-${Date.now()}`, companyId, name: input.name, latestVersion: 1, value: input.value };
      mockSecretStore[key] = secret;
      return secret;
    }),
    rotate: vi.fn(async (secretId: string, input: { value: string }) => {
      for (const key of Object.keys(mockSecretStore)) {
        if (mockSecretStore[key]!.id === secretId) {
          mockSecretStore[key]!.value = input.value;
          mockSecretStore[key]!.latestVersion += 1;
          return mockSecretStore[key];
        }
      }
      throw new Error("Secret not found");
    }),
    resolveSecretValue: vi.fn(async (_companyId: string, secretId: string, _version: string) => {
      for (const key of Object.keys(mockSecretStore)) {
        if (mockSecretStore[key]!.id === secretId) {
          return mockSecretStore[key]!.value;
        }
      }
      throw new Error("Secret not found");
    }),
  };
  // The db object is used via secretService(db) — we need to mock the underlying drizzle calls
  // Instead, we mock the secrets service module itself
  return { _secrets: secrets };
}

// ---------------------------------------------------------------------------
// Mock the secretService to use our in-memory mock
// ---------------------------------------------------------------------------
vi.mock("../services/secrets.js", () => ({
  secretService: vi.fn((_db: unknown) => ({
    getByName: vi.fn(async (companyId: string, name: string) => {
      const key = `${companyId}:${name}`;
      return mockSecretStore[key] ?? null;
    }),
    create: vi.fn(async (companyId: string, input: { name: string; provider: string; value: string; description?: string | null }) => {
      const key = `${companyId}:${input.name}`;
      const secret = { id: `secret-${Date.now()}`, companyId, name: input.name, latestVersion: 1, value: input.value };
      mockSecretStore[key] = secret;
      return secret;
    }),
    rotate: vi.fn(async (secretId: string, input: { value: string }) => {
      for (const key of Object.keys(mockSecretStore)) {
        if (mockSecretStore[key]!.id === secretId) {
          mockSecretStore[key]!.value = input.value;
          mockSecretStore[key]!.latestVersion += 1;
          return mockSecretStore[key];
        }
      }
      throw new Error("Secret not found");
    }),
    resolveSecretValue: vi.fn(async (companyId: string, secretId: string, _version: string) => {
      for (const key of Object.keys(mockSecretStore)) {
        const s = mockSecretStore[key]!;
        if (s.id === secretId && s.companyId === companyId) {
          return s.value;
        }
      }
      throw new Error("Secret not found");
    }),
  })),
}));

// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import { googleOAuthService } from "../services/google-oauth.js";
import { googleOAuthRoutes } from "../routes/google-oauth.js";

// ---------------------------------------------------------------------------
// Helper: create a minimal Express app with board auth stubbed
// ---------------------------------------------------------------------------
function createTestApp(db: unknown) {
  const app = express();
  app.use(express.json());
  // Stub actor middleware — sets board actor on all requests
  app.use((req, _res, next) => {
    (req as unknown as { actor: { type: string; source: string; isInstanceAdmin: boolean; companyIds: string[]; userId: string } }).actor = {
      type: "board",
      source: "local_implicit",
      isInstanceAdmin: true,
      companyIds: ["company-1"],
      userId: "test-user",
    };
    next();
  });
  app.use(googleOAuthRoutes(db as Parameters<typeof googleOAuthRoutes>[0]));
  return app;
}

// ---------------------------------------------------------------------------
// googleOAuthService unit tests
// ---------------------------------------------------------------------------
describe("googleOAuthService", () => {
  const mockDb = {} as Parameters<typeof googleOAuthService>[0];

  beforeEach(() => {
    mockSecretStore = {};
    vi.clearAllMocks();
  });

  it("Test 1: generatePkce produces verifier (43 chars base64url) and challenge (43 chars base64url)", () => {
    const svc = googleOAuthService(mockDb);
    const { verifier, challenge } = svc.generatePkce();
    // base64url of 32 bytes = ceil(32 * 4/3) = 43 chars (no padding)
    expect(verifier).toHaveLength(43);
    expect(challenge).toHaveLength(43);
    // Both should be valid base64url (only A-Z, a-z, 0-9, -, _)
    expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
    expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
    // Verifier and challenge should be different
    expect(verifier).not.toBe(challenge);
  });

  it("Test 2: generateAuthUrl returns URL containing client_id, code_challenge, code_challenge_method=S256, state", () => {
    const svc = googleOAuthService(mockDb);
    const state = "test-state-uuid";
    const { url, verifier } = svc.generateAuthUrl("https://example.com/callback", state);

    expect(url).toContain("accounts.google.com/o/oauth2/v2/auth");
    expect(url).toContain("client_id=812546505895");
    expect(url).toContain("code_challenge=");
    expect(url).toContain("code_challenge_method=S256");
    expect(url).toContain(`state=${state}`);
    expect(url).toContain("response_type=code");
    expect(url).toContain("access_type=offline");
    expect(url).toContain("prompt=consent");
    expect(typeof verifier).toBe("string");
    expect(verifier.length).toBeGreaterThan(0);
  });

  it("Test 3: exchangeCode POSTs to Google token endpoint with correct body (grant_type, code_verifier)", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        access_token: "ya29.test-access-token",
        refresh_token: "1//test-refresh-token",
        expires_in: 3600,
      }),
    });

    const svc = googleOAuthService(mockDb);
    const result = await svc.exchangeCode(
      "auth-code-123",
      "https://example.com/callback",
      "pkce-verifier-abc",
    );

    expect(mockFetch).toHaveBeenCalledTimes(1);
    const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
    expect(url).toBe("https://oauth2.googleapis.com/token");
    expect(init.method).toBe("POST");

    const bodyStr = init.body as string;
    expect(bodyStr).toContain("grant_type=authorization_code");
    expect(bodyStr).toContain("code=auth-code-123");
    expect(bodyStr).toContain("code_verifier=pkce-verifier-abc");
    expect(bodyStr).toContain("812546505895");

    expect(result.accessToken).toBe("ya29.test-access-token");
    expect(result.refreshToken).toBe("1//test-refresh-token");
    expect(result.expiresIn).toBe(3600);
  });

  it("Test 4: storeTokens creates a new secret when none exists (name: google_gemini_oauth_token)", async () => {
    const svc = googleOAuthService(mockDb);
    await svc.storeTokens("company-1", {
      accessToken: "ya29.new-token",
      refreshToken: "1//new-refresh",
    });

    const stored = mockSecretStore["company-1:google_gemini_oauth_token"];
    expect(stored).toBeDefined();
    const parsed = JSON.parse(stored!.value);
    expect(parsed.accessToken).toBe("ya29.new-token");
    expect(parsed.refreshToken).toBe("1//new-refresh");
  });

  it("Test 5: storeTokens rotates when secret already exists", async () => {
    // Pre-populate a secret
    mockSecretStore["company-1:google_gemini_oauth_token"] = {
      id: "existing-secret-id",
      companyId: "company-1",
      name: "google_gemini_oauth_token",
      latestVersion: 1,
      value: JSON.stringify({ accessToken: "old-token", refreshToken: "old-refresh" }),
    };

    const svc = googleOAuthService(mockDb);
    await svc.storeTokens("company-1", {
      accessToken: "ya29.rotated-token",
      refreshToken: "1//rotated-refresh",
    });

    const stored = mockSecretStore["company-1:google_gemini_oauth_token"];
    expect(stored).toBeDefined();
    const parsed = JSON.parse(stored!.value);
    expect(parsed.accessToken).toBe("ya29.rotated-token");
    expect(stored!.latestVersion).toBe(2);
  });
});

// ---------------------------------------------------------------------------
// googleOAuthRoutes integration tests
// ---------------------------------------------------------------------------
describe("googleOAuthRoutes", () => {
  const mockDb = {} as Parameters<typeof googleOAuthRoutes>[0];
  let app: ReturnType<typeof createTestApp>;

  beforeEach(() => {
    mockSecretStore = {};
    vi.clearAllMocks();
    mockFetch.mockReset();
    app = createTestApp(mockDb);
  });

  it("Test 6: POST /oauth/google/authorize returns { url, stateId } and stores verifier in pendingPkce (no companyId)", async () => {
    const res = await request(app)
      .post("/oauth/google/authorize")
      .set("Host", "localhost:3000")
      .expect(200);

    expect(res.body).toHaveProperty("url");
    expect(res.body).toHaveProperty("stateId");
    expect(typeof res.body.url).toBe("string");
    expect(typeof res.body.stateId).toBe("string");
    expect(res.body.url).toContain("accounts.google.com");
    expect(res.body.url).toContain("812546505895");
    // stateId should be a UUID
    expect(res.body.stateId).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
    );
  });

  it("Test 7: GET /oauth/google/callback with valid state exchanges code, stores tokens in pendingTokens, redirects to /?google_oauth=success", async () => {
    // First get a valid stateId via authorize
    const authRes = await request(app)
      .post("/oauth/google/authorize")
      .set("Host", "localhost:3000")
      .expect(200);

    const { stateId } = authRes.body as { stateId: string };

    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        access_token: "ya29.callback-token",
        refresh_token: "1//callback-refresh",
        expires_in: 3600,
      }),
    });

    const callbackRes = await request(app)
      .get(`/oauth/google/callback?code=auth-code&state=${stateId}`)
      .set("Host", "localhost:3000")
      .expect(302);

    expect(callbackRes.headers["location"]).toContain("google_oauth=success");
    expect(callbackRes.headers["location"]).toContain(`state=${stateId}`);
  });

  it("Test 8: GET /oauth/google/callback with invalid state returns 400", async () => {
    const res = await request(app)
      .get("/oauth/google/callback?code=some-code&state=invalid-state-does-not-exist")
      .set("Host", "localhost:3000")
      .expect(400);

    expect(res.text).toContain("Invalid or expired OAuth state");
  });

  it("Test 9: POST /oauth/google/claim with valid stateId moves tokens to secretService and returns { ok: true }", async () => {
    // Full flow: authorize -> callback -> claim
    const authRes = await request(app)
      .post("/oauth/google/authorize")
      .set("Host", "localhost:3000")
      .expect(200);

    const { stateId } = authRes.body as { stateId: string };

    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        access_token: "ya29.claim-token",
        refresh_token: "1//claim-refresh",
        expires_in: 3600,
      }),
    });

    await request(app)
      .get(`/oauth/google/callback?code=auth-code&state=${stateId}`)
      .set("Host", "localhost:3000")
      .expect(302);

    const claimRes = await request(app)
      .post("/oauth/google/claim")
      .send({ stateId, companyId: "company-1" })
      .expect(200);

    expect(claimRes.body).toEqual({ ok: true });

    // Verify tokens were stored in secretService
    const stored = mockSecretStore["company-1:google_gemini_oauth_token"];
    expect(stored).toBeDefined();
    const parsed = JSON.parse(stored!.value);
    expect(parsed.accessToken).toBe("ya29.claim-token");
  });

  it("Test 10: POST /oauth/google/claim with expired/missing stateId returns 404", async () => {
    const res = await request(app)
      .post("/oauth/google/claim")
      .send({ stateId: "nonexistent-state-id", companyId: "company-1" })
      .expect(404);

    expect(res.body).toHaveProperty("error");
    expect(res.body.error).toContain("expired or not found");
  });

  it("Test 11: POST /api-keys/store stores key via secretService upsert pattern", async () => {
    const res = await request(app)
      .post("/api-keys/store")
      .send({ companyId: "company-1", provider: "openai", apiKey: "sk-test-key-12345" })
      .expect(200);

    expect(res.body).toEqual({ ok: true });

    // Verify the key was stored
    const stored = mockSecretStore["company-1:openai_api_key"];
    expect(stored).toBeDefined();
    expect(stored!.value).toBe("sk-test-key-12345");

    // Test upsert (rotate) when key already exists
    const res2 = await request(app)
      .post("/api-keys/store")
      .send({ companyId: "company-1", provider: "openai", apiKey: "sk-new-key-67890" })
      .expect(200);

    expect(res2.body).toEqual({ ok: true });

    const stored2 = mockSecretStore["company-1:openai_api_key"];
    expect(stored2!.value).toBe("sk-new-key-67890");
    expect(stored2!.latestVersion).toBe(2);
  });
});
