const API_BASE = "https://api.github.com";
const API_VERSION = "2022-11-28";
const USER_AGENT = "overdeck-collector";
const REQUEST_TIMEOUT_MS = 20_000;
const ETAG_CACHE_ENTRIES = 128;
const ETAG_MAX_BODY_BYTES = 4_000_000;

export interface GhHttpOptions {
  fetcher?: typeof fetch;
  resolveToken?: () => Promise<string>;
  timeoutMs?: number;
}

interface CacheEntry {
  etag: string;
  body: string;
}

export class GhHttpError extends Error {
  constructor(readonly status: number, path: string) {
    super(`gh api ${path} failed (${status})`);
    this.name = "GhHttpError";
  }
}

async function spawnGhAuthToken(): Promise<string> {
  const proc = Bun.spawn(["gh", "auth", "token"], { stdout: "pipe", stderr: "pipe" });
  const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
  const token = stdout.trim();
  if (exitCode !== 0 || token.length === 0) throw new Error("gh auth token unavailable");
  return token;
}

async function defaultResolveToken(): Promise<string> {
  const fromEnv = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
  if (fromEnv && fromEnv.trim().length > 0) return fromEnv.trim();
  return spawnGhAuthToken();
}

function apiUrl(path: string): string {
  return path.startsWith("http") ? path : `${API_BASE}/${path.replace(/^\//, "")}`;
}

function typedField(raw: string): unknown {
  if (raw === "true") return true;
  if (raw === "false") return false;
  if (raw === "null") return null;
  if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw);
  return raw;
}

interface GraphqlRequest {
  query: string;
  variables: Record<string, unknown>;
}

function parseGraphql(args: string[]): GraphqlRequest {
  let query: string | null = null;
  const variables: Record<string, unknown> = {};
  for (let index = 2; index < args.length; index += 2) {
    const flag = args[index];
    const pair = args[index + 1];
    if ((flag !== "-f" && flag !== "-F") || pair === undefined) {
      throw new Error(`unsupported gh graphql argument: ${flag}`);
    }
    const split = pair.indexOf("=");
    if (split < 0) throw new Error("unsupported gh graphql field");
    const key = pair.slice(0, split);
    const raw = pair.slice(split + 1);
    if (key === "query") query = raw;
    else variables[key] = flag === "-F" ? typedField(raw) : raw;
  }
  if (query === null) throw new Error("gh graphql call without query");
  return { query, variables };
}

class EtagCache {
  private readonly entries = new Map<string, CacheEntry>();

  get(url: string): CacheEntry | undefined {
    const entry = this.entries.get(url);
    if (!entry) return undefined;
    this.entries.delete(url);
    this.entries.set(url, entry);
    return entry;
  }

  set(url: string, entry: CacheEntry): void {
    if (entry.body.length > ETAG_MAX_BODY_BYTES) {
      this.entries.delete(url);
      return;
    }
    this.entries.delete(url);
    this.entries.set(url, entry);
    while (this.entries.size > ETAG_CACHE_ENTRIES) {
      const oldest = this.entries.keys().next();
      if (oldest.done) break;
      this.entries.delete(oldest.value);
    }
  }
}

class GhHttpClient {
  private readonly cache = new EtagCache();
  private readonly fetcher: typeof fetch;
  private readonly resolveToken: () => Promise<string>;
  private readonly timeoutMs: number;
  private token: Promise<string> | null = null;

  constructor(opts: GhHttpOptions = {}) {
    this.fetcher = opts.fetcher ?? fetch;
    this.resolveToken = opts.resolveToken ?? defaultResolveToken;
    this.timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
  }

  private async headers(extra: Record<string, string> = {}): Promise<Record<string, string>> {
    this.token ??= this.resolveToken();
    let token: string;
    try {
      token = await this.token;
    } catch (error) {
      this.token = null;
      throw error;
    }
    return {
      accept: "application/vnd.github+json",
      authorization: `Bearer ${token}`,
      "user-agent": USER_AGENT,
      "x-github-api-version": API_VERSION,
      ...extra,
    };
  }

  private async send(url: string, init: RequestInit, extraHeaders: Record<string, string>): Promise<Response> {
    const attempt = async (): Promise<Response> =>
      this.fetcher(url, {
        ...init,
        headers: await this.headers(extraHeaders),
        signal: AbortSignal.timeout(this.timeoutMs),
      });
    const response = await attempt();
    if (response.status !== 401) return response;
    this.token = null;
    return attempt();
  }

  async get(path: string): Promise<string> {
    const url = apiUrl(path);
    const cached = this.cache.get(url);
    const response = await this.send(
      url,
      { method: "GET" },
      cached ? { "if-none-match": cached.etag } : {},
    );
    if (response.status === 304 && cached) {
      await response.body?.cancel();
      return cached.body;
    }
    const body = await response.text();
    if (!response.ok) throw new GhHttpError(response.status, path);
    const etag = response.headers.get("etag");
    if (etag) this.cache.set(url, { etag, body });
    return body;
  }

  async graphql(request: GraphqlRequest): Promise<string> {
    const response = await this.send(
      `${API_BASE}/graphql`,
      { method: "POST", body: JSON.stringify(request) },
      { "content-type": "application/json" },
    );
    const body = await response.text();
    if (!response.ok) throw new GhHttpError(response.status, "graphql");
    return body;
  }

  async logTail(path: string, maxBytes: number): Promise<Uint8Array> {
    const response = await this.send(apiUrl(path), { method: "GET", redirect: "manual" }, {});
    const location = response.headers.get("location");
    const target = response.status >= 300 && response.status < 400 && location ? location : null;
    if (target) await response.body?.cancel();
    else if (!response.ok) {
      await response.body?.cancel();
      throw new GhHttpError(response.status, path);
    }
    const payload = target
      ? await this.fetcher(target, {
        headers: { "user-agent": USER_AGENT },
        signal: AbortSignal.timeout(this.timeoutMs),
      })
      : response;
    if (!payload.ok) throw new GhHttpError(payload.status, path);
    const reader = payload.body?.getReader();
    let tail = new Uint8Array();
    if (!reader) return tail;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      tail = appendTail(tail, new Uint8Array(value), maxBytes);
    }
    return tail;
  }
}

export function appendTail(existing: Uint8Array, chunk: Uint8Array, maxBytes: number): Uint8Array<ArrayBuffer> {
  if (chunk.length >= maxBytes) {
    const output = new Uint8Array(maxBytes);
    output.set(chunk.subarray(chunk.length - maxBytes));
    return output;
  }
  const keep = Math.min(existing.length, maxBytes - chunk.length);
  const output = new Uint8Array(keep + chunk.length);
  output.set(existing.slice(existing.length - keep));
  output.set(chunk, keep);
  return output;
}

export interface GhHttp {
  run: (args: string[]) => Promise<string>;
  logTail: (args: string[], maxBytes: number) => Promise<Uint8Array>;
}

export function createGhHttp(opts: GhHttpOptions = {}): GhHttp {
  const client = new GhHttpClient(opts);
  const requirePath = (args: string[]): string => {
    if (args[0] !== "api" || typeof args[1] !== "string") throw new Error("unsupported gh invocation");
    return args[1];
  };
  return {
    run: async (args) => {
      const path = requirePath(args);
      if (path === "graphql") return client.graphql(parseGraphql(args));
      if (args.length !== 2) throw new Error("unsupported gh api invocation");
      return client.get(path);
    },
    logTail: async (args, maxBytes) => {
      const path = requirePath(args);
      if (args.length !== 2 || path === "graphql") throw new Error("unsupported gh log invocation");
      return client.logTail(path, maxBytes);
    },
  };
}

export function createGhHttpRunner(opts: GhHttpOptions = {}): (args: string[]) => Promise<string> {
  return createGhHttp(opts).run;
}
