import { describe, test, expect, mock, afterEach, setSystemTime } from "bun:test";

// ── mock.module must be called before importing the module under test ─────────

const mockAuthFn = mock<() => Promise<{ token: string; expiresAt: string }>>();
const mockCreateAppAuth = mock(() => mockAuthFn);

mock.module("@octokit/auth-app", () => ({
  createAppAuth: mockCreateAppAuth,
}));

import {
  createGitHubTokenProvider,
  isBadCredentialsError,
  runWithAuthRetry,
  verifyGitHubAuth,
} from "@/github/auth.js";
import { mockLogger } from "../_helpers/mock-logger.js";
import { GitHubApiError } from "@/core/errors.js";
import { createFakeFetch, jsonResponse, errorResponse } from "../_helpers/fake-fetch.js";

// ── helpers ───────────────────────────────────────────────────────────────────

/** Builds a config that uses the App credentials path. */
function appConfig(overrides: { privateKey?: string } = {}) {
  return {
    githubToken: "",
    githubAppId: 12345,
    githubAppInstallationId: 67890,
    githubAppPrivateKey:
      overrides.privateKey ??
      "-----BEGIN RSA PRIVATE KEY-----\\nfake\\n-----END RSA PRIVATE KEY-----",
  };
}

/** ISO string 1 hour from `baseMs`. */
function oneHourLater(baseMs: number): string {
  return new Date(baseMs + 3_600_000).toISOString();
}

// ── PAT path ──────────────────────────────────────────────────────────────────

describe("createGitHubTokenProvider — PAT path", () => {
  test("returns the PAT directly when no App credentials are provided", async () => {
    const provider = createGitHubTokenProvider({
      githubToken: "ghs_personalAccessToken",
      githubAppId: undefined,
      githubAppInstallationId: undefined,
      githubAppPrivateKey: undefined,
    });
    const token = await provider();
    expect(token).toBe("ghs_personalAccessToken");
  });

  test("always resolves to the same PAT on repeated calls", async () => {
    const provider = createGitHubTokenProvider({
      githubToken: "ghs_pat2",
      githubAppId: undefined,
      githubAppInstallationId: undefined,
      githubAppPrivateKey: undefined,
    });
    expect(await provider()).toBe("ghs_pat2");
    expect(await provider()).toBe("ghs_pat2");
  });
});

// ── Error path ────────────────────────────────────────────────────────────────

describe("createGitHubTokenProvider — error path", () => {
  test("throws at factory time when neither PAT nor App credentials are provided", () => {
    expect(() =>
      createGitHubTokenProvider({
        githubToken: "",
        githubAppId: undefined,
        githubAppInstallationId: undefined,
        githubAppPrivateKey: undefined,
      }),
    ).toThrow(
      "Either GITHUB_TOKEN or (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY) must be set",
    );
  });

  test("throws when privateKey is empty string (treated as absent)", () => {
    expect(() =>
      createGitHubTokenProvider({
        githubToken: "",
        githubAppId: 1,
        githubAppInstallationId: 2,
        githubAppPrivateKey: "",
      }),
    ).toThrow("Either GITHUB_TOKEN");
  });
});

// ── App path ──────────────────────────────────────────────────────────────────

describe("createGitHubTokenProvider — App path", () => {
  afterEach(() => {
    // Restore real wall clock so time leaks don't affect other suites.
    setSystemTime();
    mockAuthFn.mockClear();
    mockCreateAppAuth.mockClear();
  });

  test("first call fetches a token and returns it", async () => {
    // Use a far-future era so any cached token from previous tests is stale.
    const baseMs = new Date("2099-01-01T00:00:00Z").getTime();
    setSystemTime(new Date(baseMs));

    mockAuthFn.mockImplementation(async () => ({
      token: "app_token_first",
      expiresAt: oneHourLater(baseMs),
    }));

    const provider = createGitHubTokenProvider(appConfig());
    const token = await provider();

    expect(token).toBe("app_token_first");
    expect(mockAuthFn).toHaveBeenCalledTimes(1);
  });

  test("second call within refresh window returns cached token without calling auth", async () => {
    const baseMs = new Date("2100-01-01T00:00:00Z").getTime();
    setSystemTime(new Date(baseMs));

    mockAuthFn.mockImplementation(async () => ({
      token: "app_token_cached",
      expiresAt: oneHourLater(baseMs),
    }));

    const provider = createGitHubTokenProvider(appConfig());

    // First call — populates cache.
    const first = await provider();
    expect(first).toBe("app_token_cached");

    // Clear call count, then do a second call at the same instant.
    mockAuthFn.mockClear();

    const second = await provider();
    expect(second).toBe("app_token_cached");
    // Auth must NOT be called again — token is still fresh.
    expect(mockAuthFn).not.toHaveBeenCalled();
  });

  test("call after cache expires (within REFRESH_BEFORE_MS window) refreshes the token", async () => {
    const REFRESH_BEFORE_MS = 5 * 60 * 1000; // 5 minutes, mirrors the source constant
    const baseMs = new Date("2101-01-01T00:00:00Z").getTime();
    setSystemTime(new Date(baseMs));

    // First call — populate cache with expiresAt = baseMs + 1 hour.
    mockAuthFn.mockImplementation(async () => ({
      token: "app_token_stale",
      expiresAt: oneHourLater(baseMs),
    }));

    const provider = createGitHubTokenProvider(appConfig());
    await provider();

    // Advance clock so that expiresAt - now <= REFRESH_BEFORE_MS.
    // expiresAt = baseMs + 3_600_000, so now must be >= baseMs + 3_600_000 - REFRESH_BEFORE_MS.
    const staleMs = baseMs + 3_600_000 - REFRESH_BEFORE_MS + 1;
    setSystemTime(new Date(staleMs));

    mockAuthFn.mockClear();
    mockAuthFn.mockImplementation(async () => ({
      token: "app_token_refreshed",
      expiresAt: oneHourLater(staleMs),
    }));

    const refreshed = await provider();
    expect(refreshed).toBe("app_token_refreshed");
    expect(mockAuthFn).toHaveBeenCalledTimes(1);
  });

  test("normalizePrivateKey converts literal \\\\n escape sequences to real newlines", async () => {
    const baseMs = new Date("2102-01-01T00:00:00Z").getTime();
    setSystemTime(new Date(baseMs));

    mockAuthFn.mockImplementation(async () => ({
      token: "any_token",
      expiresAt: oneHourLater(baseMs),
    }));

    const keyWithEscapes =
      "-----BEGIN RSA PRIVATE KEY-----\\nline1\\nline2\\n-----END RSA PRIVATE KEY-----";
    const provider = createGitHubTokenProvider(appConfig({ privateKey: keyWithEscapes }));
    await provider();

    // createAppAuth should have been called with real newlines, not literal \n
    expect(mockCreateAppAuth).toHaveBeenCalledWith(
      expect.objectContaining({
        privateKey: "-----BEGIN RSA PRIVATE KEY-----\nline1\nline2\n-----END RSA PRIVATE KEY-----",
      }),
    );
  });
});

// ── isBadCredentialsError ─────────────────────────────────────────────────────

describe("isBadCredentialsError", () => {
  test("returns true for a raw error with status 401", () => {
    const err = Object.assign(new Error("Bad credentials"), { status: 401 });
    expect(isBadCredentialsError(err)).toBe(true);
  });

  test("returns true for an Octokit-shaped error with response.status 401", () => {
    const err = Object.assign(new Error("Bad credentials"), { response: { status: 401 } });
    expect(isBadCredentialsError(err)).toBe(true);
  });

  test("returns true for a GitHubApiError wrapping a 401 via cause", () => {
    const inner = Object.assign(new Error("Bad credentials"), { status: 401 });
    const wrapped = new GitHubApiError("Failed", { cause: inner });
    expect(isBadCredentialsError(wrapped)).toBe(true);
  });

  test("returns false for a non-401 status", () => {
    const err = Object.assign(new Error("Forbidden"), { status: 403 });
    expect(isBadCredentialsError(err)).toBe(false);
  });

  test("returns false for an error without a status code anywhere in the chain", () => {
    const err = new Error("boom");
    expect(isBadCredentialsError(err)).toBe(false);
  });

  test("returns false for null/undefined/primitive inputs", () => {
    expect(isBadCredentialsError(null)).toBe(false);
    expect(isBadCredentialsError(undefined)).toBe(false);
    expect(isBadCredentialsError("401")).toBe(false);
    expect(isBadCredentialsError(401)).toBe(false);
  });

  test("does not infinite-loop on a cause cycle", () => {
    const a = { status: 403, cause: {} as { cause?: unknown } };
    a.cause = a;
    expect(isBadCredentialsError(a)).toBe(false);
  });
});

// ── runWithAuthRetry ──────────────────────────────────────────────────────────

describe("runWithAuthRetry", () => {
  function build401(): GitHubApiError {
    const inner = Object.assign(new Error("Bad credentials"), { status: 401 });
    return new GitHubApiError("Failed to do thing", { cause: inner });
  }

  test("returns the result when the operation succeeds on the first try", async () => {
    const logger = mockLogger();
    const buildClient = mock(async () => ({ tag: "client-1" }));
    const op = mock(async (c: { tag: string }) => `ok:${c.tag}`);

    const result = await runWithAuthRetry(buildClient, op, logger);

    expect(result).toBe("ok:client-1");
    expect(buildClient).toHaveBeenCalledTimes(1);
    expect(op).toHaveBeenCalledTimes(1);
    expect((logger.warn as ReturnType<typeof mock>).mock.calls.length).toBe(0);
  });

  test("retries once with a fresh client on a 401 and returns the retry result", async () => {
    const logger = mockLogger();
    let clientCounter = 0;
    const buildClient = mock(async () => ({ tag: `client-${++clientCounter}` }));

    let attempts = 0;
    const op = mock(async (c: { tag: string }) => {
      attempts++;
      if (attempts === 1) throw build401();
      return `ok:${c.tag}`;
    });

    const result = await runWithAuthRetry(buildClient, op, logger);

    expect(result).toBe("ok:client-2");
    expect(buildClient).toHaveBeenCalledTimes(2);
    expect(op).toHaveBeenCalledTimes(2);
    expect((logger.warn as ReturnType<typeof mock>).mock.calls.length).toBe(1);
  });

  test("throws when the second attempt also returns 401", async () => {
    const logger = mockLogger();
    const buildClient = mock(async () => ({ tag: "client" }));
    const op = mock(async () => {
      throw build401();
    });

    await expect(runWithAuthRetry(buildClient, op, logger)).rejects.toThrow("Failed to do thing");
    expect(buildClient).toHaveBeenCalledTimes(2);
    expect(op).toHaveBeenCalledTimes(2);
  });

  test("rethrows non-401 errors immediately without retry", async () => {
    const logger = mockLogger();
    const buildClient = mock(async () => ({ tag: "client" }));
    const nonAuthErr = new Error("network down");
    const op = mock(async () => {
      throw nonAuthErr;
    });

    await expect(runWithAuthRetry(buildClient, op, logger)).rejects.toThrow("network down");
    expect(buildClient).toHaveBeenCalledTimes(1);
    expect(op).toHaveBeenCalledTimes(1);
  });
});

// ── verifyGitHubAuth ──────────────────────────────────────────────────────────

describe("verifyGitHubAuth", () => {
  const originalFetch = globalThis.fetch;

  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  test("resolves and logs on 2xx from /rate_limit", async () => {
    const logger = mockLogger();
    const { fakeFetch, calls } = createFakeFetch([
      {
        urlPattern: /api\.github\.com\/rate_limit/,
        response: () => {
          const res = jsonResponse({ resources: {}, rate: { limit: 5000 } });
          res.headers.set("github-authentication-token-expiration", "2099-01-01T00:00:00Z");
          return res;
        },
      },
    ]);
    globalThis.fetch = fakeFetch as unknown as typeof fetch;

    await verifyGitHubAuth(async () => "ghs_probeToken", logger);

    expect(calls).toHaveLength(1);
    expect(calls[0]?.url).toBe("https://api.github.com/rate_limit");
    const authHeader = (calls[0]?.init?.headers as Record<string, string>)?.Authorization;
    expect(authHeader).toBe("Bearer ghs_probeToken");
    expect((logger.info as ReturnType<typeof mock>).mock.calls.length).toBeGreaterThan(0);
  });

  test("throws on non-2xx and logs the failure with status", async () => {
    const logger = mockLogger();
    const { fakeFetch } = createFakeFetch([
      {
        urlPattern: /api\.github\.com\/rate_limit/,
        response: () => errorResponse(401, "Unauthorized", "Bad credentials"),
      },
    ]);
    globalThis.fetch = fakeFetch as unknown as typeof fetch;

    await expect(verifyGitHubAuth(async () => "ghs_bad", logger)).rejects.toThrow(
      "GitHub auth verification failed: 401",
    );
    expect((logger.error as ReturnType<typeof mock>).mock.calls.length).toBe(1);
  });
});
