"use strict";

const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const { CHECKSUM_DISPLAY_LENGTH } = require("./constants");
const { computeFileChecksum, isBinaryFile } = require("./fs-utils");

// Legacy placeholder: {UPPER_SNAKE}, NOT preceded by $ (preserves shell vars).
// Identical to v0.7.x; kept for dual-mode warning path.
const LEGACY_PLACEHOLDER_RE = /(?<!\$)\{([A-Z][A-Z0-9_]{1,})\}/g;

// New placeholder: <UPPER_SNAKE>. Bounded by non-identifier chars on each side
// so '<details>' / '<br>' (HTML) and '<-' (arrow) never match. Two+ chars.
const NEW_PLACEHOLDER_RE =
  /(?<![A-Za-z0-9_<])<([A-Z][A-Z0-9_]{1,})>(?![A-Za-z0-9_>])/g;

// Combined dual-mode matcher. Exported under the old name PLACEHOLDER_RE for
// backward compat (legacy-only consumers still work).
const PLACEHOLDER_RE = LEGACY_PLACEHOLDER_RE;

// ─── parseConstants ─────────────────────────────────────────────────────────

/**
 * Parse project-constants.md markdown tables into a Map of constant → value.
 * Skips header/separator rows and rows where value equals {SAME_KEY} (unresolved).
 * Derives PROJECT_KEY_LOWERCASE from PROJECT_KEY if present.
 */
function parseConstants(filePath) {
  const content = fs.readFileSync(filePath, "utf-8");
  const values = new Map();
  const lines = content.split("\n");

  for (const line of lines) {
    if (!line.startsWith("|")) continue;

    const rawCells = line.split("|");
    const cells = rawCells.slice(1, -1).map((c) => c.trim());

    if (cells.length < 2) continue;

    const key = cells[0];
    const value = cells[1];

    if (key.startsWith("---") || key.startsWith("-")) continue;
    if (key === "Constant" || key === "Key" || key === "Gate") continue;
    if (key === "Repository" || key === "Branch") continue;

    if (!/^[A-Z][A-Z0-9_]*$/.test(key)) continue;

    if (value === `{${key}}` || value === `<${key}>`) continue;

    if (!value) continue;

    values.set(key, value);
  }

  const localPath = path.join(path.dirname(filePath), "project-paths.local.md");
  if (fs.existsSync(localPath)) {
    const localContent = fs.readFileSync(localPath, "utf-8");
    const localLines = localContent.split("\n");
    for (const line of localLines) {
      if (!line.startsWith("|")) continue;

      const rawCells = line.split("|");
      const cells = rawCells.slice(1, -1).map((c) => c.trim());

      if (cells.length < 2) continue;

      const key = cells[0];
      const value = cells[1];

      if (key.startsWith("---") || key.startsWith("-")) continue;
      if (key === "Constant" || key === "Key" || key === "Gate") continue;
      if (key === "Repository" || key === "Branch") continue;

      if (!/^[A-Z][A-Z0-9_]*$/.test(key)) continue;

      if (value === `{${key}}` || value === `<${key}>`) continue;

      if (!value) continue;

      values.set(key, value);
    }
  }

  if (values.has("PROJECT_KEY") && !values.has("PROJECT_KEY_LOWERCASE")) {
    values.set(
      "PROJECT_KEY_LOWERCASE",
      values.get("PROJECT_KEY").toLowerCase(),
    );
  }

  return values;
}

// ─── resolveTemplate ────────────────────────────────────────────────────────

/**
 * Resolve placeholder tokens in content using values map.
 * Supports both new <X> and legacy {X} forms (dual-mode).
 * Only replaces tokens present in valuesMap. Unmatched tokens are left intact.
 *
 * @param {string} content
 * @param {Map<string,string>} valuesMap
 * @param {object} [options]
 * @param {boolean} [options.allowLegacy=true]
 * @param {boolean} [options.emitRecords=false]
 * @param {string}  [options.filePath='<memory>']
 */
function resolveTemplate(content, valuesMap, options = {}) {
  const opts = {
    allowLegacy: true,
    emitRecords: false,
    filePath: "<memory>",
    ...options,
  };
  const replacements = [];
  const unresolvedTokens = new Set();
  const diagnostics = [];

  const lineStarts = computeLineStarts(content);

  content = content.replace(
    new RegExp(NEW_PLACEHOLDER_RE.source, NEW_PLACEHOLDER_RE.flags),
    (match, token, offset) => {
      if (valuesMap.has(token)) {
        replacements.push({ token, value: valuesMap.get(token), form: "new" });
        return valuesMap.get(token);
      }
      unresolvedTokens.add(token);
      if (opts.emitRecords) {
        diagnostics.push(
          record(
            "UNRESOLVED_NEW",
            "error",
            opts.filePath,
            token,
            lineAtOffset(lineStarts, offset),
          ),
        );
      }
      return match;
    },
  );

  if (opts.allowLegacy) {
    content = content.replace(
      new RegExp(LEGACY_PLACEHOLDER_RE.source, LEGACY_PLACEHOLDER_RE.flags),
      (match, token, offset) => {
        if (valuesMap.has(token)) {
          replacements.push({
            token,
            value: valuesMap.get(token),
            form: "legacy",
          });
          if (opts.emitRecords) {
            diagnostics.push(
              record(
                "DEPRECATED_LEGACY",
                "warning",
                opts.filePath,
                token,
                lineAtOffset(lineStarts, offset),
              ),
            );
          }
          return valuesMap.get(token);
        }
        unresolvedTokens.add(token);
        return match;
      },
    );
  }

  const result = {
    resolved: content,
    replacements,
    unresolvedTokens: [...unresolvedTokens],
  };
  if (opts.emitRecords) result.diagnostics = diagnostics;
  return result;
}

// ─── resolveFile ────────────────────────────────────────────────────────────

/**
 * Read a file and resolve placeholders. Returns null for binary files.
 */
function resolveFile(filePath, valuesMap) {
  if (isBinaryFile(filePath)) return null;
  const content = fs.readFileSync(filePath, "utf-8");
  const result = resolveTemplate(content, valuesMap);
  result.filePath = filePath;
  return result;
}

// ─── computeResolvedChecksum ────────────────────────────────────────────────

/**
 * Resolve placeholders in memory and compute SHA-256 checksum (12-char truncated).
 * For binary files, returns the raw file checksum.
 */
function computeResolvedChecksum(filePath, valuesMap) {
  if (isBinaryFile(filePath)) {
    return computeFileChecksum(filePath);
  }
  const content = fs.readFileSync(filePath, "utf-8");
  const { resolved } = resolveTemplate(content, valuesMap);
  return crypto
    .createHash("sha256")
    .update(resolved)
    .digest("hex")
    .slice(0, CHECKSUM_DISPLAY_LENGTH);
}

// ─── diffClassify ───────────────────────────────────────────────────────────

/**
 * Classify the difference between a template file and a deployed file.
 * Four-tier classification:
 *   identical        — raw checksums match
 *   placeholder-only — resolved template checksum matches deployed
 *   formatting       — whitespace-normalized checksums match
 *   content          — real content differences
 */
function diffClassify(templatePath, deployedPath, valuesMap) {
  const templateChecksum = computeFileChecksum(templatePath);
  const deployedChecksum = computeFileChecksum(deployedPath);

  // Tier 1: raw checksums match
  if (templateChecksum === deployedChecksum) {
    return { classification: "identical", templateChecksum, deployedChecksum };
  }

  // Tier 2: resolved template matches deployed
  const resolvedChecksum = computeResolvedChecksum(templatePath, valuesMap);
  if (resolvedChecksum === deployedChecksum) {
    return {
      classification: "placeholder-only",
      templateChecksum,
      deployedChecksum,
    };
  }

  // Tier 3: whitespace-normalized comparison
  const templateBinary = isBinaryFile(templatePath);
  const deployedBinary = isBinaryFile(deployedPath);

  if (!templateBinary && !deployedBinary) {
    const templateContent = fs.readFileSync(templatePath, "utf-8");
    const { resolved } = resolveTemplate(templateContent, valuesMap);
    const deployedContent = fs.readFileSync(deployedPath, "utf-8");

    const normalizeWs = (s) =>
      s
        .replace(/[ \t]+/g, " ")
        .replace(/\r\n/g, "\n")
        .trim();
    const normalizedResolved = normalizeWs(resolved);
    const normalizedDeployed = normalizeWs(deployedContent);

    const normResolvedHash = crypto
      .createHash("sha256")
      .update(normalizedResolved)
      .digest("hex")
      .slice(0, CHECKSUM_DISPLAY_LENGTH);
    const normDeployedHash = crypto
      .createHash("sha256")
      .update(normalizedDeployed)
      .digest("hex")
      .slice(0, CHECKSUM_DISPLAY_LENGTH);

    if (normResolvedHash === normDeployedHash) {
      return {
        classification: "formatting",
        templateChecksum,
        deployedChecksum,
      };
    }
  }

  // Tier 4: real content differences
  return { classification: "content", templateChecksum, deployedChecksum };
}

// ─── buildChangeSet ─────────────────────────────────────────────────────────

/**
 * Recursive directory comparison with per-file classification.
 * Returns { added, removed, modified, identical, summary }.
 */
function buildChangeSet(sourceDir, targetDir, valuesMap, options = {}) {
  const { exclude = [], includeUnchanged = false } = options;

  const added = [];
  const removed = [];
  const modified = [];
  const identical = [];

  // Collect all relative paths from both dirs
  const sourceFiles = collectFiles(sourceDir, "");
  const targetFiles = collectFiles(targetDir, "");

  const sourceSet = new Set(sourceFiles);
  const targetSet = new Set(targetFiles);

  // Files in source but not in target → added
  for (const relPath of sourceFiles) {
    if (matchesExclude(relPath, exclude)) continue;

    if (!targetSet.has(relPath)) {
      const templateChecksum = computeFileChecksum(
        path.join(sourceDir, relPath),
      );
      const result = resolveFile(path.join(sourceDir, relPath), valuesMap);
      added.push({
        relPath,
        classification: "added",
        templateChecksum,
        deployedChecksum: null,
        unresolvedTokens: result ? result.unresolvedTokens : [],
      });
    } else {
      // Both exist — classify the difference
      const srcPath = path.join(sourceDir, relPath);
      const tgtPath = path.join(targetDir, relPath);
      const diff = diffClassify(srcPath, tgtPath, valuesMap);

      if (
        diff.classification === "identical" ||
        diff.classification === "placeholder-only"
      ) {
        if (includeUnchanged) {
          const result = resolveFile(srcPath, valuesMap);
          identical.push({
            relPath,
            classification: diff.classification,
            templateChecksum: diff.templateChecksum,
            deployedChecksum: diff.deployedChecksum,
            unresolvedTokens: result ? result.unresolvedTokens : [],
          });
        }
      } else {
        const result = resolveFile(srcPath, valuesMap);
        modified.push({
          relPath,
          classification: diff.classification,
          templateChecksum: diff.templateChecksum,
          deployedChecksum: diff.deployedChecksum,
          unresolvedTokens: result ? result.unresolvedTokens : [],
        });
      }
    }
  }

  // Files in target but not in source → removed
  for (const relPath of targetFiles) {
    if (matchesExclude(relPath, exclude)) continue;

    if (!sourceSet.has(relPath)) {
      const deployedChecksum = computeFileChecksum(
        path.join(targetDir, relPath),
      );
      removed.push({
        relPath,
        classification: "removed",
        templateChecksum: null,
        deployedChecksum,
        unresolvedTokens: [],
      });
    }
  }

  return {
    added,
    removed,
    modified,
    identical,
    summary: {
      added: added.length,
      removed: removed.length,
      modified: modified.length,
      identical: identical.length,
    },
  };
}

/**
 * Recursively collect all file relative paths in a directory.
 */
function collectFiles(dir, prefix) {
  const files = [];
  if (!fs.existsSync(dir)) return files;

  const entries = fs.readdirSync(dir, { withFileTypes: true });
  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;

    // Skip symlinks
    const stat = fs.lstatSync(fullPath);
    if (stat.isSymbolicLink()) continue;

    if (entry.isDirectory()) {
      files.push(...collectFiles(fullPath, relPath));
    } else {
      files.push(relPath);
    }
  }
  return files;
}

/**
 * Convert a simple glob pattern to a regex.
 * Supports * (any non-slash) and ** (any path).
 * Patterns without path separators match the basename of the path.
 */
function globToRegex(pattern) {
  let regex = pattern
    .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex specials (except * and ?)
    .replace(/\?/g, "[^/]") // ? matches single non-slash char (before * processing)
    .replace(/\*\*\//g, "\0GLOBSTAR_SLASH\0")
    .replace(/\*\*/g, "\0GLOBSTAR\0")
    .replace(/\*/g, "[^/]*") // * matches non-slash chars
    .replace(/\0GLOBSTAR_SLASH\0/g, "(.*/)?") // **/ matches zero or more path segments
    .replace(/\0GLOBSTAR\0/g, ".*"); // ** matches anything
  return new RegExp(`^${regex}$`);
}

/**
 * Check if a relative path matches any of the exclude patterns.
 * Simple patterns without / match against basename; patterns with / match full path.
 */
function matchesExclude(relPath, excludePatterns) {
  const basename = path.basename(relPath);
  return excludePatterns.some((pattern) => {
    const re = globToRegex(pattern);
    // If pattern has no path separator, also try matching basename
    if (!pattern.includes("/")) {
      return re.test(relPath) || re.test(basename);
    }
    return re.test(relPath);
  });
}

// ─── applyTemplate ──────────────────────────────────────────────────────────

/**
 * Copy a template file to target, resolving placeholders.
 * Atomic write (.tmp → rename). Preserves executable bits.
 */
function applyTemplate(templatePath, targetPath, valuesMap, options = {}) {
  const { dryRun = false } = options;

  if (isBinaryFile(templatePath)) {
    // Binary files: copy directly without resolution
    if (!dryRun) {
      fs.mkdirSync(path.dirname(targetPath), { recursive: true });
      fs.copyFileSync(templatePath, targetPath);
      preserveExecutableBits(templatePath, targetPath);
    }
    return { written: !dryRun, replacements: [], unresolvedTokens: [] };
  }

  const content = fs.readFileSync(templatePath, "utf-8");
  const { resolved, replacements, unresolvedTokens } = resolveTemplate(
    content,
    valuesMap,
  );

  if (!dryRun) {
    fs.mkdirSync(path.dirname(targetPath), { recursive: true });
    const tmp = targetPath + ".tmp";
    fs.writeFileSync(tmp, resolved, "utf-8");
    fs.renameSync(tmp, targetPath);
    preserveExecutableBits(templatePath, targetPath);
  }

  return { written: !dryRun, replacements, unresolvedTokens };
}

/**
 * Apply templates recursively from sourceDir to targetDir.
 */
function applyTemplateDir(sourceDir, targetDir, valuesMap, options = {}) {
  const { dryRun = false, exclude = [] } = options;
  const results = { written: 0, skipped: 0, errors: [] };

  const files = collectFiles(sourceDir, "");
  for (const relPath of files) {
    if (matchesExclude(relPath, exclude)) {
      results.skipped++;
      continue;
    }

    const srcPath = path.join(sourceDir, relPath);
    const tgtPath = path.join(targetDir, relPath);

    try {
      const result = applyTemplate(srcPath, tgtPath, valuesMap, { dryRun });
      if (result.written) {
        results.written++;
      }
    } catch (err) {
      results.errors.push({ relPath, error: err.message });
    }
  }

  return results;
}

/**
 * Preserve executable bits from source file to target file.
 */
function preserveExecutableBits(srcPath, destPath) {
  const srcMode = fs.lstatSync(srcPath).mode;
  if (srcMode & 0o111) {
    fs.chmodSync(destPath, srcMode);
  }
}

// ─── verifyNoUnresolved ─────────────────────────────────────────────────────

/**
 * Scan directory for remaining placeholder tokens.
 * Returns structured diagnostic envelope: {errors, warnings, info, clean, files}.
 *
 * @param {string} dir
 * @param {object} [options]
 * @param {string[]} [options.exclude]
 * @param {boolean}  [options.includeContext=false]
 */
function verifyNoUnresolved(dir, options = {}) {
  const {
    exclude = ["project-constants.md", "project-catalog.yaml"],
    includeContext = false,
  } = options;

  const errors = [];
  const warnings = [];
  const info = [];
  const files = [];

  const allFiles = collectFiles(dir, "");

  for (const relPath of allFiles) {
    if (matchesExclude(relPath, exclude)) continue;

    const fullPath = path.join(dir, relPath);
    if (isBinaryFile(fullPath)) continue;

    const content = fs.readFileSync(fullPath, "utf-8");
    const lineStarts = computeLineStarts(content);
    let fileHasNew = false;
    let fileHasLegacy = false;
    const tokensInFile = new Set();

    for (const match of content.matchAll(
      new RegExp(NEW_PLACEHOLDER_RE.source, NEW_PLACEHOLDER_RE.flags),
    )) {
      fileHasNew = true;
      tokensInFile.add(match[1]);
      errors.push(
        record(
          "UNRESOLVED_NEW",
          "error",
          relPath,
          match[1],
          lineAtOffset(lineStarts, match.index),
          includeContext ? sliceContext(content, match.index) : undefined,
        ),
      );
    }

    for (const match of content.matchAll(
      new RegExp(LEGACY_PLACEHOLDER_RE.source, LEGACY_PLACEHOLDER_RE.flags),
    )) {
      fileHasLegacy = true;
      tokensInFile.add(match[1]);
      warnings.push(
        record(
          "DEPRECATED_LEGACY",
          "warning",
          relPath,
          match[1],
          lineAtOffset(lineStarts, match.index),
          includeContext ? sliceContext(content, match.index) : undefined,
        ),
      );
    }

    if (fileHasNew && fileHasLegacy) {
      info.push(record("MIXED_SYNTAX", "info", relPath, "(multiple)", 0));
    }

    if (tokensInFile.size > 0) {
      files.push({ path: relPath, tokens: [...tokensInFile] });
    }
  }

  sortStable(errors);
  sortStable(warnings);
  sortStable(info);

  return {
    errors,
    warnings,
    info,
    clean: errors.length === 0,
    files,
  };
}

// ─── deprojectify ───────────────────────────────────────────────────────────

/**
 * Reverse operation: replace resolved values with placeholder tokens.
 * Default emits <X> (new form). Pass {legacy: true} to emit {X}.
 *
 * @param {string} content
 * @param {Map<string,string>} valuesMap
 * @param {object} [options]
 * @param {boolean} [options.legacy=false]
 */
function deprojectify(content, valuesMap, options = {}) {
  const opts = { legacy: false, ...options };
  const wrap = opts.legacy ? (t) => `{${t}}` : (t) => `<${t}>`;
  const replacements = [];

  const reverseEntries = [];
  for (const [token, value] of valuesMap) {
    if (value.length < 3) continue;
    reverseEntries.push({ token, value });
  }

  reverseEntries.sort((a, b) => b.value.length - a.value.length);

  let deprojectified = content;
  for (const { token, value } of reverseEntries) {
    const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    const re = new RegExp(escaped, "g");
    let matched = false;
    deprojectified = deprojectified.replace(re, () => {
      matched = true;
      return wrap(token);
    });
    if (matched) {
      replacements.push({ token, value });
    }
  }

  return { deprojectified, replacements };
}

// ─── parseSections ──────────────────────────────────────────────────────────

/**
 * Split markdown content into sections based on headings.
 * Returns an array of { heading, level, body } objects.
 * Content before the first heading is stored as __preamble__ (level 0).
 */
function parseSections(content) {
  const lines = content.split("\n");
  const sections = [];
  let currentHeading = "__preamble__";
  let currentLevel = 0;
  let currentLines = [];

  for (const line of lines) {
    const match = line.match(/^(#{1,6})\s+(.+)$/);
    if (match) {
      // Save previous section
      sections.push({
        heading: currentHeading,
        level: currentLevel,
        body: currentLines.join("\n"),
      });
      currentHeading = match[2];
      currentLevel = match[1].length;
      currentLines = [line];
    } else {
      currentLines.push(line);
    }
  }

  // Save final section
  sections.push({
    heading: currentHeading,
    level: currentLevel,
    body: currentLines.join("\n"),
  });

  return sections;
}

// ─── Section merge helpers ──────────────────────────────────────────────────

/**
 * Normalize a heading string for comparison.
 * Lowercases, trims, strips special characters.
 */
function normalizeHeading(heading) {
  return heading
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9\s-]/g, "")
    .replace(/\s+/g, " ");
}

// ─── mergeConstantsSections ─────────────────────────────────────────────────

/**
 * Constants-specific section merge for project-constants.md.
 * Adds new sections from template, adds new rows to existing sections,
 * never overwrites filled-in values with placeholders.
 * Preserves conditional markers (<!-- START:X --> / <!-- END:X -->).
 *
 * Returns { merged, sectionsAdded, rowsAdded }.
 */
function mergeConstantsSections(templateContent, deployedContent) {
  const templateSections = parseSections(templateContent);
  const deployedSections = parseSections(deployedContent);

  // Build lookup of deployed sections by normalized heading
  const deployedMap = new Map();
  for (const section of deployedSections) {
    deployedMap.set(normalizeHeading(section.heading), section);
  }

  let sectionsAdded = 0;
  let rowsAdded = 0;
  const resultSections = [];
  const processedDeployed = new Set();

  for (const tplSection of templateSections) {
    const normHeading = normalizeHeading(tplSection.heading);
    const deployed = deployedMap.get(normHeading);

    if (!deployed) {
      // New section from template — add it
      resultSections.push(tplSection);
      if (tplSection.heading !== "__preamble__") {
        sectionsAdded++;
      }
    } else {
      processedDeployed.add(normHeading);

      // Section exists in deployed — merge table rows
      const tplRows = extractTableRows(tplSection.body);
      const depRows = extractTableRows(deployed.body);

      if (tplRows.length === 0 || depRows.length === 0) {
        // No table to merge — keep deployed
        resultSections.push(deployed);
        continue;
      }

      // Build set of deployed row keys (first column, UPPER_SNAKE_CASE)
      const depKeys = new Set();
      for (const row of depRows) {
        const key = extractRowKey(row);
        if (key) depKeys.add(key);
      }

      // Find new rows from template not in deployed
      const newRows = [];
      for (const row of tplRows) {
        const key = extractRowKey(row);
        if (key && !depKeys.has(key)) {
          newRows.push(row);
        }
      }

      if (newRows.length > 0) {
        // Insert new rows before the last empty line / end of deployed body
        const mergedBody = insertTableRows(deployed.body, newRows);
        resultSections.push({
          heading: deployed.heading,
          level: deployed.level,
          body: mergedBody,
        });
        rowsAdded += newRows.length;
      } else {
        resultSections.push(deployed);
      }
    }
  }

  // Append deployed sections not in template (user-added)
  for (const section of deployedSections) {
    const normHeading = normalizeHeading(section.heading);
    if (
      !processedDeployed.has(normHeading) &&
      normHeading !== normalizeHeading("__preamble__")
    ) {
      resultSections.push(section);
    }
  }

  const merged = resultSections.map((s) => s.body).join("\n");
  return { merged, sectionsAdded, rowsAdded };
}

/**
 * Extract table data rows (not headers or separators) from section body.
 */
function extractTableRows(body) {
  const lines = body.split("\n");
  const rows = [];
  let inTable = false;
  let headerSeen = false;

  for (const line of lines) {
    if (!line.startsWith("|")) {
      if (inTable) break; // End of table
      continue;
    }

    if (!inTable) {
      inTable = true;
      // First | row is header
      continue;
    }

    // Second | row is separator
    if (!headerSeen) {
      headerSeen = true;
      continue;
    }

    rows.push(line);
  }

  return rows;
}

/**
 * Extract the key (first column) from a table row if it's UPPER_SNAKE_CASE.
 */
function extractRowKey(row) {
  const cells = row
    .split("|")
    .slice(1, -1)
    .map((c) => c.trim());
  if (cells.length < 1) return null;
  const key = cells[0];
  return /^[A-Z][A-Z0-9_]*$/.test(key) ? key : null;
}

/**
 * Insert new table rows at the end of the table in a section body.
 */
function insertTableRows(body, newRows) {
  const lines = body.split("\n");
  // Find the last table row
  let lastTableIdx = -1;
  for (let i = lines.length - 1; i >= 0; i--) {
    if (lines[i].startsWith("|")) {
      lastTableIdx = i;
      break;
    }
  }

  if (lastTableIdx === -1) return body;

  const before = lines.slice(0, lastTableIdx + 1);
  const after = lines.slice(lastTableIdx + 1);
  return [...before, ...newRows, ...after].join("\n");
}

// ─── mergeSections ──────────────────────────────────────────────────────────

/**
 * Generic section merge for CLAUDE.md and project-constitution.md.
 * - Template sections existing in deployed with different content → preserve deployed
 * - Template sections not in deployed → add (new section)
 * - Deployed sections not in template → preserve at end
 * - Output follows template section order
 * - Updates version/date in preamble
 *
 * Returns { merged, sectionsAdded, sectionsPreserved }.
 */
function mergeSections(templateContent, deployedContent) {
  const templateSections = parseSections(templateContent);
  const deployedSections = parseSections(deployedContent);

  // Build lookup of deployed sections by normalized heading
  const deployedMap = new Map();
  for (const section of deployedSections) {
    deployedMap.set(normalizeHeading(section.heading), section);
  }

  let sectionsAdded = 0;
  let sectionsPreserved = 0;
  const resultSections = [];
  const processedDeployed = new Set();

  for (const tplSection of templateSections) {
    const normHeading = normalizeHeading(tplSection.heading);
    const deployed = deployedMap.get(normHeading);

    if (normHeading === normalizeHeading("__preamble__")) {
      // Preamble: use template version (has updated version/date)
      resultSections.push(tplSection);
      processedDeployed.add(normHeading);
      continue;
    }

    if (!deployed) {
      // New section from template — add it
      resultSections.push(tplSection);
      sectionsAdded++;
    } else {
      processedDeployed.add(normHeading);
      // Section exists in deployed — keep deployed version (user customized)
      resultSections.push(deployed);
      sectionsPreserved++;
    }
  }

  // Append deployed sections not in template (user-added)
  for (const section of deployedSections) {
    const normHeading = normalizeHeading(section.heading);
    if (
      !processedDeployed.has(normHeading) &&
      normHeading !== normalizeHeading("__preamble__")
    ) {
      resultSections.push(section);
      sectionsPreserved++;
    }
  }

  const merged = resultSections.map((s) => s.body).join("\n");
  return { merged, sectionsAdded, sectionsPreserved };
}

// ─── Helper utilities ────────────────────────────────────────────────────────

function computeLineStarts(content) {
  const starts = [0];
  for (let i = 0; i < content.length; i++) {
    if (content.charCodeAt(i) === 10) starts.push(i + 1);
  }
  return starts;
}

function lineAtOffset(lineStarts, offset) {
  let lo = 0,
    hi = lineStarts.length - 1;
  while (lo < hi) {
    const mid = (lo + hi + 1) >>> 1;
    if (lineStarts[mid] <= offset) lo = mid;
    else hi = mid - 1;
  }
  return lo + 1;
}

function sliceContext(content, offset, width = 20) {
  const start = Math.max(0, offset - width);
  const end = Math.min(content.length, offset + width);
  return content.slice(start, end).replace(/\s+/g, " ");
}

function record(code, level, file, token, line, context) {
  const r = { code, level, file, token, line };
  if (context !== undefined) r.context = context;
  return r;
}

function sortStable(arr) {
  arr.sort((a, b) =>
    a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line,
  );
}

// ─── Exports ────────────────────────────────────────────────────────────────

module.exports = {
  PLACEHOLDER_RE,
  LEGACY_PLACEHOLDER_RE,
  NEW_PLACEHOLDER_RE,
  parseConstants,
  resolveTemplate,
  resolveFile,
  computeResolvedChecksum,
  diffClassify,
  buildChangeSet,
  applyTemplate,
  applyTemplateDir,
  verifyNoUnresolved,
  deprojectify,
  parseSections,
  mergeConstantsSections,
  mergeSections,
  // Internal helpers exported for testing
  collectFiles,
  globToRegex,
  matchesExclude,
  normalizeHeading,
  // New internal helpers for tests + migration scripts
  computeLineStarts,
  lineAtOffset,
};
