// vsix-fetch.mjs — Fetch VSIX packages from VS Code Marketplace, OpenVSX, or direct URL.
// Zero dependencies. Streams to memory with strict size cap, computes SHA-256 on the fly.
//
// Defenses:
//   - HTTPS only (no plain HTTP, no protocol downgrade on redirects)
//   - 30s total timeout (network + body)
//   - 50MB max compressed size (abort streaming when exceeded)
//   - TLS verification always enabled
//   - No follow on cross-origin redirects (same registered host only)
//   - Marketplace endpoint is undocumented but stable; documented in
//     knowledge/marketplace-api-notes.md.

import { createHash } from 'node:crypto';

const MAX_VSIX_BYTES = 50 * 1024 * 1024; // 50MB
const FETCH_TIMEOUT_MS = 30_000;

const MARKETPLACE_HOSTS = new Set([
  'marketplace.visualstudio.com',
]);
const OPENVSX_HOSTS = new Set([
  'open-vsx.org',
]);

// JetBrains Marketplace — explicit 3-host allowlist per research brief §3.
// Do NOT use a wildcard suffix match on the jetbrains.com apex — any subdomain
// (evil.jetbrains.com via subdomain takeover) would pass and constitute a
// security regression. Use exact-match Set membership only.
const JETBRAINS_ALLOWED_HOSTS = new Set([
  'plugins.jetbrains.com',
  'downloads.marketplace.jetbrains.com',
  'cache-redirector.jetbrains.com',
]);

const MAX_JETBRAINS_META_BYTES = 8 * 1024; // 8KB cap for JSON metadata responses

/**
 * Detect what kind of URL this is.
 * @param {string} url
 * @returns {{ type: 'marketplace'|'openvsx'|'vsix'|'github'|'unknown', publisher?: string, name?: string, version?: string }}
 */
export function detectUrlType(url) {
  let u;
  try { u = new URL(url); } catch { return { type: 'unknown' }; }
  if (u.protocol !== 'https:') return { type: 'unknown' };

  // VS Code Marketplace: items?itemName=publisher.name
  if (MARKETPLACE_HOSTS.has(u.hostname)) {
    const itemName = u.searchParams.get('itemName');
    if (!itemName || !itemName.includes('.')) return { type: 'unknown' };
    const dot = itemName.indexOf('.');
    const publisher = itemName.slice(0, dot);
    const name = itemName.slice(dot + 1);
    if (!publisher || !name) return { type: 'unknown' };
    return { type: 'marketplace', publisher, name };
  }

  // OpenVSX: /extension/{publisher}/{name}[/{version}]
  if (OPENVSX_HOSTS.has(u.hostname)) {
    const parts = u.pathname.split('/').filter(Boolean);
    if (parts[0] !== 'extension' || parts.length < 3) return { type: 'unknown' };
    const [, publisher, name, version] = parts;
    return { type: 'openvsx', publisher, name, version: version || null };
  }

  // GitHub repo (not supported in v6.4.0)
  if (u.hostname === 'github.com') {
    return { type: 'github' };
  }

  // JetBrains Marketplace: /plugin/7973-intellivue (numeric ID + optional slug)
  // or /plugin/download?pluginId=xmlId (direct download by xmlId).
  if (u.hostname === 'plugins.jetbrains.com') {
    // Download-by-xmlId path: /plugin/download?pluginId=<xmlId>
    if (u.pathname === '/plugin/download') {
      const xmlId = u.searchParams.get('pluginId');
      if (xmlId && /^[A-Za-z0-9._-]+$/.test(xmlId)) {
        const version = u.searchParams.get('version');
        return { type: 'jetbrains', numericId: null, xmlId, version: version || null };
      }
      return { type: 'unknown' };
    }
    // Plugin page: /plugin/<numericId>[-<slug>]
    const m = u.pathname.match(/^\/plugin\/(\d+)(?:-[^/]+)?\/?$/);
    if (m) {
      return { type: 'jetbrains', numericId: m[1], xmlId: null, version: null };
    }
    return { type: 'unknown' };
  }

  // Direct .vsix link
  if (u.pathname.toLowerCase().endsWith('.vsix')) {
    return { type: 'vsix' };
  }

  return { type: 'unknown' };
}

function isAllowedHost(hostname, originalType) {
  if (originalType === 'marketplace') {
    // Marketplace API redirects to vsassets cdn (vstmrblob).
    return MARKETPLACE_HOSTS.has(hostname)
      || hostname.endsWith('.gallerycdn.vsassets.io')
      || hostname.endsWith('.vsassets.io');
  }
  if (originalType === 'openvsx') {
    return OPENVSX_HOSTS.has(hostname)
      || hostname === 'openvsxorg.blob.core.windows.net'
      || hostname.endsWith('.openvsx.org');
  }
  if (originalType === 'jetbrains') {
    // Strict 3-host Set — exact match only; no wildcard suffix check.
    return JETBRAINS_ALLOWED_HOSTS.has(hostname);
  }
  // Direct vsix: only same host as the original URL (caller enforces).
  return true;
}

/**
 * Stream the body of a Response into a Buffer with size cap and SHA-256.
 * Aborts via the AbortController if cap is exceeded.
 * @param {Response} res
 * @param {AbortController} controller
 * @returns {Promise<{ buffer: Buffer, sha256: string, size: number }>}
 */
async function readBodyCapped(res, controller) {
  if (!res.body) throw new Error('response has no body');
  const hash = createHash('sha256');
  const chunks = [];
  let size = 0;
  const reader = res.body.getReader();
  // eslint-disable-next-line no-constant-condition
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    if (!value) continue;
    size += value.byteLength;
    if (size > MAX_VSIX_BYTES) {
      try { controller.abort(); } catch {}
      throw new Error(`VSIX exceeds maximum size (${MAX_VSIX_BYTES} bytes)`);
    }
    hash.update(value);
    chunks.push(Buffer.from(value));
  }
  return { buffer: Buffer.concat(chunks), sha256: hash.digest('hex'), size };
}

async function httpsFetch(url, init, originalType) {
  const u = new URL(url);
  if (u.protocol !== 'https:') {
    throw new Error(`refusing non-HTTPS URL: ${url}`);
  }
  if (!isAllowedHost(u.hostname, originalType)) {
    throw new Error(`refusing redirect to disallowed host: ${u.hostname}`);
  }
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(url, {
      ...init,
      signal: controller.signal,
      // Manual redirect handling so we can validate every hop.
      redirect: 'manual',
    });
    if (res.status >= 300 && res.status < 400) {
      const loc = res.headers.get('location');
      if (!loc) throw new Error(`HTTP ${res.status} without Location header`);
      const next = new URL(loc, url).toString();
      // Cap redirect depth via init counter.
      const depth = (init && init.__depth) || 0;
      if (depth >= 5) throw new Error('too many redirects');
      return httpsFetch(next, { ...init, __depth: depth + 1, method: 'GET', body: undefined }, originalType);
    }
    if (!res.ok) {
      throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
    }
    const out = await readBodyCapped(res, controller);
    return { ...out, finalUrl: url };
  } finally {
    clearTimeout(timer);
  }
}

/**
 * Fetch a VSIX from the VS Code Marketplace by publisher.name.
 * Uses the undocumented but stable gallery API:
 *   POST https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery
 * The response includes a download URL; we then GET that.
 * Falls back to the well-known direct URL pattern if extensionquery is not usable.
 *
 * @param {string} publisher
 * @param {string} name
 * @returns {Promise<{ buffer: Buffer, sha256: string, size: number, finalUrl: string, source: object }>}
 */
export async function fetchMarketplaceVsix(publisher, name) {
  // Direct download URL pattern (well-known, used by `vsce` and `code` itself):
  //   https://{publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/{publisher}/extension/{name}/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage
  const directUrl =
    `https://${encodeURIComponent(publisher)}.gallery.vsassets.io` +
    `/_apis/public/gallery/publisher/${encodeURIComponent(publisher)}` +
    `/extension/${encodeURIComponent(name)}/latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage`;

  const out = await httpsFetch(directUrl, { method: 'GET' }, 'marketplace');
  return {
    ...out,
    source: { type: 'marketplace', publisher, name, requestedUrl: directUrl },
  };
}

/**
 * Fetch a VSIX from OpenVSX. If version is omitted, hits the "latest" endpoint to resolve.
 * Direct file pattern:
 *   https://open-vsx.org/api/{pub}/{name}/{version}/file/{pub}.{name}-{version}.vsix
 * Without version we hit:
 *   https://open-vsx.org/api/{pub}/{name}/latest
 * to resolve, then download.
 *
 * @param {string} publisher
 * @param {string} name
 * @param {string|null} version
 */
export async function fetchOpenVsxVsix(publisher, name, version) {
  let resolvedVersion = version;
  if (!resolvedVersion) {
    const meta = await httpsFetch(
      `https://open-vsx.org/api/${encodeURIComponent(publisher)}/${encodeURIComponent(name)}/latest`,
      { method: 'GET', headers: { Accept: 'application/json' } },
      'openvsx',
    );
    let info;
    try { info = JSON.parse(meta.buffer.toString('utf8')); }
    catch { throw new Error('OpenVSX returned non-JSON metadata'); }
    if (!info || typeof info.version !== 'string') {
      throw new Error('OpenVSX metadata missing version');
    }
    resolvedVersion = info.version;
  }

  const url =
    `https://open-vsx.org/api/${encodeURIComponent(publisher)}/${encodeURIComponent(name)}` +
    `/${encodeURIComponent(resolvedVersion)}/file/` +
    `${encodeURIComponent(publisher)}.${encodeURIComponent(name)}-${encodeURIComponent(resolvedVersion)}.vsix`;

  const out = await httpsFetch(url, { method: 'GET' }, 'openvsx');
  return {
    ...out,
    source: { type: 'openvsx', publisher, name, version: resolvedVersion, requestedUrl: url },
  };
}

/**
 * Fetch a VSIX from a direct URL.
 * @param {string} url
 */
export async function fetchDirectVsix(url) {
  const u = new URL(url);
  if (u.protocol !== 'https:') {
    throw new Error('direct VSIX URL must be HTTPS');
  }
  // Track host so redirects must stay on the same registered host.
  const sourceHost = u.hostname;
  const out = await httpsFetchSameHost(url, sourceHost);
  return {
    ...out,
    source: { type: 'vsix', requestedUrl: url },
  };
}

async function httpsFetchSameHost(url, sourceHost) {
  const u = new URL(url);
  if (u.protocol !== 'https:') {
    throw new Error(`refusing non-HTTPS URL: ${url}`);
  }
  if (u.hostname !== sourceHost) {
    throw new Error(`refusing cross-host redirect: ${u.hostname} != ${sourceHost}`);
  }
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
  try {
    const res = await fetch(url, { signal: controller.signal, redirect: 'manual' });
    if (res.status >= 300 && res.status < 400) {
      const loc = res.headers.get('location');
      if (!loc) throw new Error(`HTTP ${res.status} without Location header`);
      const next = new URL(loc, url).toString();
      return httpsFetchSameHost(next, sourceHost);
    }
    if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
    const out = await readBodyCapped(res, controller);
    return { ...out, finalUrl: url };
  } finally {
    clearTimeout(timer);
  }
}

/**
 * Fetch a JetBrains plugin from plugins.jetbrains.com.
 *
 * Two paths:
 *   1. `xmlId` present → direct download:
 *      GET https://plugins.jetbrains.com/plugin/download?pluginId=<xmlId>[&version=<v>]
 *      Redirects through cache-redirector.jetbrains.com / downloads.marketplace.jetbrains.com.
 *   2. Only `numericId` present → first resolve to xmlId via the public API:
 *      GET https://plugins.jetbrains.com/api/plugins/<numericId>  (JSON, capped at 8KB)
 *      Then follow path 1.
 *
 * All requests go through `httpsFetch(url, init, 'jetbrains')` which validates
 * every redirect hop against `JETBRAINS_ALLOWED_HOSTS`.
 *
 * @param {{numericId?: string|null, xmlId?: string|null, version?: string|null}} params
 * @returns {Promise<{buffer:Buffer, sha256:string, size:number, finalUrl:string, source:object}>}
 */
export async function fetchJetBrainsPlugin(params) {
  const { numericId = null, xmlId: initialXmlId = null, version = null } = params || {};
  let xmlId = initialXmlId;

  // Resolve numericId → xmlId if needed.
  if (!xmlId) {
    if (!numericId) throw new Error('fetchJetBrainsPlugin: need xmlId or numericId');
    if (!/^\d+$/.test(String(numericId))) {
      throw new Error(`fetchJetBrainsPlugin: invalid numericId: ${numericId}`);
    }
    const metaUrl = `https://plugins.jetbrains.com/api/plugins/${encodeURIComponent(numericId)}`;
    const meta = await httpsFetch(
      metaUrl,
      { method: 'GET', headers: { Accept: 'application/json' } },
      'jetbrains',
    );
    if (meta.size > MAX_JETBRAINS_META_BYTES) {
      throw new Error(`JetBrains metadata exceeds ${MAX_JETBRAINS_META_BYTES} bytes`);
    }
    let info;
    try { info = JSON.parse(meta.buffer.toString('utf8')); }
    catch { throw new Error('JetBrains API returned non-JSON metadata'); }
    if (!info || typeof info.xmlId !== 'string' || !info.xmlId) {
      throw new Error('JetBrains API metadata missing xmlId');
    }
    xmlId = info.xmlId;
  }

  // Validate xmlId shape before putting it in a URL.
  if (!/^[A-Za-z0-9._-]+$/.test(xmlId)) {
    throw new Error(`fetchJetBrainsPlugin: suspicious xmlId: ${xmlId}`);
  }

  let downloadUrl = `https://plugins.jetbrains.com/plugin/download?pluginId=${encodeURIComponent(xmlId)}`;
  if (version) {
    if (!/^[A-Za-z0-9._+-]+$/.test(version)) {
      throw new Error(`fetchJetBrainsPlugin: suspicious version: ${version}`);
    }
    downloadUrl += `&version=${encodeURIComponent(version)}`;
  }

  const out = await httpsFetch(downloadUrl, { method: 'GET' }, 'jetbrains');
  return {
    ...out,
    source: {
      type: 'jetbrains',
      numericId,
      xmlId,
      version,
      requestedUrl: downloadUrl,
    },
  };
}

/**
 * High-level dispatch that covers both VS Code family (VSIX) and JetBrains.
 * New callers should prefer this over `fetchVsixFromUrl` — it delegates based
 * on detected URL type. Existing `fetchVsixFromUrl` callers are preserved.
 * @param {string} url
 */
export async function fetchPluginFromUrl(url) {
  const detected = detectUrlType(url);
  if (detected.type === 'jetbrains') {
    return fetchJetBrainsPlugin({
      numericId: detected.numericId,
      xmlId: detected.xmlId,
      version: detected.version,
    });
  }
  return fetchVsixFromUrl(url);
}

/**
 * High-level dispatch. Detects URL type and returns a fetched VSIX.
 * @param {string} url
 * @returns {Promise<{ buffer: Buffer, sha256: string, size: number, finalUrl: string, source: object }>}
 */
export async function fetchVsixFromUrl(url) {
  const detected = detectUrlType(url);
  switch (detected.type) {
    case 'marketplace':
      return fetchMarketplaceVsix(detected.publisher, detected.name);
    case 'openvsx':
      return fetchOpenVsxVsix(detected.publisher, detected.name, detected.version);
    case 'vsix':
      return fetchDirectVsix(url);
    case 'jetbrains':
      // Route via the JetBrains-specific fetcher so callers that accidentally
      // hit the VSIX dispatch with a JB URL still work. Real orchestration
      // should use `fetchPluginFromUrl` or check `detectUrlType` first.
      return fetchJetBrainsPlugin({
        numericId: detected.numericId,
        xmlId: detected.xmlId,
        version: detected.version,
      });
    case 'github':
      throw new Error('GitHub repo URLs are not supported in v6.4.0 (would require build step). Use Marketplace, OpenVSX, or a direct .vsix URL.');
    default:
      throw new Error(`unsupported URL: ${url}`);
  }
}

export const __testing = {
  MAX_VSIX_BYTES,
  MAX_JETBRAINS_META_BYTES,
  FETCH_TIMEOUT_MS,
  JETBRAINS_ALLOWED_HOSTS,
  isAllowedHost,
  readBodyCapped,
};
