// Enhanced Rule Engine v3.0.0
// Evaluates declarative detection rules with signals, boolean conditions, and dynamic output.
// Supports detection rules (threat findings) and fingerprint rules (technology identification).
// No eval(), no Function(), no arbitrary JS — all logic is declarative.
//
// v3.0.0 additions:
//   - "provider" signal type: calls a SignalProviderRegistry provider by name
//   - "ref" signal type: reads a feature from the FeatureStore (cross-rule state)
//   - output.feature: writes rule output to the FeatureStore for downstream rules
//   - rule.requires: dependency ordering via topological sort

const LIMITS = {
  MAX_SIGNALS_PER_RULE: 20,
  MAX_CONDITION_DEPTH: 5,
  MAX_REGEX_LENGTH: 500,
  SIGNAL_TIMEOUT_MS: 10,
  RULE_TIMEOUT_MS: 100,
  MAX_RULES_PER_RULESET: 200,
  MAX_CONTAINS_VALUES: 100,
  MAX_SIMILARITY_TARGETS: 50,
  MAX_TRANSFORM_PIPELINE: 5,
};

const SUPPORTED_SCHEMA_VERSIONS = ['2.0.0', '3.0.0'];

const VALID_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
const VALID_ACTIONS = ['block', 'warn', 'log', 'flag'];
const VALID_CHANNELS = ['content', 'url', 'dom', 'headers', 'clipboard', 'scripts', 'cookies', 'meta', 'permissions'];
const VALID_COMPARE_OPS = ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'between'];

export class EnhancedRuleEngine {
  constructor() {
    this.compiledRulesets = new Map(); // rulesetId -> compiled ruleset
    this.stats = { compiled: 0, evaluated: 0, matched: 0, errors: 0 };

    // v3.0.0: optional external dependencies (set via setProviderRegistry / setFeatureStore)
    this.signalProviderRegistry = null;
    this.featureStore = null;
  }

  /**
   * Set the SignalProviderRegistry for v3 provider signals.
   * @param {object} registry - SignalProviderRegistry instance
   */
  setProviderRegistry(registry) {
    this.signalProviderRegistry = registry;
  }

  /**
   * Set the FeatureStore for v3 ref signals and feature output.
   * @param {object} store - FeatureStore instance
   */
  setFeatureStore(store) {
    this.featureStore = store;
  }

  // ─── Compilation ──────────────────────────────────────────

  /**
   * Compile a v2.0.0 ruleset envelope. Validates and pre-compiles all regexes.
   * @param {object} ruleset - Raw ruleset JSON
   * @returns {object} Compiled ruleset ready for evaluation
   */
  compileRuleset(ruleset) {
    if (!ruleset || typeof ruleset !== 'object') {
      throw new Error('Ruleset must be an object');
    }
    if (!SUPPORTED_SCHEMA_VERSIONS.includes(ruleset.schemaVersion)) {
      throw new Error(`Unsupported schema version: ${ruleset.schemaVersion}`);
    }
    if (!ruleset.rulesetId) {
      throw new Error('Ruleset must have a rulesetId');
    }
    if (!Array.isArray(ruleset.rules) || ruleset.rules.length === 0) {
      throw new Error('Ruleset must have a non-empty rules array');
    }
    if (ruleset.rules.length > LIMITS.MAX_RULES_PER_RULESET) {
      throw new Error(`Ruleset exceeds max rules (${LIMITS.MAX_RULES_PER_RULESET})`);
    }

    const isV3 = ruleset.schemaVersion === '3.0.0';
    const compiledRules = ruleset.rules.map(r => this.compileRule(r, isV3));

    // v3: topologically sort rules by "requires" dependencies
    const orderedRules = isV3 ? this._topologicalSort(compiledRules) : compiledRules;

    const compiled = {
      rulesetId: ruleset.rulesetId,
      schemaVersion: ruleset.schemaVersion,
      category: ruleset.category,
      subcategory: ruleset.subcategory,
      description: ruleset.description || '',
      minEngineVersion: ruleset.minEngineVersion || ruleset.schemaVersion,
      rules: orderedRules,
    };

    this.compiledRulesets.set(compiled.rulesetId, compiled);
    this.stats.compiled++;

    console.log(`[EnhancedRuleEngine] Compiled ruleset: ${compiled.rulesetId} (${compiled.rules.length} rules, schema ${ruleset.schemaVersion})`);
    return compiled;
  }

  /**
   * Compile a single rule: validate, compile signals, verify condition references.
   */
  compileRule(rule, isV3 = false) {
    if (!rule.id) throw new Error('Rule must have an id');
    if (!rule.signals || !Array.isArray(rule.signals)) throw new Error(`Rule ${rule.id}: signals must be an array`);
    if (!rule.condition) throw new Error(`Rule ${rule.id}: condition is required`);
    if (!rule.output) throw new Error(`Rule ${rule.id}: output is required`);

    if (rule.signals.length > LIMITS.MAX_SIGNALS_PER_RULE) {
      throw new Error(`Rule ${rule.id}: exceeds max signals (${LIMITS.MAX_SIGNALS_PER_RULE})`);
    }

    // Validate channels
    if (rule.channels) {
      for (const ch of Object.keys(rule.channels)) {
        if (!VALID_CHANNELS.includes(ch) && rule.channels[ch] === true) {
          console.warn(`[EnhancedRuleEngine] Rule ${rule.id}: unknown channel "${ch}"`);
        }
      }
    }

    // Compile signals
    const signalIds = new Set();
    const compiledSignals = rule.signals.map(sig => {
      if (signalIds.has(sig.id)) {
        throw new Error(`Rule ${rule.id}: duplicate signal id "${sig.id}"`);
      }
      signalIds.add(sig.id);
      return this.compileSignal(sig, rule.id);
    });

    // Validate condition references
    this.validateConditionRefs(rule.condition, signalIds, rule.id, 0);

    const compiled = {
      id: rule.id,
      type: rule.type || 'detection',
      name: rule.name || rule.id,
      channels: rule.channels || {},
      signals: compiledSignals,
      condition: rule.condition,
      output: rule.output,
    };

    // v3: dependency declarations
    if (isV3 && rule.requires) {
      compiled.requires = Array.isArray(rule.requires) ? rule.requires : [];
    }

    return compiled;
  }

  /**
   * Compile a single signal, pre-compiling any regex patterns.
   */
  compileSignal(signal, ruleId) {
    if (!signal.id) throw new Error(`Rule ${ruleId}: signal missing id`);

    const compiled = { ...signal };

    // Pre-compile regex in match signals
    if (signal.match) {
      compiled.match = { ...signal.match };
      if (signal.match.pattern) {
        this.validateRegexLength(signal.match.pattern, ruleId, signal.id);
        compiled.match._regex = this.safeCompileRegex(
          signal.match.pattern,
          signal.match.flags || 'i',
          ruleId, signal.id
        );
      }
    }

    // Pre-compile regex in header signals
    if (signal.header && signal.header.pattern) {
      compiled.header = { ...signal.header };
      this.validateRegexLength(signal.header.pattern, ruleId, signal.id);
      compiled.header._regex = this.safeCompileRegex(
        signal.header.pattern,
        signal.header.flags || 'i',
        ruleId, signal.id
      );
    }

    // Pre-compile regex in cookie signals
    if (signal.cookie && signal.cookie.pattern) {
      compiled.cookie = { ...signal.cookie };
      this.validateRegexLength(signal.cookie.pattern, ruleId, signal.id);
      compiled.cookie._regex = this.safeCompileRegex(
        signal.cookie.pattern,
        signal.cookie.flags || 'i',
        ruleId, signal.id
      );
    }

    // Pre-compile regex in meta signals
    if (signal.meta && signal.meta.pattern) {
      compiled.meta = { ...signal.meta };
      this.validateRegexLength(signal.meta.pattern, ruleId, signal.id);
      compiled.meta._regex = this.safeCompileRegex(
        signal.meta.pattern,
        signal.meta.flags || 'i',
        ruleId, signal.id
      );
    }

    // Validate contains limits
    if (signal.contains && signal.contains.values) {
      if (signal.contains.values.length > LIMITS.MAX_CONTAINS_VALUES) {
        throw new Error(`Rule ${ruleId}, signal ${signal.id}: contains.values exceeds max (${LIMITS.MAX_CONTAINS_VALUES})`);
      }
    }

    // Validate similarity limits
    if (signal.similarity && signal.similarity.targets) {
      if (signal.similarity.targets.length > LIMITS.MAX_SIMILARITY_TARGETS) {
        throw new Error(`Rule ${ruleId}, signal ${signal.id}: similarity.targets exceeds max (${LIMITS.MAX_SIMILARITY_TARGETS})`);
      }
    }

    // Validate transform pipeline length
    if (signal.transform && signal.transform.pipeline) {
      if (signal.transform.pipeline.length > LIMITS.MAX_TRANSFORM_PIPELINE) {
        throw new Error(`Rule ${ruleId}, signal ${signal.id}: transform pipeline exceeds max (${LIMITS.MAX_TRANSFORM_PIPELINE})`);
      }
    }

    return compiled;
  }

  validateRegexLength(pattern, ruleId, signalId) {
    if (pattern.length > LIMITS.MAX_REGEX_LENGTH) {
      throw new Error(`Rule ${ruleId}, signal ${signalId}: regex exceeds max length (${LIMITS.MAX_REGEX_LENGTH})`);
    }
  }

  safeCompileRegex(pattern, flags, ruleId, signalId) {
    try {
      return new RegExp(pattern, flags);
    } catch (e) {
      throw new Error(`Rule ${ruleId}, signal ${signalId}: invalid regex "${pattern}": ${e.message}`);
    }
  }

  /**
   * Validate that all signal references in a condition expression exist.
   */
  validateConditionRefs(expr, signalIds, ruleId, depth) {
    if (depth > LIMITS.MAX_CONDITION_DEPTH) {
      throw new Error(`Rule ${ruleId}: condition nesting exceeds max depth (${LIMITS.MAX_CONDITION_DEPTH})`);
    }

    if (typeof expr === 'string') {
      if (!signalIds.has(expr)) {
        throw new Error(`Rule ${ruleId}: condition references unknown signal "${expr}"`);
      }
      return;
    }

    if (typeof expr !== 'object' || expr === null) {
      throw new Error(`Rule ${ruleId}: invalid condition expression`);
    }

    if (expr.and) {
      for (const sub of expr.and) this.validateConditionRefs(sub, signalIds, ruleId, depth + 1);
    } else if (expr.or) {
      for (const sub of expr.or) this.validateConditionRefs(sub, signalIds, ruleId, depth + 1);
    } else if (expr.not) {
      this.validateConditionRefs(expr.not, signalIds, ruleId, depth + 1);
    } else if (expr.atLeast !== undefined && expr.of) {
      for (const sub of expr.of) this.validateConditionRefs(sub, signalIds, ruleId, depth + 1);
    } else if (expr.signal) {
      if (!signalIds.has(expr.signal)) {
        throw new Error(`Rule ${ruleId}: condition references unknown signal "${expr.signal}"`);
      }
    } else {
      throw new Error(`Rule ${ruleId}: unrecognized condition expression`);
    }
  }

  // ─── Evaluation ───────────────────────────────────────────

  /**
   * Evaluate all rules in a compiled ruleset against a target.
   * @param {object} compiledRuleset - Compiled ruleset from compileRuleset()
   * @param {object} target - Data to evaluate { content, url, dom, headers, clipboard, scripts, cookies, meta, permissions }
   * @returns {object} { findings: [], technologies: [], matched: boolean }
   */
  /**
   * Evaluate all rules in a compiled ruleset against a target.
   * @param {object} compiledRuleset - Compiled ruleset from compileRuleset()
   * @param {object} target - Data to evaluate { content, url, dom, headers, clipboard, scripts, cookies, meta, permissions, _tabId?, _frameId? }
   * @param {object} [evalOptions] - { featureStore?, signalProviderRegistry? } — per-call overrides
   * @returns {object} { findings: [], technologies: [], matched: boolean }
   */
  evaluateRuleset(compiledRuleset, target, evalOptions = {}) {
    const findings = [];
    const technologies = [];
    const rulesetStart = performance.now();

    // Allow per-call overrides of feature store and provider registry
    const prevFS = this.featureStore;
    const prevSPR = this.signalProviderRegistry;
    if (evalOptions.featureStore) this.featureStore = evalOptions.featureStore;
    if (evalOptions.signalProviderRegistry) this.signalProviderRegistry = evalOptions.signalProviderRegistry;

    try {
      // Rules are already in dependency order (topological sort at compile time for v3)
      for (const rule of compiledRuleset.rules) {
        try {
          const result = this.evaluateRule(rule, target, compiledRuleset);
          if (result) {
            if (rule.type === 'fingerprint') {
              technologies.push(result);
            } else {
              findings.push(result);
            }
          }
        } catch (error) {
          console.error(`[EnhancedRuleEngine] Error evaluating rule ${rule.id}:`, error);
          this.stats.errors++;
        }
      }
    } finally {
      // Restore originals
      this.featureStore = prevFS;
      this.signalProviderRegistry = prevSPR;
    }

    this.stats.evaluated++;
    if (findings.length > 0 || technologies.length > 0) {
      this.stats.matched++;
    }

    return {
      rulesetId: compiledRuleset.rulesetId,
      findings,
      technologies,
      matched: findings.length > 0 || technologies.length > 0,
      latency: performance.now() - rulesetStart,
    };
  }

  /**
   * Evaluate a single rule against target data.
   * Returns a finding/technology object or null if not matched.
   */
  evaluateRule(rule, target, ruleset) {
    const ruleStart = performance.now();

    // Check channel requirements — skip rule if target doesn't have needed data
    if (!this.checkChannels(rule.channels, target)) {
      return null;
    }

    // Evaluate all signals in order, building a results context
    const ctx = {
      signalResults: new Map(),  // signalId -> { matched, value, count, captures }
      variables: {},             // $variableName -> value
      target,
    };

    for (const signal of rule.signals) {
      // Per-rule timeout check
      if (performance.now() - ruleStart > LIMITS.RULE_TIMEOUT_MS) {
        console.warn(`[EnhancedRuleEngine] Rule ${rule.id} timed out`);
        return null;
      }

      const result = this.evaluateSignal(signal, ctx);
      ctx.signalResults.set(signal.id, result);

      // Store resultField as variable (value and count)
      if (signal.resultField) {
        if (result.value !== undefined) {
          ctx.variables[signal.resultField] = result.value;
        }
        // Also store the count as resultField + "Count" for match-counting patterns
        if (result.count !== undefined) {
          ctx.variables[signal.resultField + 'Count'] = result.count;
          // When the primary value is non-numeric, use count as the resultField value
          // This enables patterns like: match with global → compare count
          if (typeof result.value === 'string' && result.count > 0) {
            ctx.variables[signal.resultField] = result.count;
          }
        }
      }
      // Store capture groups as variables
      if (result.captures) {
        for (const [key, val] of Object.entries(result.captures)) {
          ctx.variables[key] = val;
        }
      }
    }

    // Evaluate condition
    const conditionMet = this.evaluateCondition(rule.condition, ctx, 0);

    if (!conditionMet) return null;

    // Build output
    const output = this.buildOutput(rule, ctx, ruleset);

    // v3: Write output feature to FeatureStore for downstream rule consumption
    if (rule.output.feature && this.featureStore && ctx.target._tabId !== undefined) {
      this.featureStore.set(ctx.target._tabId, rule.output.feature, output, {
        source: `rule:${rule.id}`,
        frameId: ctx.target._frameId ?? 0,
        scope: rule.output.featureScope || 'tab',
      });
    }

    return output;
  }

  /**
   * Check if target has data for the channels the rule needs.
   */
  checkChannels(channels, target) {
    if (!channels || Object.keys(channels).length === 0) return true;

    for (const [channel, required] of Object.entries(channels)) {
      if (!required) continue;
      // At least one required channel must have data
      if (target[channel] !== undefined && target[channel] !== null && target[channel] !== '') {
        return true;
      }
    }

    // If channels were specified and none had data, skip
    const requiredChannels = Object.entries(channels).filter(([, v]) => v);
    if (requiredChannels.length > 0) {
      return false;
    }

    return true;
  }

  /**
   * Evaluate a single signal against the context.
   * Returns { matched, value, count, captures }
   */
  evaluateSignal(signal, ctx) {
    try {
      if (signal.match) return this.evalMatch(signal, ctx);
      if (signal.contains) return this.evalContains(signal, ctx);
      if (signal.compare) return this.evalCompare(signal, ctx);
      if (signal.exists) return this.evalExists(signal, ctx);
      if (signal.list) return this.evalList(signal, ctx);
      if (signal.urlParse) return this.evalUrlParse(signal, ctx);
      if (signal.similarity) return this.evalSimilarity(signal, ctx);
      if (signal.domQuery) return this.evalDomQuery(signal, ctx);
      if (signal.transform) return this.evalTransform(signal, ctx);
      if (signal.header) return this.evalHeader(signal, ctx);
      if (signal.cookie) return this.evalCookie(signal, ctx);
      if (signal.meta) return this.evalMeta(signal, ctx);

      // v3.0.0 signal types
      if (signal.provider) return this.evalProvider(signal, ctx);
      if (signal.ref) return this.evalRef(signal, ctx);

      console.warn(`[EnhancedRuleEngine] Unknown signal type for ${signal.id}`);
      return { matched: false, value: undefined, count: 0, captures: null };
    } catch (error) {
      console.error(`[EnhancedRuleEngine] Signal ${signal.id} error:`, error);
      return { matched: false, value: undefined, count: 0, captures: null };
    }
  }

  // ── v3 Signal Evaluators ──────────────────────────────

  /**
   * v3: Call a signal provider by name, store result in feature store.
   * Signal format: { id, provider: { name, args? }, resultField? }
   */
  evalProvider(signal, ctx) {
    const { provider } = signal;
    if (!this.signalProviderRegistry) {
      console.warn(`[EnhancedRuleEngine] No SignalProviderRegistry set — cannot evaluate provider signal "${signal.id}"`);
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    const providerName = provider.name;
    const args = provider.args || {};

    const result = this.signalProviderRegistry.evaluate(providerName, ctx.target, this.featureStore, args);
    const value = result.value;

    // Write to feature store if available and we have a tabId
    if (this.featureStore && ctx.target._tabId !== undefined) {
      this.featureStore.set(ctx.target._tabId, providerName, value, {
        source: `provider:${providerName}`,
        frameId: ctx.target._frameId ?? 0,
        scope: provider.scope || 'frame',
        metadata: result.metadata || null,
      });
    }

    // Determine if "matched": value is truthy, or non-zero, or non-empty
    const matched = this._isTruthy(value);
    const count = typeof value === 'number' ? value : (matched ? 1 : 0);

    return { matched, value, count, captures: result.metadata || null };
  }

  /**
   * v3: Reference a feature from the FeatureStore.
   * Signal format: { id, ref: { feature, scope?, compare? } }
   */
  evalRef(signal, ctx) {
    const { ref } = signal;
    if (!this.featureStore) {
      console.warn(`[EnhancedRuleEngine] No FeatureStore set — cannot evaluate ref signal "${signal.id}"`);
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    const tabId = ctx.target._tabId;
    if (tabId === undefined) {
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    const value = this.featureStore.get(tabId, ref.feature, {
      scope: ref.scope,
      frameId: ctx.target._frameId ?? 0,
    });

    // If compare is specified, use it to determine match
    if (ref.compare) {
      const numValue = Number(value);
      if (ref.compare.operator === 'eq' || ref.compare.operator === 'neq') {
        // Support non-numeric equality checks
        const matched = ref.compare.operator === 'eq'
          ? value === ref.compare.value
          : value !== ref.compare.value;
        return { matched, value, count: matched ? 1 : 0, captures: null };
      }
      if (!isNaN(numValue)) {
        const matched = this.compareValue(numValue, ref.compare.operator, ref.compare.value, ref.compare.valueTo);
        return { matched, value: numValue, count: matched ? 1 : 0, captures: null };
      }
      return { matched: false, value, count: 0, captures: null };
    }

    // Default: matched if value exists and is truthy
    const matched = this._isTruthy(value);
    return { matched, value, count: matched ? 1 : 0, captures: null };
  }

  // ── Signal Evaluators ───────────────────────────────────

  evalMatch(signal, ctx) {
    const { match } = signal;
    const fieldValue = this.resolveField(match.field, ctx);
    if (fieldValue === undefined || fieldValue === null) {
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    const str = String(fieldValue);
    const regex = match._regex;
    if (!regex) return { matched: false, value: undefined, count: 0, captures: null };

    // Count all matches
    const globalRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : regex.flags + 'g');
    const allMatches = str.match(globalRegex);
    const count = allMatches ? allMatches.length : 0;

    // Get first match with capture groups
    const singleMatch = regex.exec(str);
    let captures = null;
    if (singleMatch && match.captureGroups) {
      captures = {};
      for (const [name, groupIndex] of Object.entries(match.captureGroups)) {
        captures[name] = singleMatch[groupIndex] || null;
      }
    }

    const matched = count > 0;
    const value = singleMatch ? singleMatch[0] : undefined;

    return { matched, value, count, captures };
  }

  evalContains(signal, ctx) {
    const { contains } = signal;
    const fieldValue = this.resolveField(contains.field, ctx);
    if (fieldValue === undefined || fieldValue === null) {
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    const str = String(fieldValue).toLowerCase();
    const values = contains.values || [];
    const mode = contains.mode || 'any'; // any, all, count

    let matchCount = 0;
    const matchedValues = [];

    for (const val of values) {
      if (str.includes(val.toLowerCase())) {
        matchCount++;
        matchedValues.push(val);
      }
    }

    let matched = false;
    if (mode === 'any') {
      matched = matchCount > 0;
    } else if (mode === 'all') {
      matched = matchCount === values.length;
    } else if (mode === 'count') {
      // When mode is 'count', use compare-style threshold
      matched = this.compareValue(matchCount, contains.countOp || 'gte', contains.countValue || 1);
    }

    return { matched, value: matchedValues, count: matchCount, captures: null };
  }

  evalCompare(signal, ctx) {
    const { compare } = signal;
    const fieldValue = this.resolveField(compare.field, ctx);
    const numValue = Number(fieldValue);

    if (isNaN(numValue)) {
      return { matched: false, value: fieldValue, count: 0, captures: null };
    }

    const matched = this.compareValue(numValue, compare.operator, compare.value, compare.valueTo);
    return { matched, value: numValue, count: matched ? 1 : 0, captures: null };
  }

  evalExists(signal, ctx) {
    const { exists } = signal;
    const fieldValue = this.resolveField(exists.field, ctx);
    const fieldExists = fieldValue !== undefined && fieldValue !== null && fieldValue !== '';

    const matched = exists.absent ? !fieldExists : fieldExists;
    return { matched, value: fieldValue, count: matched ? 1 : 0, captures: null };
  }

  evalList(signal, ctx) {
    const { list } = signal;
    const fieldValue = this.resolveField(list.field, ctx);

    if (!Array.isArray(fieldValue)) {
      return { matched: false, value: fieldValue, count: 0, captures: null };
    }

    let matched = false;
    let value = fieldValue;
    const count = fieldValue.length;

    if (list.op === 'length') {
      matched = this.compareValue(count, list.operator || 'gt', list.value || 0);
      value = count;
    } else if (list.op === 'includes') {
      matched = fieldValue.includes(list.value);
    } else if (list.op === 'distinct') {
      const uniqueCount = new Set(fieldValue).size;
      matched = this.compareValue(uniqueCount, list.operator || 'gt', list.value || 0);
      value = uniqueCount;
    }

    return { matched, value, count, captures: null };
  }

  evalUrlParse(signal, ctx) {
    const { urlParse } = signal;
    const urlStr = this.resolveField(urlParse.field || 'url', ctx);
    if (!urlStr) return { matched: false, value: undefined, count: 0, captures: null };

    const parsed = this.parseUrl(urlStr);
    if (!parsed) return { matched: false, value: undefined, count: 0, captures: null };

    const component = parsed[urlParse.component]; // hostname, tld, sld, path, scheme, etc.
    if (component === undefined) return { matched: false, value: undefined, count: 0, captures: null };

    let matched = true;
    let captures = null;

    // Optional pattern matching on the component
    if (urlParse.pattern) {
      const regex = this.safeCompileRegex(urlParse.pattern, urlParse.flags || 'i', 'urlParse', signal.id);
      const m = regex.exec(String(component));
      matched = !!m;
      if (m && urlParse.captureGroups) {
        captures = {};
        for (const [name, idx] of Object.entries(urlParse.captureGroups)) {
          captures[name] = m[idx] || null;
        }
      }
    }

    // Optional value check
    if (urlParse.equals !== undefined) {
      matched = String(component).toLowerCase() === String(urlParse.equals).toLowerCase();
    }
    if (urlParse.oneOf) {
      matched = urlParse.oneOf.some(v => String(component).toLowerCase() === v.toLowerCase());
    }

    return { matched, value: component, count: matched ? 1 : 0, captures };
  }

  evalSimilarity(signal, ctx) {
    const { similarity } = signal;
    const fieldValue = this.resolveField(similarity.field, ctx);
    if (!fieldValue) return { matched: false, value: undefined, count: 0, captures: null };

    const str = String(fieldValue).toLowerCase();
    const targets = similarity.targets || [];
    const threshold = similarity.threshold || 0.8;

    let bestScore = 0;
    let bestTarget = null;

    for (const target of targets) {
      const score = this.computeSimilarity(str, target.toLowerCase());
      if (score > bestScore) {
        bestScore = score;
        bestTarget = target;
      }
    }

    const matched = bestScore >= threshold;
    return {
      matched,
      value: bestScore,
      count: matched ? 1 : 0,
      captures: bestTarget ? { matchedBrand: bestTarget, similarityScore: bestScore } : null,
    };
  }

  evalDomQuery(signal, ctx) {
    const { domQuery } = signal;
    const dom = ctx.target.dom;

    // DOM queries work against a serialized DOM representation
    // In a Chrome extension, the content script sends DOM data as an object
    if (!dom) return { matched: false, value: undefined, count: 0, captures: null };

    // dom should be an object like { elements: [...], querySelectorResults: { selector: count } }
    if (domQuery.selector && dom.querySelectorResults) {
      const count = dom.querySelectorResults[domQuery.selector] || 0;

      let matched = false;
      if (domQuery.operator) {
        matched = this.compareValue(count, domQuery.operator, domQuery.value || 0);
      } else {
        matched = count > 0;
      }

      return { matched, value: count, count, captures: null };
    }

    // Check element attributes
    if (domQuery.attribute && dom.attributes) {
      const attrValue = dom.attributes[domQuery.attribute];
      if (attrValue !== undefined) {
        if (domQuery.pattern) {
          const regex = this.safeCompileRegex(domQuery.pattern, domQuery.flags || 'i', 'domQuery', signal.id);
          const m = regex.exec(String(attrValue));
          return { matched: !!m, value: attrValue, count: m ? 1 : 0, captures: null };
        }
        return { matched: true, value: attrValue, count: 1, captures: null };
      }
    }

    return { matched: false, value: undefined, count: 0, captures: null };
  }

  evalTransform(signal, ctx) {
    const { transform } = signal;
    let value = this.resolveField(transform.field, ctx);

    // Apply pipeline operations
    for (const op of transform.pipeline) {
      value = this.applyTransform(value, op);
      if (value === undefined || value === null) break;
    }

    // Compare result
    let matched = false;
    if (transform.compare) {
      const numValue = Number(value);
      if (!isNaN(numValue)) {
        matched = this.compareValue(numValue, transform.compare.operator, transform.compare.value, transform.compare.valueTo);
      }
    } else if (transform.pattern) {
      const regex = this.safeCompileRegex(transform.pattern, transform.flags || 'i', 'transform', signal.id);
      matched = regex.test(String(value || ''));
    } else {
      // Transform succeeded and produced a truthy value
      matched = !!value;
    }

    return { matched, value, count: matched ? 1 : 0, captures: null };
  }

  evalHeader(signal, ctx) {
    const { header } = signal;
    const headers = ctx.target.headers;
    if (!headers) return { matched: false, value: undefined, count: 0, captures: null };

    // Headers can be an object { name: value } or array of { name, value }
    let headerValue = null;
    if (Array.isArray(headers)) {
      const found = headers.find(h => h.name.toLowerCase() === header.name.toLowerCase());
      headerValue = found ? found.value : null;
    } else if (typeof headers === 'object') {
      // Case-insensitive header lookup
      const key = Object.keys(headers).find(k => k.toLowerCase() === header.name.toLowerCase());
      headerValue = key ? headers[key] : null;
    }

    if (headerValue === null || headerValue === undefined) {
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    // Pattern match if specified
    if (header._regex) {
      const m = header._regex.exec(String(headerValue));
      let captures = null;
      if (m && header.captureGroups) {
        captures = {};
        for (const [name, idx] of Object.entries(header.captureGroups)) {
          captures[name] = m[idx] || null;
        }
      }
      return { matched: !!m, value: headerValue, count: m ? 1 : 0, captures };
    }

    // Exact value match
    if (header.equals !== undefined) {
      const matched = String(headerValue).toLowerCase() === String(header.equals).toLowerCase();
      return { matched, value: headerValue, count: matched ? 1 : 0, captures: null };
    }

    // Header exists
    return { matched: true, value: headerValue, count: 1, captures: null };
  }

  evalCookie(signal, ctx) {
    const { cookie } = signal;
    const cookies = ctx.target.cookies;
    if (!cookies) return { matched: false, value: undefined, count: 0, captures: null };

    // Cookies: array of { name, value } or object { name: value }
    let cookieValue = null;
    if (Array.isArray(cookies)) {
      const found = cookies.find(c => c.name.toLowerCase() === cookie.name.toLowerCase());
      cookieValue = found ? found.value : null;
    } else if (typeof cookies === 'object') {
      const key = Object.keys(cookies).find(k => k.toLowerCase() === cookie.name.toLowerCase());
      cookieValue = key ? cookies[key] : null;
    }

    if (cookieValue === null || cookieValue === undefined) {
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    if (cookie._regex) {
      const m = cookie._regex.exec(String(cookieValue));
      let captures = null;
      if (m && cookie.captureGroups) {
        captures = {};
        for (const [name, idx] of Object.entries(cookie.captureGroups)) {
          captures[name] = m[idx] || null;
        }
      }
      return { matched: !!m, value: cookieValue, count: m ? 1 : 0, captures };
    }

    return { matched: true, value: cookieValue, count: 1, captures: null };
  }

  evalMeta(signal, ctx) {
    const { meta: metaSig } = signal;
    const metaTags = ctx.target.meta;
    if (!metaTags) return { matched: false, value: undefined, count: 0, captures: null };

    // Meta tags: array of { name, content } or object { name: content }
    let metaValue = null;
    if (Array.isArray(metaTags)) {
      const found = metaTags.find(m =>
        (m.name && m.name.toLowerCase() === metaSig.name.toLowerCase()) ||
        (m.property && m.property.toLowerCase() === metaSig.name.toLowerCase())
      );
      metaValue = found ? (found.content || found.value) : null;
    } else if (typeof metaTags === 'object') {
      const key = Object.keys(metaTags).find(k => k.toLowerCase() === metaSig.name.toLowerCase());
      metaValue = key ? metaTags[key] : null;
    }

    if (metaValue === null || metaValue === undefined) {
      return { matched: false, value: undefined, count: 0, captures: null };
    }

    if (metaSig._regex) {
      const m = metaSig._regex.exec(String(metaValue));
      let captures = null;
      if (m && metaSig.captureGroups) {
        captures = {};
        for (const [name, idx] of Object.entries(metaSig.captureGroups)) {
          captures[name] = m[idx] || null;
        }
      }
      return { matched: !!m, value: metaValue, count: m ? 1 : 0, captures };
    }

    return { matched: true, value: metaValue, count: 1, captures: null };
  }

  // ── Condition Evaluator ─────────────────────────────────

  /**
   * Evaluate a condition expression against signal results.
   */
  evaluateCondition(expr, ctx, depth) {
    if (depth > LIMITS.MAX_CONDITION_DEPTH) return false;

    // Simple signal reference
    if (typeof expr === 'string') {
      const result = ctx.signalResults.get(expr);
      return result ? result.matched : false;
    }

    if (typeof expr !== 'object' || expr === null) return false;

    // AND
    if (expr.and) {
      return expr.and.every(sub => this.evaluateCondition(sub, ctx, depth + 1));
    }

    // OR
    if (expr.or) {
      return expr.or.some(sub => this.evaluateCondition(sub, ctx, depth + 1));
    }

    // NOT
    if (expr.not !== undefined) {
      return !this.evaluateCondition(expr.not, ctx, depth + 1);
    }

    // atLeast N of M
    if (expr.atLeast !== undefined && expr.of) {
      let count = 0;
      for (const sub of expr.of) {
        if (this.evaluateCondition(sub, ctx, depth + 1)) count++;
        if (count >= expr.atLeast) return true; // Early exit
      }
      return false;
    }

    // Count-based signal check
    if (expr.signal && expr.count) {
      const result = ctx.signalResults.get(expr.signal);
      if (!result) return false;
      return this.compareValue(result.count, expr.count.operator, expr.count.value);
    }

    return false;
  }

  // ── Output Builder ──────────────────────────────────────

  /**
   * Build the finding/technology output for a matched rule.
   */
  buildOutput(rule, ctx, ruleset) {
    const output = rule.output;
    const base = {
      ruleId: rule.id,
      ruleName: rule.name,
      rulesetId: ruleset.rulesetId,
      category: ruleset.category,
      subcategory: ruleset.subcategory,
      type: rule.type || 'detection',
    };

    if (rule.type === 'fingerprint') {
      return this.buildFingerprintOutput(base, output, ctx);
    }

    return this.buildDetectionOutput(base, output, ctx);
  }

  buildDetectionOutput(base, output, ctx) {
    let severity = output.severity || 'medium';
    let weight = output.weight || 1;
    let action = output.action || 'warn';
    let description = output.description || '';

    // Apply overrides
    if (output.overrides) {
      for (const override of output.overrides) {
        if (this.evaluateCondition(override.when, ctx, 0)) {
          if (override.severity) severity = override.severity;
          if (override.weight) weight = override.weight;
          if (override.action) action = override.action;
          if (override.description) description = override.description;
        }
      }
    }

    // Calculate confidence
    let confidence = null;
    if (output.confidence) {
      confidence = this.calculateConfidence(output.confidence, ctx);
    }

    // Interpolate description template
    description = this.interpolateString(description, ctx.variables);

    return {
      ...base,
      severity,
      weight,
      action,
      description,
      confidence,
      timestamp: Date.now(),
      metadata: {
        signalResults: this.serializeSignalResults(ctx),
        variables: { ...ctx.variables },
      },
    };
  }

  buildFingerprintOutput(base, output, ctx) {
    let confidence = null;
    if (output.confidence) {
      confidence = this.calculateConfidence(output.confidence, ctx);
    }

    return {
      ...base,
      technology: this.interpolateString(output.technology || '', ctx.variables),
      category: output.category || base.category,
      version: this.interpolateString(output.version || '', ctx.variables) || null,
      cpe: this.interpolateString(output.cpe || '', ctx.variables) || null,
      confidence,
      timestamp: Date.now(),
      metadata: {
        variables: { ...ctx.variables },
      },
    };
  }

  calculateConfidence(conf, ctx) {
    let score = conf.base || 0;

    if (conf.perSignal) {
      for (const [sigId, points] of Object.entries(conf.perSignal)) {
        const result = ctx.signalResults.get(sigId);
        if (result && result.matched) {
          score += points;
        }
      }
    }

    const max = conf.max || 100;
    return Math.min(score, max);
  }

  serializeSignalResults(ctx) {
    const results = {};
    for (const [id, result] of ctx.signalResults) {
      results[id] = {
        matched: result.matched,
        count: result.count,
        value: result.value !== undefined ? String(result.value).substring(0, 200) : null,
      };
    }
    return results;
  }

  // ── Utilities ───────────────────────────────────────────

  /**
   * Resolve a field reference from the target or variables.
   * Supports dot notation (e.g. "target.url") and $variable references.
   */
  resolveField(field, ctx) {
    if (!field) return undefined;

    // Variable reference: $varName
    if (typeof field === 'string' && field.startsWith('$')) {
      // resultField is stored WITH the leading "$" (e.g. "$susUrlCount"), but
      // historically this stripped it on read — so compare-on-variable signals
      // never resolved and silently never matched. Accept both keyings.
      const stripped = ctx.variables[field.substring(1)];
      return stripped !== undefined ? stripped : ctx.variables[field];
    }

    // Direct channel names map to target properties
    const directFields = ['content', 'url', 'clipboard', 'scripts', 'permissions'];
    if (directFields.includes(field)) {
      return ctx.target[field];
    }

    // Dot-notation path resolution on target
    if (typeof field === 'string' && field.includes('.')) {
      return this.resolvePath(ctx.target, field);
    }

    // Fallback: check target directly
    if (ctx.target[field] !== undefined) {
      return ctx.target[field];
    }

    // Check variables
    if (ctx.variables[field] !== undefined) {
      return ctx.variables[field];
    }

    return undefined;
  }

  resolvePath(obj, path) {
    const parts = path.split('.');
    let current = obj;
    for (const part of parts) {
      if (current === undefined || current === null) return undefined;
      current = current[part];
    }
    return current;
  }

  /**
   * Interpolate ${variable} placeholders in a string.
   */
  interpolateString(template, variables) {
    if (!template || typeof template !== 'string') return template;
    return template.replace(/\$\{(\w+)\}/g, (match, varName) => {
      const val = variables[varName];
      return val !== undefined && val !== null ? String(val) : match;
    });
  }

  /**
   * Compare a numeric value using an operator.
   */
  compareValue(value, operator, target, targetTo) {
    switch (operator) {
      case 'gt': return value > target;
      case 'gte': return value >= target;
      case 'lt': return value < target;
      case 'lte': return value <= target;
      case 'eq': return value === target;
      case 'neq': return value !== target;
      case 'between': return value >= target && value <= (targetTo || target);
      default: return false;
    }
  }

  /**
   * Parse a URL into components including TLD extraction.
   */
  parseUrl(urlStr) {
    try {
      const url = new URL(urlStr);
      const hostname = url.hostname;
      const parts = hostname.split('.');

      // Simple TLD/SLD extraction (handles common cases)
      let tld = '';
      let sld = '';
      if (parts.length >= 2) {
        // Check for compound TLDs (co.uk, com.au, etc.)
        const compoundTlds = ['co.uk', 'com.au', 'co.nz', 'co.za', 'com.br', 'co.jp', 'co.kr', 'org.uk', 'net.au'];
        const lastTwo = parts.slice(-2).join('.');
        if (compoundTlds.includes(lastTwo) && parts.length >= 3) {
          tld = lastTwo;
          sld = parts[parts.length - 3];
        } else {
          tld = parts[parts.length - 1];
          sld = parts[parts.length - 2];
        }
      }

      return {
        hostname,
        tld,
        sld,
        domain: sld + '.' + tld,
        path: url.pathname,
        scheme: url.protocol.replace(':', ''),
        port: url.port || (url.protocol === 'https:' ? '443' : '80'),
        query: url.search,
        hash: url.hash,
        origin: url.origin,
        isPunycode: hostname.startsWith('xn--') || hostname.includes('.xn--'),
        subdomain: parts.length > 2 ? parts.slice(0, -2).join('.') : '',
      };
    } catch {
      return null;
    }
  }

  /**
   * Compute string similarity using Levenshtein-based normalized distance.
   */
  computeSimilarity(a, b) {
    if (a === b) return 1.0;
    const maxLen = Math.max(a.length, b.length);
    if (maxLen === 0) return 1.0;
    const dist = this.levenshteinDistance(a, b);
    return 1.0 - (dist / maxLen);
  }

  levenshteinDistance(a, b) {
    const m = a.length;
    const n = b.length;

    // Use single-row optimization for memory efficiency
    let prev = new Array(n + 1);
    let curr = new Array(n + 1);

    for (let j = 0; j <= n; j++) prev[j] = j;

    for (let i = 1; i <= m; i++) {
      curr[0] = i;
      for (let j = 1; j <= n; j++) {
        const cost = a[i - 1] === b[j - 1] ? 0 : 1;
        curr[j] = Math.min(
          prev[j] + 1,       // deletion
          curr[j - 1] + 1,   // insertion
          prev[j - 1] + cost  // substitution
        );
      }
      [prev, curr] = [curr, prev];
    }

    return prev[n];
  }

  /**
   * Apply a single transform operation.
   */
  applyTransform(value, op) {
    if (value === undefined || value === null) return undefined;

    switch (op.type) {
      case 'length':
        return typeof value === 'string' ? value.length : Array.isArray(value) ? value.length : 0;

      case 'lowercase':
        return String(value).toLowerCase();

      case 'uppercase':
        return String(value).toUpperCase();

      case 'trim':
        return String(value).trim();

      case 'split': {
        const parts = String(value).split(op.delimiter || ',');
        return op.index !== undefined ? parts[op.index] : parts;
      }

      case 'regexExtract': {
        if (!op.pattern) return undefined;
        try {
          const regex = new RegExp(op.pattern, op.flags || 'i');
          const m = regex.exec(String(value));
          if (!m) return undefined;
          return op.group !== undefined ? m[op.group] : m[0];
        } catch {
          return undefined;
        }
      }

      case 'base64Decode': {
        try {
          return atob(String(value));
        } catch {
          return undefined;
        }
      }

      case 'count': {
        if (!op.pattern) return 0;
        try {
          const regex = new RegExp(op.pattern, (op.flags || '') + 'g');
          const matches = String(value).match(regex);
          return matches ? matches.length : 0;
        } catch {
          return 0;
        }
      }

      case 'replace': {
        if (!op.pattern) return value;
        try {
          const regex = new RegExp(op.pattern, op.flags || 'g');
          return String(value).replace(regex, op.replacement || '');
        } catch {
          return value;
        }
      }

      case 'substring':
        return String(value).substring(op.start || 0, op.end);

      default:
        console.warn(`[EnhancedRuleEngine] Unknown transform type: ${op.type}`);
        return value;
    }
  }

  // ── Public API ──────────────────────────────────────────

  /**
   * Get a compiled ruleset by ID.
   */
  getCompiledRuleset(rulesetId) {
    return this.compiledRulesets.get(rulesetId) || null;
  }

  /**
   * Get all compiled rulesets.
   */
  getAllCompiledRulesets() {
    return Array.from(this.compiledRulesets.values());
  }

  /**
   * Remove a compiled ruleset.
   */
  removeRuleset(rulesetId) {
    return this.compiledRulesets.delete(rulesetId);
  }

  /**
   * Get engine statistics.
   */
  getStats() {
    return {
      ...this.stats,
      compiledRulesets: this.compiledRulesets.size,
    };
  }

  /**
   * Clear all compiled rulesets.
   */
  clear() {
    this.compiledRulesets.clear();
    console.log('[EnhancedRuleEngine] Cleared all compiled rulesets');
  }

  // ── v3 Helpers ────────────────────────────────────────

  /**
   * Topologically sort rules by their "requires" dependencies.
   * Rules without requires come first. Cyclic dependencies are detected and warned.
   * @param {object[]} rules - Compiled rules with optional .requires arrays
   * @returns {object[]} Sorted rules
   */
  _topologicalSort(rules) {
    // Build adjacency: ruleId -> set of ruleIds it depends on
    // "requires" contains feature paths like "rule.suspicious_overlay" or "url.brand_similarity"
    // We match these against output.feature of other rules
    const featureToRuleId = new Map(); // featurePath -> ruleId that produces it
    for (const rule of rules) {
      if (rule.output && rule.output.feature) {
        featureToRuleId.set(rule.output.feature, rule.id);
      }
    }

    const ruleMap = new Map(); // ruleId -> rule
    const deps = new Map();   // ruleId -> Set<ruleId> (dependencies)

    for (const rule of rules) {
      ruleMap.set(rule.id, rule);
      const ruleDeps = new Set();
      if (rule.requires) {
        for (const req of rule.requires) {
          const depRuleId = featureToRuleId.get(req);
          if (depRuleId && depRuleId !== rule.id) {
            ruleDeps.add(depRuleId);
          }
          // If req doesn't map to a rule (it's a provider), no dependency needed
        }
      }
      deps.set(rule.id, ruleDeps);
    }

    // Kahn's algorithm
    const inDegree = new Map();
    for (const [id, d] of deps) {
      inDegree.set(id, d.size);
    }

    const queue = [];
    for (const [id, deg] of inDegree) {
      if (deg === 0) queue.push(id);
    }

    const sorted = [];
    while (queue.length > 0) {
      const id = queue.shift();
      sorted.push(ruleMap.get(id));

      // Decrease in-degree of dependents
      for (const [otherId, otherDeps] of deps) {
        if (otherDeps.has(id)) {
          otherDeps.delete(id);
          inDegree.set(otherId, inDegree.get(otherId) - 1);
          if (inDegree.get(otherId) === 0) {
            queue.push(otherId);
          }
        }
      }
    }

    // Check for cycles
    if (sorted.length < rules.length) {
      const missing = rules.filter(r => !sorted.includes(r));
      console.warn(`[EnhancedRuleEngine] Cyclic dependency detected in ${missing.length} rules — appending in original order`);
      sorted.push(...missing);
    }

    return sorted;
  }

  /**
   * Check if a value should be considered "truthy" for signal matching.
   * @param {any} value
   * @returns {boolean}
   */
  _isTruthy(value) {
    if (value === undefined || value === null || value === false) return false;
    if (value === 0) return false;
    if (typeof value === 'string' && value === '') return false;
    if (Array.isArray(value) && value.length === 0) return false;
    return true;
  }
}
