'use strict';

const net = require('node:net');

const DEFAULT_ALLOWED_PROTOCOLS = ['http:', 'https:'];
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);

class SsrfGuardError extends Error {
  constructor(message, details = {}) {
    super(message);
    this.name = 'SsrfGuardError';
    this.code = 'ERR_WALLE_SSRF_GUARD';
    this.details = details;
  }
}

function validateSafeUrl(input, options = {}) {
  const {
    allowLocal = false,
    allowedProtocols = DEFAULT_ALLOWED_PROTOCOLS,
    resolvedAddresses = [],
  } = options;
  const parsed = parseUrl(input);
  if (!parsed.ok) return parsed;

  const protocolAllowed = allowedProtocols.includes(parsed.url.protocol);
  if (!protocolAllowed) {
    return failure('blocked-protocol', `URL protocol is not allowed: ${parsed.url.protocol}`, parsed.url);
  }

  const host = normalizeHostname(parsed.url.hostname);
  if (!host) return failure('missing-hostname', 'URL must include a hostname', parsed.url);

  if (!allowLocal) {
    if (isLocalHostname(host)) return failure('local-hostname', `URL hostname is local: ${host}`, parsed.url);
    if (isPrivateAddress(host)) return failure('private-address', `URL hostname resolves to private address: ${host}`, parsed.url);
    for (const address of resolvedAddresses || []) {
      if (isPrivateAddress(address)) {
        return failure('private-resolved-address', `URL hostname resolved to private address: ${address}`, parsed.url, { address });
      }
    }
  }

  return {
    ok: true,
    url: parsed.url.toString(),
    protocol: parsed.url.protocol,
    hostname: host,
  };
}

async function validateSafeUrlAsync(input, options = {}) {
  const initial = validateSafeUrl(input, options);
  if (!initial.ok) return initial;
  if (options.allowLocal || !options.resolveHostname) return initial;
  if (net.isIP(initial.hostname)) return initial;

  let resolved = [];
  try {
    const result = await options.resolveHostname(initial.hostname);
    resolved = Array.isArray(result) ? result : [result];
  } catch (err) {
    return failure('dns-resolution-failed', `Could not resolve URL hostname: ${initial.hostname}`, initial.url, {
      error: err.message,
    });
  }

  const addresses = resolved
    .map(item => typeof item === 'string' ? item : item && item.address)
    .filter(Boolean);
  return validateSafeUrl(initial.url, { ...options, resolvedAddresses: addresses });
}

function createSafeFetch(options = {}) {
  const {
    fetchImpl = globalThis.fetch,
    maxRedirects = 5,
    ...guardOptions
  } = options;
  if (typeof fetchImpl !== 'function') throw new Error('fetchImpl is required');

  return async function safeFetch(input, init = {}) {
    let currentUrl = inputToUrl(input);
    let redirects = 0;

    while (true) {
      await assertSafeUrl(currentUrl, guardOptions);
      const response = await fetchImpl(currentUrl, { ...init, redirect: 'manual' });
      if (response && response.url && response.url !== currentUrl) {
        await assertSafeUrl(response.url, guardOptions);
      }

      if (init.redirect === 'manual' || !isRedirectResponse(response)) return response;
      const location = response.headers && typeof response.headers.get === 'function'
        ? response.headers.get('location')
        : null;
      if (!location) return response;
      if (init.redirect === 'error') {
        throw new SsrfGuardError('Redirect received while redirect mode is error', { url: currentUrl, location });
      }
      if (redirects >= maxRedirects) {
        throw new SsrfGuardError('Too many redirects while fetching URL', { url: currentUrl, maxRedirects });
      }
      redirects += 1;
      currentUrl = new URL(location, currentUrl).toString();
      if (response.body && typeof response.body.cancel === 'function') {
        try { await response.body.cancel(); } catch {}
      }
    }
  };
}

async function assertSafeUrl(input, options = {}) {
  const result = await validateSafeUrlAsync(input, options);
  if (!result.ok) throw new SsrfGuardError(result.message, result);
  return result;
}

function parseUrl(input) {
  try {
    const url = input instanceof URL ? input : new URL(inputToUrl(input));
    return { ok: true, url };
  } catch (err) {
    return {
      ok: false,
      reason: 'invalid-url',
      message: `Invalid URL: ${err.message}`,
      url: String(input || ''),
    };
  }
}

function inputToUrl(input) {
  if (input instanceof URL) return input.toString();
  if (typeof Request !== 'undefined' && input instanceof Request) return input.url;
  if (input && typeof input === 'object' && typeof input.url === 'string') return input.url;
  return String(input || '');
}

function isRedirectResponse(response) {
  return response && REDIRECT_STATUSES.has(response.status);
}

function normalizeHostname(hostname) {
  return String(hostname || '')
    .trim()
    .replace(/^\[/, '')
    .replace(/\]$/, '')
    .replace(/\.$/, '')
    .split('%')[0]
    .toLowerCase();
}

function isLocalHostname(hostname) {
  const host = normalizeHostname(hostname);
  return host === 'localhost' ||
    host.endsWith('.localhost') ||
    host === 'localdomain' ||
    host.endsWith('.localdomain') ||
    host.endsWith('.local');
}

function isPrivateAddress(address) {
  const value = normalizeHostname(address);
  const ipVersion = net.isIP(value);
  if (ipVersion === 4) return isPrivateIpv4(value);
  if (ipVersion === 6) return isPrivateIpv6(value);
  return false;
}

function isPrivateIpv4(address) {
  const parts = address.split('.').map(part => Number(part));
  if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return false;
  const [a, b] = parts;
  if (a === 0 || a === 10 || a === 127) return true;
  if (a === 100 && b >= 64 && b <= 127) return true;
  if (a === 169 && b === 254) return true;
  if (a === 172 && b >= 16 && b <= 31) return true;
  if (a === 192 && (b === 0 || b === 168)) return true;
  if (a === 198 && (b === 18 || b === 19)) return true;
  if (a >= 224) return true;
  return false;
}

function isPrivateIpv6(address) {
  const ip = normalizeHostname(address);
  if (ip === '::' || ip === '::1') return true;
  const mapped = ip.match(/^::ffff:(.+)$/);
  if (mapped) {
    const mappedIpv4 = ipv4FromMappedIpv6(mapped[1]);
    if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
  }
  const first = parseInt(ip.split(':')[0] || '0', 16);
  if (!Number.isFinite(first)) return false;
  if ((first & 0xfe00) === 0xfc00) return true; // unique local fc00::/7
  if ((first & 0xffc0) === 0xfe80) return true; // link-local fe80::/10
  if ((first & 0xff00) === 0xff00) return true; // multicast ff00::/8
  return false;
}

function ipv4FromMappedIpv6(value) {
  if (net.isIP(value) === 4) return value;
  const parts = value.split(':');
  if (parts.length !== 2) return null;
  const high = parseInt(parts[0] || '0', 16);
  const low = parseInt(parts[1] || '0', 16);
  if (!Number.isInteger(high) || !Number.isInteger(low) || high < 0 || high > 0xffff || low < 0 || low > 0xffff) {
    return null;
  }
  return [
    (high >> 8) & 0xff,
    high & 0xff,
    (low >> 8) & 0xff,
    low & 0xff,
  ].join('.');
}

function failure(reason, message, url, details = {}) {
  return {
    ok: false,
    reason,
    message,
    url: url instanceof URL ? url.toString() : String(url || ''),
    ...details,
  };
}

module.exports = {
  SsrfGuardError,
  assertSafeUrl,
  createSafeFetch,
  isLocalHostname,
  isPrivateAddress,
  validateSafeUrl,
  validateSafeUrlAsync,
};
