// URL Domain Signal Providers (v3)
// Typed features for typosquatting / combo-squatting (se-typosquatting-v3) and
// lookalike domains (url-lookalike-domain-v3). Ported from the legacy
// typosquatting + lookalike-domain detectors. Providers read target.url only
// and return { value, metadata? } — no decisions.
//
// Every provider self-suppresses when the page's registrable domain (eTLD+1) is
// itself a known brand — so visiting google.com or accounts.google.com never
// flags. This kills the legacy detectors' biggest FP (subdomain of the real
// brand reading as "impersonation").

/**
 * @param {import('../../core/signal-provider.js').SignalProviderRegistry} registry
 */
export function registerUrlDomainSignals(registry) {
  // ── url.punycode ──────────────────────────────
  registry.register('url', 'punycode', (target) => {
    const host = hostnameOf(target.url);
    if (!host || isKnownBrand(host)) return { value: false };
    const hit = host.startsWith('xn--') || host.includes('.xn--');
    return { value: hit, metadata: hit ? { host } : null };
  }, { returnType: 'boolean', scope: 'frame', description: 'Punycode (xn--) hostname' });

  // ── url.mixed_scripts ─────────────────────────
  registry.register('url', 'mixed_scripts', (target) => {
    const host = hostnameOf(target.url);
    if (!host || isKnownBrand(host)) return { value: false };
    return { value: hasMixedScripts(host), metadata: { host } };
  }, { returnType: 'boolean', scope: 'frame', description: 'Hostname mixes Latin with another script' });

  // ── url.homoglyph ─────────────────────────────
  // Non-ASCII lookalike letters only (Cyrillic/Greek/Turkish). Digit homoglyphs
  // (0→o, 1→l) are deliberately NOT counted — they FP on every domain with a
  // digit (1password.com, office365.com, …).
  registry.register('url', 'homoglyph', (target) => {
    const host = hostnameOf(target.url);
    if (!host || isKnownBrand(host)) return { value: false };
    const chars = detectHomoglyphs(host);
    return { value: chars.length > 0, metadata: chars.length ? { host, chars } : null };
  }, { returnType: 'boolean', scope: 'frame', description: 'Hostname contains non-ASCII lookalike letters' });

  // ── url.brand_subdomain_impersonation ─────────
  // A brand domain appears as a non-terminal label group (google.com.evil.com).
  registry.register('url', 'brand_subdomain_impersonation', (target) => {
    const host = hostnameOf(target.url);
    if (!host || isKnownBrand(host)) return { value: false };
    for (const brand of BRAND_DOMAINS) {
      if (host.startsWith(brand + '.') || host.includes('.' + brand + '.')) {
        return { value: true, metadata: { host, brand } };
      }
    }
    return { value: false };
  }, { returnType: 'boolean', scope: 'frame', description: 'Known brand domain used as a deceptive subdomain' });

  // ── url.combosquat ────────────────────────────
  // Registrable domain combines a brand with a lure keyword (paypal-verify.com).
  registry.register('url', 'combosquat', (target) => {
    const host = hostnameOf(target.url);
    if (!host || isKnownBrand(host)) return { value: false };
    const sld = sldOf(host);
    if (!sld) return { value: false };
    for (const brandDomain of BRAND_DOMAINS) {
      const brand = brandDomain.split('.')[0];
      if (brand.length < 4) continue;
      if (!sld.includes(brand)) continue;
      if (sld === brand) continue; // exact brand SLD is not a combo
      for (const kw of SUSPICIOUS_KEYWORDS) {
        if (sld === `${brand}-${kw}` || sld === `${brand}${kw}` ||
            sld === `${kw}-${brand}` || sld === `${kw}${brand}` ||
            sld.includes(`${brand}-${kw}`) || sld.includes(`${kw}-${brand}`)) {
          return { value: true, metadata: { host, brand, keyword: kw } };
        }
      }
    }
    return { value: false };
  }, { returnType: 'boolean', scope: 'frame', description: 'Brand + lure-keyword combo-squat domain' });

  // ── url.typosquat ─────────────────────────────
  // Single-edit near-miss of a brand SLD (keyboard adjacency or one char
  // omission/insertion/substitution). Gated to brand length ≥ 6 so short brands
  // (zoom) don't drag in unrelated real domains (room.com). Plain Levenshtein≤2
  // is intentionally NOT used — it FPs on legit words (maple≈apple).
  registry.register('url', 'typosquat', (target) => {
    const host = hostnameOf(target.url);
    if (!host || isKnownBrand(host)) return { value: false };
    const sld = sldOf(host);
    if (!sld || sld.length < 4) return { value: false };
    for (const brandDomain of BRAND_DOMAINS) {
      const brand = brandDomain.split('.')[0];
      if (brand.length < 6) continue;
      if (sld === brand) continue;
      if (Math.abs(sld.length - brand.length) > 1) continue;
      if (isKeyboardAdjacencyTypo(sld, brand)) {
        return { value: true, metadata: { host, brand: brandDomain, technique: 'keyboard-adjacency' } };
      }
      if (isCharacterTypo(sld, brand)) {
        return { value: true, metadata: { host, brand: brandDomain, technique: 'character-typo' } };
      }
    }
    return { value: false };
  }, { returnType: 'boolean', scope: 'frame', description: 'Single-edit typosquat of a known brand' });
}

// ── Brand + keyword catalogs (ported from rules/social-engineering/*.json) ────
const BRAND_DOMAINS = [
  'google.com', 'microsoft.com', 'apple.com', 'amazon.com', 'paypal.com',
  'facebook.com', 'instagram.com', 'twitter.com', 'linkedin.com', 'github.com',
  'dropbox.com', 'netflix.com', 'spotify.com', 'adobe.com', 'salesforce.com',
  'oracle.com', 'chase.com', 'wellsfargo.com', 'bankofamerica.com',
  'americanexpress.com', 'coinbase.com', 'binance.com', 'kraken.com',
  'office365.com', 'outlook.com', 'gmail.com', 'yahoo.com', 'icloud.com',
  'zoom.us', 'slack.com', 'docusign.com',
];
const BRAND_SET = new Set(BRAND_DOMAINS);
const SUSPICIOUS_KEYWORDS = [
  'login', 'signin', 'verify', 'security', 'secure', 'account', 'support',
  'help', 'billing', 'payment', 'update', 'confirm', 'authentication', 'auth',
  'sso', 'portal', 'admin', 'dashboard', 'console', 'recovery', 'reset',
  'unlock', 'suspended', 'expired', 'renew', 'invoice', 'receipt',
];

// Multi-label public suffixes we care about (approximation of the PSL — enough
// for the common ccTLDs brands and phishers use).
const MULTI_TLDS = new Set([
  'co.uk', 'org.uk', 'gov.uk', 'ac.uk', 'co.jp', 'com.au', 'net.au', 'org.au',
  'com.br', 'co.in', 'co.nz', 'co.za', 'com.mx', 'com.sg', 'com.hk', 'com.tr',
]);

// ── Helpers ───────────────────────────────────────────────────────────────
function hostnameOf(url) {
  if (!url || typeof url !== 'string') return '';
  let host = '';
  try { host = new URL(url).hostname.toLowerCase(); }
  catch { return ''; }
  return host.replace(/^www\./, '');
}

// Registrable domain (eTLD+1), e.g. accounts.google.com → google.com,
// foo.bar.co.uk → bar.co.uk.
function registrableDomain(host) {
  const labels = host.split('.').filter(Boolean);
  if (labels.length <= 2) return labels.join('.');
  const last2 = labels.slice(-2).join('.');
  const last3 = labels.slice(-3).join('.');
  if (MULTI_TLDS.has(last2)) return last3;
  return last2;
}

// The "second-level domain" — the registrable label without its TLD suffix.
function sldOf(host) {
  const reg = registrableDomain(host);
  const labels = reg.split('.');
  if (MULTI_TLDS.has(labels.slice(-2).join('.'))) return labels.slice(0, -2).join('.');
  return labels.slice(0, -1).join('.');
}

function isKnownBrand(host) {
  return BRAND_SET.has(registrableDomain(host));
}

function hasMixedScripts(s) {
  const hasLatin = /[a-z]/i.test(s);
  const hasCyrillic = /[Ѐ-ӿ]/.test(s);
  const hasGreek = /[Ͱ-Ͽ]/.test(s);
  return [hasLatin, hasCyrillic, hasGreek].filter(Boolean).length > 1;
}

const HOMOGLYPHS = {
  'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'у': 'y', 'х': 'x', // Cyrillic
  'ѕ': 's', 'і': 'i', 'ј': 'j', 'ԁ': 'd', 'ո': 'n',
  'ı': 'i', // dotless i
  'ɢ': 'g', 'ｇ': 'g',
};
function detectHomoglyphs(host) {
  const out = [];
  for (const ch of host) {
    if (HOMOGLYPHS[ch]) out.push(`${ch}→${HOMOGLYPHS[ch]}`);
  }
  return out;
}

const KEYBOARD = {
  q: ['w', 'a'], w: ['q', 'e', 's'], e: ['w', 'r', 'd'], r: ['e', 't', 'f'],
  t: ['r', 'y', 'g'], y: ['t', 'u', 'h'], u: ['y', 'i', 'j'], i: ['u', 'o', 'k'],
  o: ['i', 'p', 'l'], p: ['o', 'l'], a: ['q', 's', 'z'], s: ['a', 'w', 'd', 'x'],
  d: ['s', 'e', 'f', 'c'], f: ['d', 'r', 'g', 'v'], g: ['f', 't', 'h', 'b'],
  h: ['g', 'y', 'j', 'n'], j: ['h', 'u', 'k', 'm'], k: ['j', 'i', 'l'],
  l: ['k', 'o', 'p'], z: ['a', 'x'], x: ['z', 's', 'c'], c: ['x', 'd', 'v'],
  v: ['c', 'f', 'b'], b: ['v', 'g', 'n'], n: ['b', 'h', 'm'], m: ['n', 'j'],
};
function isKeyboardAdjacencyTypo(domain, brand) {
  if (domain.length !== brand.length) return false;
  let typoCount = 0;
  for (let i = 0; i < domain.length; i++) {
    if (domain[i] !== brand[i]) {
      const adjacent = KEYBOARD[brand[i]] || [];
      if (!adjacent.includes(domain[i])) return false;
      typoCount++;
    }
  }
  return typoCount === 1;
}
function isCharacterTypo(domain, brand) {
  const lengthDiff = Math.abs(domain.length - brand.length);
  if (lengthDiff > 1) return false;
  // Omission (gogle vs google)
  if (domain.length === brand.length - 1) {
    for (let i = 0; i < brand.length; i++) {
      if (brand.slice(0, i) + brand.slice(i + 1) === domain) return true;
    }
  }
  // Insertion (googgle vs google)
  if (domain.length === brand.length + 1) {
    for (let i = 0; i < domain.length; i++) {
      if (domain.slice(0, i) + domain.slice(i + 1) === brand) return true;
    }
  }
  // Substitution (google vs gooogle handled above; goagle vs google)
  if (domain.length === brand.length) {
    let diff = 0;
    for (let i = 0; i < domain.length; i++) if (domain[i] !== brand[i]) diff++;
    return diff === 1;
  }
  return false;
}
