/**
 * Config-Audit Fix Engine
 * Deterministic fix engine: maps scanner findings to concrete file changes.
 * Zero external dependencies.
 */

import { readFile, writeFile, rename, stat } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
import { createBackup } from './lib/backup.mjs';
import { runAllScanners } from './scan-orchestrator.mjs';

/**
 * Fix type constants.
 */
const FIX_TYPES = {
  JSON_KEY_ADD: 'json-key-add',
  JSON_KEY_REMOVE: 'json-key-remove',
  JSON_KEY_TYPE_FIX: 'json-key-type-fix',
  JSON_RESTRUCTURE: 'json-restructure',
  FRONTMATTER_RENAME: 'frontmatter-rename',
  FILE_RENAME: 'file-rename',
};

/** Valid effortLevel values for nearest-match */
const VALID_EFFORT_LEVELS = ['low', 'medium', 'high', 'max'];

/**
 * Plan fixes from a scanner envelope.
 * @param {object} envelope - Full scanner envelope from scan-orchestrator
 * @returns {{ fixes: object[], skipped: object[], manual: object[] }}
 */
export function planFixes(envelope) {
  const fixes = [];
  const skipped = [];
  const manual = [];

  for (const scanner of envelope.scanners) {
    for (const finding of scanner.findings) {
      if (!finding.autoFixable) {
        manual.push({
          findingId: finding.id,
          title: finding.title,
          file: finding.file,
          recommendation: finding.recommendation,
        });
        continue;
      }

      const fixPlan = createFixPlan(finding);
      if (fixPlan) {
        fixes.push(fixPlan);
      } else {
        skipped.push(finding);
      }
    }
  }

  // Sort fixes by severity weight (critical first)
  const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
  fixes.sort((a, b) => (severityOrder[a.severity] || 4) - (severityOrder[b.severity] || 4));

  return { fixes, skipped, manual };
}

/**
 * Create a fix plan for a single finding.
 * @param {object} finding
 * @returns {object|null}
 */
function createFixPlan(finding) {
  if (!finding.file) return null;

  const base = {
    findingId: finding.id,
    file: finding.file,
    severity: finding.severity,
    description: '',
    before: null,
    after: null,
    type: null,
  };

  const scanner = finding.scanner;
  const title = finding.title;

  // --- SET scanner fixes ---
  if (scanner === 'SET') {
    if (title === 'Missing $schema reference') {
      return {
        ...base,
        type: FIX_TYPES.JSON_KEY_ADD,
        description: 'Add $schema reference for IDE autocomplete',
        before: '(no $schema key)',
        after: '"$schema": "https://json.schemastore.org/claude-code-settings.json"',
        key: '$schema',
        value: 'https://json.schemastore.org/claude-code-settings.json',
      };
    }

    if (title === 'Deprecated settings key') {
      const key = extractKeyFromEvidence(finding.evidence);
      if (!key) return null;
      return {
        ...base,
        type: FIX_TYPES.JSON_KEY_REMOVE,
        description: `Remove deprecated key "${key}"`,
        before: finding.evidence,
        after: '(key removed)',
        key,
      };
    }

    if (title === 'Type mismatch in settings') {
      const key = extractKeyFromEvidence(finding.evidence);
      if (!key) return null;
      const expectedType = extractExpectedType(finding.description);
      return {
        ...base,
        type: FIX_TYPES.JSON_KEY_TYPE_FIX,
        description: `Fix type of "${key}" to ${expectedType}`,
        before: finding.evidence,
        after: `(converted to ${expectedType})`,
        key,
        expectedType,
      };
    }

    if (title === 'Invalid effortLevel value') {
      return {
        ...base,
        type: FIX_TYPES.JSON_KEY_TYPE_FIX,
        description: 'Fix effortLevel to nearest valid value',
        before: finding.evidence,
        after: '(nearest valid effortLevel)',
        key: 'effortLevel',
        expectedType: 'effortLevel',
      };
    }

    if (title === 'Hooks configured as array instead of object') {
      return {
        ...base,
        type: FIX_TYPES.JSON_RESTRUCTURE,
        description: 'Convert hooks from array to object format',
        before: '"hooks": [...]',
        after: '"hooks": { ... }',
        restructureType: 'hooks-array-to-object',
      };
    }
  }

  // --- HKV scanner fixes ---
  if (scanner === 'HKV') {
    if (title === 'Matcher must be a string, not an object') {
      return {
        ...base,
        type: FIX_TYPES.JSON_RESTRUCTURE,
        description: 'Convert matcher from object to string',
        before: finding.evidence,
        after: '"matcher": "ToolName"',
        restructureType: 'matcher-object-to-string',
        event: extractEventFromDescription(finding.description),
      };
    }

    if (title === 'Hook timeout must be a number') {
      return {
        ...base,
        type: FIX_TYPES.JSON_KEY_TYPE_FIX,
        description: 'Convert timeout to number',
        before: finding.evidence,
        after: '(parsed to number)',
        key: 'timeout',
        expectedType: 'number',
        event: extractEventFromDescription(finding.description),
      };
    }
  }

  // --- RUL scanner fixes ---
  if (scanner === 'RUL') {
    if (title === 'Rule uses deprecated "globs" field') {
      return {
        ...base,
        type: FIX_TYPES.FRONTMATTER_RENAME,
        description: 'Rename "globs" to "paths" in frontmatter',
        before: 'globs:',
        after: 'paths:',
        oldField: 'globs',
        newField: 'paths',
      };
    }

    if (title === 'Rule file is not .md') {
      const newPath = finding.file.replace(/\.[^.]+$/, '.md');
      return {
        ...base,
        type: FIX_TYPES.FILE_RENAME,
        description: `Rename to .md extension`,
        before: finding.file,
        after: newPath,
        newPath,
      };
    }
  }

  return null;
}

/**
 * Apply planned fixes to files.
 * @param {object[]} fixPlans - Array of fix plans from planFixes()
 * @param {object} opts
 * @param {boolean} [opts.dryRun=false]
 * @param {string} [opts.backupDir] - Required if not dryRun
 * @returns {Promise<{ applied: object[], failed: object[] }>}
 */
export async function applyFixes(fixPlans, opts = {}) {
  const applied = [];
  const failed = [];

  if (!opts.dryRun && !opts.backupDir) {
    throw new Error('backupDir is required when not in dryRun mode');
  }

  for (const plan of fixPlans) {
    if (opts.dryRun) {
      applied.push({
        findingId: plan.findingId,
        file: plan.file,
        status: 'dry-run',
        type: plan.type,
        description: plan.description,
      });
      continue;
    }

    try {
      await applyFix(plan);
      applied.push({
        findingId: plan.findingId,
        file: plan.file,
        status: 'applied',
        type: plan.type,
        description: plan.description,
      });
    } catch (err) {
      failed.push({
        findingId: plan.findingId,
        file: plan.file,
        status: 'failed',
        error: err.message,
        type: plan.type,
      });
    }
  }

  return { applied, failed };
}

/**
 * Apply a single fix.
 * @param {object} plan
 */
async function applyFix(plan) {
  switch (plan.type) {
    case FIX_TYPES.JSON_KEY_ADD:
      await applyJsonKeyAdd(plan);
      break;
    case FIX_TYPES.JSON_KEY_REMOVE:
      await applyJsonKeyRemove(plan);
      break;
    case FIX_TYPES.JSON_KEY_TYPE_FIX:
      await applyJsonKeyTypeFix(plan);
      break;
    case FIX_TYPES.JSON_RESTRUCTURE:
      await applyJsonRestructure(plan);
      break;
    case FIX_TYPES.FRONTMATTER_RENAME:
      await applyFrontmatterRename(plan);
      break;
    case FIX_TYPES.FILE_RENAME:
      await applyFileRename(plan);
      break;
    default:
      throw new Error(`Unknown fix type: ${plan.type}`);
  }
}

/**
 * Add a key to a JSON file (as first key for $schema).
 */
async function applyJsonKeyAdd(plan) {
  const content = await readFile(plan.file, 'utf-8');
  const parsed = parseJson(content);
  if (parsed === null) throw new Error('Invalid JSON');

  // For $schema, insert as first key
  if (plan.key === '$schema') {
    const newObj = { $schema: plan.value, ...parsed };
    await writeJsonFile(plan.file, newObj);
  } else {
    parsed[plan.key] = plan.value;
    await writeJsonFile(plan.file, parsed);
  }
}

/**
 * Remove a key from a JSON file.
 */
async function applyJsonKeyRemove(plan) {
  const content = await readFile(plan.file, 'utf-8');
  const parsed = parseJson(content);
  if (parsed === null) throw new Error('Invalid JSON');

  delete parsed[plan.key];
  await writeJsonFile(plan.file, parsed);
}

/**
 * Fix the type of a JSON key value.
 */
async function applyJsonKeyTypeFix(plan) {
  const content = await readFile(plan.file, 'utf-8');
  const parsed = parseJson(content);
  if (parsed === null) throw new Error('Invalid JSON');

  // Handle nested hook timeout fixes
  if (plan.key === 'timeout' && plan.event) {
    fixTimeoutInHooks(parsed, plan.event);
    await writeJsonFile(plan.file, parsed);
    return;
  }

  // Handle effortLevel special case
  if (plan.key === 'effortLevel' && plan.expectedType === 'effortLevel') {
    parsed.effortLevel = findNearestEffortLevel(parsed.effortLevel);
    await writeJsonFile(plan.file, parsed);
    return;
  }

  // Generic type conversion
  if (parsed[plan.key] !== undefined) {
    parsed[plan.key] = convertType(parsed[plan.key], plan.expectedType);
    await writeJsonFile(plan.file, parsed);
  }
}

/**
 * Restructure JSON (hooks array→object, matcher object→string).
 */
async function applyJsonRestructure(plan) {
  const content = await readFile(plan.file, 'utf-8');
  const parsed = parseJson(content);
  if (parsed === null) throw new Error('Invalid JSON');

  if (plan.restructureType === 'hooks-array-to-object') {
    restructureHooksArrayToObject(parsed);
    await writeJsonFile(plan.file, parsed);
    return;
  }

  if (plan.restructureType === 'matcher-object-to-string') {
    restructureMatcherObjectToString(parsed, plan.event);
    await writeJsonFile(plan.file, parsed);
    return;
  }

  throw new Error(`Unknown restructure type: ${plan.restructureType}`);
}

/**
 * Rename a frontmatter field in a markdown file.
 */
async function applyFrontmatterRename(plan) {
  const content = await readFile(plan.file, 'utf-8');

  // Replace the field name in the frontmatter section only
  const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
  if (!fmMatch) throw new Error('No frontmatter found');

  const before = fmMatch[2];
  const regex = new RegExp(`^(${plan.oldField})(\\s*:)`, 'gm');
  const after = before.replace(regex, `${plan.newField}$2`);

  if (before === after) throw new Error(`Field "${plan.oldField}" not found in frontmatter`);

  const newContent = fmMatch[1] + after + fmMatch[3] + content.slice(fmMatch[0].length);
  await writeFile(plan.file, newContent, 'utf-8');

  // Validate frontmatter still parses
  const { frontmatter } = parseFrontmatter(newContent);
  if (!frontmatter) throw new Error('Frontmatter parse failed after rename');
}

/**
 * Rename a file (change extension to .md).
 */
async function applyFileRename(plan) {
  try {
    await stat(plan.file);
  } catch {
    throw new Error(`Source file not found: ${plan.file}`);
  }

  // Check target doesn't already exist
  try {
    await stat(plan.newPath);
    throw new Error(`Target already exists: ${plan.newPath}`);
  } catch (err) {
    if (err.message.startsWith('Target already exists')) throw err;
    // File doesn't exist — good
  }

  await rename(plan.file, plan.newPath);
}

// --- Helper functions ---

/**
 * Write a JSON object to a file with 2-space indent.
 */
async function writeJsonFile(filePath, obj) {
  const json = JSON.stringify(obj, null, 2) + '\n';
  // Validate the JSON we're about to write
  const reparsed = parseJson(json);
  if (reparsed === null) throw new Error('Generated invalid JSON');
  await writeFile(filePath, json, 'utf-8');
}

/**
 * Convert a value to the expected type.
 */
function convertType(value, expectedType) {
  switch (expectedType) {
    case 'boolean':
      if (typeof value === 'string') {
        if (value.toLowerCase() === 'true' || value === '1') return true;
        if (value.toLowerCase() === 'false' || value === '0') return false;
      }
      if (typeof value === 'number') return value !== 0;
      return Boolean(value);
    case 'number':
      if (typeof value === 'string') {
        const num = Number(value);
        return isNaN(num) ? 10000 : num; // default 10000 for timeouts
      }
      return Number(value);
    case 'string':
      return String(value);
    default:
      return value;
  }
}

/**
 * Find the nearest valid effortLevel.
 */
function findNearestEffortLevel(value) {
  if (typeof value !== 'string') return 'medium';
  const lower = value.toLowerCase();
  // Simple distance-based matching
  let best = 'medium';
  let bestDist = Infinity;
  for (const level of VALID_EFFORT_LEVELS) {
    const dist = levenshtein(lower, level);
    if (dist < bestDist) {
      bestDist = dist;
      best = level;
    }
  }
  return best;
}

/**
 * Simple Levenshtein distance.
 */
function levenshtein(a, b) {
  const m = a.length, n = b.length;
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
  for (let i = 0; i <= m; i++) dp[i][0] = i;
  for (let j = 0; j <= n; j++) dp[0][j] = j;
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      dp[i][j] = Math.min(
        dp[i - 1][j] + 1,
        dp[i][j - 1] + 1,
        dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
      );
    }
  }
  return dp[m][n];
}

/**
 * Convert hooks array to object format.
 * Best-effort: wraps array items under "PreToolUse" if they have event fields,
 * otherwise groups by event property.
 */
function restructureHooksArrayToObject(parsed) {
  if (!Array.isArray(parsed.hooks)) return;

  const hooksObj = {};
  for (const item of parsed.hooks) {
    const event = item.event || 'PreToolUse';
    if (!hooksObj[event]) hooksObj[event] = [];

    // Build the handler group
    const group = {};
    if (item.matcher) group.matcher = typeof item.matcher === 'string' ? item.matcher : String(item.matcher);
    group.hooks = [];

    if (item.command) {
      group.hooks.push({
        type: item.type || 'command',
        command: item.command,
        ...(item.timeout !== undefined ? { timeout: typeof item.timeout === 'number' ? item.timeout : Number(item.timeout) || 10000 } : {}),
      });
    } else if (item.hooks && Array.isArray(item.hooks)) {
      group.hooks = item.hooks;
    }

    hooksObj[event].push(group);
  }

  parsed.hooks = hooksObj;
}

/**
 * Convert matcher from object to string in hooks config.
 */
function restructureMatcherObjectToString(parsed, event) {
  const hooks = parsed.hooks || parsed;
  if (typeof hooks !== 'object' || Array.isArray(hooks)) return;

  for (const [eventKey, handlers] of Object.entries(hooks)) {
    if (event && eventKey !== event) continue;
    if (!Array.isArray(handlers)) continue;

    for (const group of handlers) {
      if (group.matcher && typeof group.matcher === 'object') {
        // Extract tool name from object — common patterns: { tool: "Bash" }, { name: "Bash" }
        const tool = group.matcher.tool || group.matcher.name || group.matcher.type || Object.values(group.matcher)[0];
        group.matcher = typeof tool === 'string' ? tool : 'Bash';
      }
    }
  }
}

/**
 * Fix timeout type in nested hooks config.
 */
function fixTimeoutInHooks(parsed, event) {
  const hooks = parsed.hooks || parsed;
  if (typeof hooks !== 'object' || Array.isArray(hooks)) return;

  for (const [eventKey, handlers] of Object.entries(hooks)) {
    if (event && eventKey !== event) continue;
    if (!Array.isArray(handlers)) continue;

    for (const group of handlers) {
      if (!group.hooks || !Array.isArray(group.hooks)) continue;
      for (const hook of group.hooks) {
        if (hook.timeout !== undefined && typeof hook.timeout !== 'number') {
          const num = Number(hook.timeout);
          hook.timeout = isNaN(num) ? 10000 : num;
        }
      }
    }
  }
}

/**
 * Extract key name from evidence string like: 'someKey: "value"'
 */
function extractKeyFromEvidence(evidence) {
  if (!evidence) return null;
  const match = evidence.match(/^(\w+)\s*:/);
  return match ? match[1] : null;
}

/**
 * Extract expected type from description string like: 'should be boolean, got string'
 */
function extractExpectedType(description) {
  const match = description.match(/should be (\w+)/);
  return match ? match[1] : 'string';
}

/**
 * Extract event name from description like: '"PreToolUse" has a matcher...'
 */
function extractEventFromDescription(description) {
  const match = description.match(/"(\w+)"/);
  return match ? match[1] : null;
}

/**
 * Verify fixes by re-running affected scanners.
 * @param {object} originalEnvelope - Original scanner envelope
 * @param {object[]} appliedResults - Results from applyFixes()
 * @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
 */
export async function verifyFixes(originalEnvelope, appliedResults) {
  const targetPath = originalEnvelope.meta.target;
  const verified = [];
  const regressions = [];
  const newFindings = [];

  // Re-scan the target
  const newEnvelope = await runAllScanners(targetPath, { includeGlobal: false });

  // Build set of original finding IDs that were fixed
  const fixedIds = new Set(
    appliedResults.filter(r => r.status === 'applied').map(r => r.findingId),
  );

  // Build set of new finding titles for comparison
  const newFindingMap = new Map();
  for (const scanner of newEnvelope.scanners) {
    for (const f of scanner.findings) {
      newFindingMap.set(`${f.scanner}:${f.title}:${f.file}`, f);
    }
  }

  // Check that fixed findings are gone
  for (const scanner of originalEnvelope.scanners) {
    for (const f of scanner.findings) {
      if (!fixedIds.has(f.id)) continue;

      const key = `${f.scanner}:${f.title}:${f.file}`;
      // For file-rename fixes, the original file path won't exist anymore
      const fixResult = appliedResults.find(r => r.findingId === f.id);
      if (fixResult && fixResult.type === 'file-rename') {
        // Check that the finding doesn't reappear at the new path
        verified.push(f.id);
        continue;
      }

      if (newFindingMap.has(key)) {
        regressions.push(f.id);
      } else {
        verified.push(f.id);
      }
    }
  }

  // Check for any completely new findings not in original
  const originalKeys = new Set();
  for (const scanner of originalEnvelope.scanners) {
    for (const f of scanner.findings) {
      originalKeys.add(`${f.scanner}:${f.title}:${f.file}`);
    }
  }

  for (const [key, f] of newFindingMap) {
    if (!originalKeys.has(key)) {
      newFindings.push(f);
    }
  }

  return { verified, regressions, newFindings };
}

export { FIX_TYPES };
