/**
 * GitHub Copilot OAuth flow
 */

import { getModels } from "../../models.js";
import type { Api, Model } from "../../types.js";
import type {
  OAuthCredentials,
  OAuthLoginCallbacks,
  OAuthProviderInterface,
} from "./types.js";

type CopilotCredentials = OAuthCredentials & {
  enterpriseUrl?: string;
};

const decode = (s: string) => atob(s);
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");

const COPILOT_HEADERS = {
  "User-Agent": "GitHubCopilotChat/0.35.0",
  "Editor-Version": "vscode/1.107.0",
  "Editor-Plugin-Version": "copilot-chat/0.35.0",
  "Copilot-Integration-Id": "vscode-chat",
} as const;

type DeviceCodeResponse = {
  device_code: string;
  user_code: string;
  verification_uri: string;
  interval: number;
  expires_in: number;
};

type DeviceTokenSuccessResponse = {
  access_token: string;
  token_type?: string;
  scope?: string;
};

type DeviceTokenErrorResponse = {
  error: string;
  error_description?: string;
  interval?: number;
};

export function normalizeDomain(input: string): string | null {
  const trimmed = input.trim();
  if (!trimmed) return null;
  try {
    const url = trimmed.includes("://")
      ? new URL(trimmed)
      : new URL(`https://${trimmed}`);
    return url.hostname;
  } catch {
    return null;
  }
}

function getUrls(domain: string): {
  deviceCodeUrl: string;
  accessTokenUrl: string;
  copilotTokenUrl: string;
} {
  return {
    deviceCodeUrl: `https://${domain}/login/device/code`,
    accessTokenUrl: `https://${domain}/login/oauth/access_token`,
    copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
  };
}

/**
 * Parse the proxy-ep from a Copilot token and convert to API base URL.
 * Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...
 * Returns API URL like https://api.individual.githubcopilot.com
 */
function getBaseUrlFromToken(token: string): string | null {
  const match = token.match(/proxy-ep=([^;]+)/);
  if (!match) return null;
  const proxyHost = match[1];
  // Convert proxy.xxx to api.xxx
  const apiHost = proxyHost.replace(/^proxy\./, "api.");
  return `https://${apiHost}`;
}

export function getGitHubCopilotBaseUrl(
  token?: string,
  enterpriseDomain?: string,
): string {
  // If we have a token, extract the base URL from proxy-ep
  if (token) {
    const urlFromToken = getBaseUrlFromToken(token);
    if (urlFromToken) return urlFromToken;
  }
  // Fallback for enterprise or if token parsing fails
  if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
  return "https://api.individual.githubcopilot.com";
}

async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
  const response = await fetch(url, init);
  if (!response.ok) {
    const text = await response.text();
    throw new Error(`${response.status} ${response.statusText}: ${text}`);
  }
  return response.json();
}

async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
  const urls = getUrls(domain);
  const data = await fetchJson(urls.deviceCodeUrl, {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      "User-Agent": "GitHubCopilotChat/0.35.0",
    },
    body: JSON.stringify({
      client_id: CLIENT_ID,
      scope: "read:user",
    }),
  });

  if (!data || typeof data !== "object") {
    throw new Error("Invalid device code response");
  }

  const deviceCode = (data as Record<string, unknown>).device_code;
  const userCode = (data as Record<string, unknown>).user_code;
  const verificationUri = (data as Record<string, unknown>).verification_uri;
  const interval = (data as Record<string, unknown>).interval;
  const expiresIn = (data as Record<string, unknown>).expires_in;

  if (
    typeof deviceCode !== "string" ||
    typeof userCode !== "string" ||
    typeof verificationUri !== "string" ||
    typeof interval !== "number" ||
    typeof expiresIn !== "number"
  ) {
    throw new Error("Invalid device code response fields");
  }

  return {
    device_code: deviceCode,
    user_code: userCode,
    verification_uri: verificationUri,
    interval,
    expires_in: expiresIn,
  };
}

/**
 * Sleep that can be interrupted by an AbortSignal
 */
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(new Error("Login cancelled"));
      return;
    }

    const timeout = setTimeout(resolve, ms);

    signal?.addEventListener(
      "abort",
      () => {
        clearTimeout(timeout);
        reject(new Error("Login cancelled"));
      },
      { once: true },
    );
  });
}

async function pollForGitHubAccessToken(
  domain: string,
  deviceCode: string,
  intervalSeconds: number,
  expiresIn: number,
  signal?: AbortSignal,
) {
  const urls = getUrls(domain);
  const deadline = Date.now() + expiresIn * 1000;
  let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));

  while (Date.now() < deadline) {
    if (signal?.aborted) {
      throw new Error("Login cancelled");
    }

    const raw = await fetchJson(urls.accessTokenUrl, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        "User-Agent": "GitHubCopilotChat/0.35.0",
      },
      body: JSON.stringify({
        client_id: CLIENT_ID,
        device_code: deviceCode,
        grant_type: "urn:ietf:params:oauth:grant-type:device_code",
      }),
    });

    if (
      raw &&
      typeof raw === "object" &&
      typeof (raw as DeviceTokenSuccessResponse).access_token === "string"
    ) {
      return (raw as DeviceTokenSuccessResponse).access_token;
    }

    if (
      raw &&
      typeof raw === "object" &&
      typeof (raw as DeviceTokenErrorResponse).error === "string"
    ) {
      const err = (raw as DeviceTokenErrorResponse).error;
      if (err === "authorization_pending") {
        await abortableSleep(intervalMs, signal);
        continue;
      }

      if (err === "slow_down") {
        intervalMs += 5000;
        await abortableSleep(intervalMs, signal);
        continue;
      }

      throw new Error(`Device flow failed: ${err}`);
    }

    await abortableSleep(intervalMs, signal);
  }

  throw new Error("Device flow timed out");
}

/**
 * Refresh GitHub Copilot token
 */
export async function refreshGitHubCopilotToken(
  refreshToken: string,
  enterpriseDomain?: string,
): Promise<OAuthCredentials> {
  const domain = enterpriseDomain || "github.com";
  const urls = getUrls(domain);

  const raw = await fetchJson(urls.copilotTokenUrl, {
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${refreshToken}`,
      ...COPILOT_HEADERS,
    },
  });

  if (!raw || typeof raw !== "object") {
    throw new Error("Invalid Copilot token response");
  }

  const token = (raw as Record<string, unknown>).token;
  const expiresAt = (raw as Record<string, unknown>).expires_at;

  if (typeof token !== "string" || typeof expiresAt !== "number") {
    throw new Error("Invalid Copilot token response fields");
  }

  return {
    refresh: refreshToken,
    access: token,
    expires: expiresAt * 1000 - 5 * 60 * 1000,
    enterpriseUrl: enterpriseDomain,
  };
}

/**
 * Enable a model for the user's GitHub Copilot account.
 * This is required for some models (like Claude, Grok) before they can be used.
 */
async function enableGitHubCopilotModel(
  token: string,
  modelId: string,
  enterpriseDomain?: string,
): Promise<boolean> {
  const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
  const url = `${baseUrl}/models/${modelId}/policy`;

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
        ...COPILOT_HEADERS,
        "openai-intent": "chat-policy",
        "x-interaction-type": "chat-policy",
      },
      body: JSON.stringify({ state: "enabled" }),
    });
    return response.ok;
  } catch {
    return false;
  }
}

/**
 * Enable all known GitHub Copilot models that may require policy acceptance.
 * Called after successful login to ensure all models are available.
 */
async function enableAllGitHubCopilotModels(
  token: string,
  enterpriseDomain?: string,
  onProgress?: (model: string, success: boolean) => void,
): Promise<void> {
  const models = getModels("github-copilot");
  await Promise.all(
    models.map(async (model) => {
      const success = await enableGitHubCopilotModel(
        token,
        model.id,
        enterpriseDomain,
      );
      onProgress?.(model.id, success);
    }),
  );
}

/**
 * Login with GitHub Copilot OAuth (device code flow)
 *
 * @param options.onAuth - Callback with URL and optional instructions (user code)
 * @param options.onPrompt - Callback to prompt user for input
 * @param options.onProgress - Optional progress callback
 * @param options.signal - Optional AbortSignal for cancellation
 */
export async function loginGitHubCopilot(options: {
  onAuth: (url: string, instructions?: string) => void;
  onPrompt: (prompt: {
    message: string;
    placeholder?: string;
    allowEmpty?: boolean;
  }) => Promise<string>;
  onProgress?: (message: string) => void;
  signal?: AbortSignal;
}): Promise<OAuthCredentials> {
  const input = await options.onPrompt({
    message: "GitHub Enterprise URL/domain (blank for github.com)",
    placeholder: "company.ghe.com",
    allowEmpty: true,
  });

  if (options.signal?.aborted) {
    throw new Error("Login cancelled");
  }

  const trimmed = input.trim();
  const enterpriseDomain = normalizeDomain(input);
  if (trimmed && !enterpriseDomain) {
    throw new Error("Invalid GitHub Enterprise URL/domain");
  }
  const domain = enterpriseDomain || "github.com";

  const device = await startDeviceFlow(domain);
  options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);

  const githubAccessToken = await pollForGitHubAccessToken(
    domain,
    device.device_code,
    device.interval,
    device.expires_in,
    options.signal,
  );
  const credentials = await refreshGitHubCopilotToken(
    githubAccessToken,
    enterpriseDomain ?? undefined,
  );

  // Enable all models after successful login
  options.onProgress?.("Enabling models...");
  await enableAllGitHubCopilotModels(
    credentials.access,
    enterpriseDomain ?? undefined,
  );
  return credentials;
}

export const githubCopilotOAuthProvider: OAuthProviderInterface = {
  id: "github-copilot",
  name: "GitHub Copilot",

  async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
    return loginGitHubCopilot({
      onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
      onPrompt: callbacks.onPrompt,
      onProgress: callbacks.onProgress,
      signal: callbacks.signal,
    });
  },

  async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
    const creds = credentials as CopilotCredentials;
    return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl);
  },

  getApiKey(credentials: OAuthCredentials): string {
    return credentials.access;
  },

  modifyModels(
    models: Model<Api>[],
    credentials: OAuthCredentials,
  ): Model<Api>[] {
    const creds = credentials as CopilotCredentials;
    const domain = creds.enterpriseUrl
      ? (normalizeDomain(creds.enterpriseUrl) ?? undefined)
      : undefined;
    const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain);
    return models.map((m) =>
      m.provider === "github-copilot" ? { ...m, baseUrl } : m,
    );
  },
};
