/**
 * Shared proxy URL parsing for Playwright and Puppeteer wrappers.
 */

import { binarySupportsHttpProxyInlineAuth } from "./config.js";

export interface ParsedProxy {
  server: string;
  username?: string;
  password?: string;
}

/**
 * Prepend http:// to schemeless proxy URLs so parsers can extract hostname.
 * Used by geoip resolution which only needs a valid hostname, not auth fields.
 */
export function ensureProxyScheme(proxyUrl: string): string {
  return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
}

/**
 * Parse a proxy URL, extracting credentials into separate fields.
 *
 * Handles authenticated proxy URLs by extracting credentials into separate
 * fields. Also handles: no credentials, URL-encoded special chars, socks5://,
 * missing port, and bare proxy strings without a scheme.
 */
/** Proxy dict shape accepted by Playwright/Puppeteer wrappers. */
export type ProxyDict = { server: string; bypass?: string; username?: string; password?: string };

/** Result of resolveProxyConfig — either Playwright dict OR Chrome arg, never both. */
export interface ProxyConfig {
  /** Playwright proxy option (for HTTP proxies). */
  proxyOption?: ParsedProxy;
  /** Chrome CLI args (for SOCKS5 proxies, e.g. ["--proxy-server=socks5://..."]). */
  proxyArgs: string[];
}

/**
 * Check if a proxy uses the SOCKS5 protocol.
 */
export function isSocksProxy(proxy: string | ProxyDict | undefined | null): boolean {
  if (!proxy) return false;
  const url = typeof proxy === "string" ? proxy : proxy.server;
  return /^socks5h?:\/\//i.test(url);
}

/**
 * Build a SOCKS URL from already-percent-encoded credentials and a host suffix.
 *
 * `encPass === null` means no password (no colon in userinfo). Empty string
 * means present-but-empty (colon preserved).
 */
function assembleSocksUrl(
  scheme: string,
  encUser: string,
  encPass: string | null,
  hostAndRest: string,
): string {
  let userinfo: string;
  if (encPass !== null) {
    userinfo = `${encUser}:${encPass}@`;
  } else if (encUser) {
    userinfo = `${encUser}@`;
  } else {
    userinfo = "";
  }
  return `${scheme}://${userinfo}${hostAndRest}`;
}

/**
 * Lenient percent-decode that handles malformed escapes gracefully, matching
 * Python's ``urllib.parse.unquote``: valid ``%XX`` sequences are decoded,
 * bare ``%`` not followed by two hex digits is left as a literal ``%``.
 */
function lenientDecodeURIComponent(s: string): string {
  return s.replace(/%([0-9A-Fa-f]{2})|%/g, (_match, hex) =>
    hex ? String.fromCharCode(parseInt(hex, 16)) : "%",
  );
}

/**
 * Reconstruct a SOCKS5 URL with inline credentials from a proxy dict.
 */
export function reconstructSocksUrl(proxy: ProxyDict): string {
  const url = new URL(proxy.server);
  if (proxy.username) {
    url.username = encodeURIComponent(proxy.username);
    if (proxy.password) url.password = encodeURIComponent(proxy.password);
  }
  return url.href.replace(/\/$/, "");
}

/**
 * Re-encode credentials in a SOCKS5 URL string so Chromium's parser doesn't
 * truncate them at special chars like '='. Idempotent: pre-encoded input stays
 * the same (decoded then re-encoded).
 *
 * Parsing is done manually rather than via `new URL` + setters, because WHATWG
 * URL's username/password setters re-encode `%` on assignment, causing
 * double-encoding when we round-trip decode-then-encode.
 *
 * On any unexpected failure, logs a warning and returns the original string
 * so Chromium's own error handling can surface the real problem.
 */
export function normalizeSocksStringUrl(urlStr: string): string {
  // Split userinfo from host at the LAST '@' (RFC 3986), so a raw '@' inside
  // a password like `socks5://user:p@ss@host:1080` parses correctly. Matches
  // Python urlparse's rpartition('@') behavior.
  const schemeMatch = urlStr.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
  if (!schemeMatch) return urlStr;
  const [, scheme, rest] = schemeMatch;
  const hostStart = rest.search(/[/?#]/);
  const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
  const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
  const atIdx = authority.lastIndexOf("@");
  if (atIdx === -1) return urlStr;  // no creds
  const userinfo = authority.slice(0, atIdx);
  const hostPart = authority.slice(atIdx + 1);
  // Validate port (matches Python's urlparse().port ValueError guard).
  // Extract port after last ':' — but skip IPv6 brackets (e.g. [::1]:1080).
  const bracketEnd = hostPart.lastIndexOf("]");
  const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
  if (portColonIdx !== -1) {
    const portStr = hostPart.slice(portColonIdx + 1);
    if (portStr && !/^\d+$/.test(portStr)) {
      console.warn(`[cloakbrowser] Malformed SOCKS5 proxy URL, passing through unchanged: invalid port`);
      return urlStr;
    }
  }
  const hostAndRest = hostPart + suffix;
  const colonIdx = userinfo.indexOf(":");
  const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
  const hasPassword = colonIdx !== -1;
  const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
  try {
    const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
    const encPass = hasPassword
      ? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
      : null;
    const normalized = assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
    // Compare credentials, not the full URL: keeps the log condition focused
    // on real encoding work, not cosmetic differences (parity with the Python
    // implementation, which has to skip urlparse's hostname lowercasing).
    const credsChanged = encUser !== rawUserEnc
      || (hasPassword ? encPass !== rawPassEnc : false);
    if (credsChanged) {
      console.info(
        "[cloakbrowser] Auto URL-encoded SOCKS5 proxy credentials (special " +
        "characters detected). Pre-encode the URL to suppress this notice.",
      );
    }
    return normalized;
  } catch (e) {
    console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
    return urlStr;
  }
}

function hasCredentials(proxy: string | ProxyDict): boolean {
  if (typeof proxy === "string") return proxy.includes("@");
  return !!proxy.username;
}

/**
 * Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
 */
export function reconstructHttpUrl(proxy: ProxyDict): string {
  if (!proxy.username) return proxy.server;
  const url = new URL(ensureProxyScheme(proxy.server));
  url.username = encodeURIComponent(proxy.username);
  if (proxy.password) url.password = encodeURIComponent(proxy.password);
  return url.href.replace(/\/$/, "");
}

/**
 * Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
 * Same pattern as normalizeSocksStringUrl.
 */
export function normalizeHttpStringUrl(urlStr: string): string {
  const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
  const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
  if (!schemeMatch) return normalized;
  const [, scheme, rest] = schemeMatch;
  const hostStart = rest.search(/[/?#]/);
  const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
  const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
  const atIdx = authority.lastIndexOf("@");
  if (atIdx === -1) return normalized;
  const userinfo = authority.slice(0, atIdx);
  const hostPart = authority.slice(atIdx + 1);
  const bracketEnd = hostPart.lastIndexOf("]");
  const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
  if (portColonIdx !== -1) {
    const portStr = hostPart.slice(portColonIdx + 1);
    if (portStr && !/^\d+$/.test(portStr)) {
      console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
      return normalized;
    }
  }
  const hostAndRest = hostPart + suffix;
  const colonIdx = userinfo.indexOf(":");
  const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
  const hasPassword = colonIdx !== -1;
  const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
  try {
    const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
    const encPass = hasPassword
      ? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
      : null;
    let userinfoPart: string;
    if (encPass !== null) {
      userinfoPart = `${encUser}:${encPass}@`;
    } else if (encUser) {
      userinfoPart = `${encUser}@`;
    } else {
      userinfoPart = "";
    }
    const result = `${scheme}://${userinfoPart}${hostAndRest}`;
    const credsChanged = encUser !== rawUserEnc
      || (hasPassword ? encPass !== rawPassEnc : false);
    if (credsChanged) {
      console.info(
        "[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
        "characters detected). Pre-encode the URL to suppress this notice.",
      );
    }
    return result;
  } catch (e) {
    console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
    return normalized;
  }
}

/**
 * Resolve proxy into Playwright option and/or Chrome args.
 *
 * Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
 * passed via Chrome's --proxy-server flag with inline credentials, bypassing
 * Playwright's CDP auth interceptor which breaks on some proxies (#182).
 */
export function resolveProxyConfig(
  proxy: string | ProxyDict | undefined,
  browserVersion?: string,
  licenseKey?: string,
  releaseChannel?: string,
): ProxyConfig {
  if (!proxy) return { proxyArgs: [] };

  if (isSocksProxy(proxy)) {
    // SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server.
    if (typeof proxy === "string") {
      // Re-encode creds to work around Chromium parser truncating passwords
      // at '=' and other special chars (#157).
      return { proxyArgs: [`--proxy-server=${normalizeSocksStringUrl(proxy)}`] };
    }
    const socksUrl = reconstructSocksUrl(proxy);
    const args = [`--proxy-server=${socksUrl}`];
    if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
    return { proxyArgs: args };
  }

  // HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth:
  // use Chrome's native proxy authentication path instead of Playwright's CDP
  // auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse
  // inline credentials, so they fall through to the Playwright proxy dict below.
  if (
    hasCredentials(proxy) &&
    binarySupportsHttpProxyInlineAuth(licenseKey, browserVersion, releaseChannel)
  ) {
    if (typeof proxy === "string") {
      return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
    }
    const httpUrl = reconstructHttpUrl(proxy);
    const args = [`--proxy-server=${httpUrl}`];
    if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
    return { proxyArgs: args };
  }

  // HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
  if (typeof proxy === "string") {
    return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
  }
  return { proxyOption: proxy as ParsedProxy, proxyArgs: [] };
}

export function parseProxyUrl(proxy: string): ParsedProxy {
  let url: URL;
  // Bare format: "user:pass@host:port" — new URL() throws without a scheme.
  const normalized =
    proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy;
  try {
    url = new URL(normalized);
  } catch {
    // Not a parseable URL (e.g. bare "host:port") — pass through as-is
    return { server: proxy };
  }

  if (!url.username) {
    return { server: proxy };
  }

  // Rebuild server URL without credentials
  const server = `${url.protocol}//${url.hostname}${url.port ? `:${url.port}` : ""}`;

  const result: ParsedProxy = {
    server,
    username: decodeURIComponent(url.username),
  };
  if (url.password) {
    result.password = decodeURIComponent(url.password);
  }

  return result;
}
