import { createAppAuth } from "@octokit/auth-app";
import type { Logger } from "pino";
import type { AppConfig } from "@/types/index.js";

/** Cached installation token and expiry (ms since epoch). */
let cached: { token: string; expiresAt: number } | null = null;

/** Minimum TTL before we refresh (5 minutes). */
const REFRESH_BEFORE_MS = 5 * 60 * 1000;

function normalizePrivateKey(key: string): string {
  return key.replace(/\\n/g, "\n").trim();
}

/**
 * True when an error (raw Octokit RequestError or a wrapping GitHubApiError) indicates
 * a 401 Bad credentials response. Octokit exposes `.status` directly; our GitHubApiError
 * wraps the original via `.cause`, so we check both.
 */
export function isBadCredentialsError(err: unknown): boolean {
  const visited = new Set<unknown>();
  let current: unknown = err;
  while (current && typeof current === "object" && !visited.has(current)) {
    visited.add(current);
    const status = (current as { status?: unknown }).status;
    if (typeof status === "number" && status === 401) return true;
    const response = (current as { response?: { status?: unknown } }).response;
    if (response && typeof response === "object") {
      const respStatus = (response as { status?: unknown }).status;
      if (typeof respStatus === "number" && respStatus === 401) return true;
    }
    current = (current as { cause?: unknown }).cause;
  }
  return false;
}

/**
 * Run a GitHub operation with automatic one-shot retry on 401 Bad credentials.
 *
 * The caller provides a `buildClient` factory (NOT a pre-built client) so the retry
 * can use a freshly-issued token. On the first 401, we build a new client via the
 * factory and call `operation` once more. Any other error, or a second 401, is
 * re-thrown — callers should let the queue's retry path take over.
 */
export async function runWithAuthRetry<TClient, TResult>(
  buildClient: () => Promise<TClient>,
  operation: (client: TClient) => Promise<TResult>,
  logger: Logger,
): Promise<TResult> {
  const client = await buildClient();
  try {
    return await operation(client);
  } catch (err) {
    if (!isBadCredentialsError(err)) throw err;
    logger.warn({ err: err instanceof Error ? err.message : String(err) }, "github_auth_401_retry");
    const fresh = await buildClient();
    return await operation(fresh);
  }
}

/**
 * Returns a function that resolves to a GitHub token. Uses either
 * GITHUB_TOKEN (PAT) or GitHub App installation token (when
 * GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY are set).
 */
export function createGitHubTokenProvider(
  config: Pick<
    AppConfig,
    "githubToken" | "githubAppId" | "githubAppInstallationId" | "githubAppPrivateKey"
  >,
): () => Promise<string> {
  const appId = config.githubAppId;
  const installationId = config.githubAppInstallationId;
  const privateKey = config.githubAppPrivateKey;

  if (appId != null && installationId != null && privateKey != null && privateKey.length > 0) {
    const auth = createAppAuth({
      appId,
      privateKey: normalizePrivateKey(privateKey),
      installationId,
    });

    return async (): Promise<string> => {
      const now = Date.now();
      if (cached && cached.expiresAt - now > REFRESH_BEFORE_MS) {
        return cached.token;
      }
      const { token, expiresAt } = await auth({
        type: "installation",
        installationId,
      });
      cached = { token, expiresAt: new Date(expiresAt).getTime() };
      return token;
    };
  }

  const token = config.githubToken;
  if (!token) {
    throw new Error(
      "Either GITHUB_TOKEN or (GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY) must be set",
    );
  }
  return async (): Promise<string> => token;
}

/**
 * Boot-time probe that verifies the provided GitHub token can actually reach the API.
 *
 * Hits `GET /rate_limit` — the lightest call that validates a token without needing any
 * repo access (and it's not counted against the rate limit itself). Throws on network
 * failure or a non-2xx response so the caller can fail fast at startup, instead of
 * letting pollers discover the bad credential 30 s later and trip their backoff.
 */
export async function verifyGitHubAuth(
  getToken: () => Promise<string>,
  logger: Logger,
): Promise<void> {
  const token = await getToken();
  const res = await fetch("https://api.github.com/rate_limit", {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/vnd.github+json",
      "X-GitHub-Api-Version": "2022-11-28",
    },
  });

  if (!res.ok) {
    const body = await res.text().catch(() => "");
    logger.error(
      { status: res.status, tokenPreview: `${token.slice(0, 8)}…`, body: body.slice(0, 500) },
      "github_auth_verification_failed",
    );
    throw new Error(`GitHub auth verification failed: ${res.status} ${res.statusText}`);
  }

  const expiration = res.headers.get("github-authentication-token-expiration");
  logger.info(
    {
      tokenType: token.startsWith("ghs_") ? "github-app-installation" : "pat",
      tokenPreview: `${token.slice(0, 8)}…`,
      expiresAt: expiration,
    },
    "github_auth_verified",
  );
}
