/**
 * CLI helper: deterministic accept/discard of variant sessions.
 *
 * Usage:
 *   node live-accept.mjs --id SESSION_ID --discard
 *   node live-accept.mjs --id SESSION_ID --variant N
 *
 * For discard: removes the entire variant wrapper and restores the original.
 * For accept: replaces the wrapper with the chosen variant's content. If the
 * session had a colocated <style> block, it's preserved with carbonize markers
 * for a background agent to integrate into the project's CSS.
 *
 * Output: JSON to stdout.
 */

import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';

const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

export async function acceptCli() {
  const args = process.argv.slice(2);

  if (args.includes('--help') || args.includes('-h')) {
    console.log(`Usage: node live-accept.mjs [options]

Deterministic accept/discard for live variant sessions.

Modes:
  --discard          Remove variants, restore original
  --variant N        Accept variant N, discard the rest

Required:
  --id SESSION_ID    Session ID of the variant wrapper

Options:
  --page-url URL     Current browser page URL; scopes staged copy-edit cleanup

Output (JSON):
  { handled, file, carbonize }`);
    process.exit(0);
  }

  const id = argVal(args, '--id');
  const variantNum = argVal(args, '--variant');
  const paramValuesRaw = argVal(args, '--param-values');
  const pageUrl = argVal(args, '--page-url');
  const isDiscard = args.includes('--discard');

  if (!id) { console.error('Missing --id'); process.exit(1); }
  if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }

  let paramValues = null;
  if (paramValuesRaw) {
    try { paramValues = JSON.parse(paramValuesRaw); }
    catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
  }

  // Find the file containing this session's markers
  const found = findSessionFile(id, process.cwd());
  if (!found) {
    console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
    process.exit(0);
  }

  const { file: targetFile, content, lines } = found;
  const relFile = path.relative(process.cwd(), targetFile);

  // Bail if the session lives in a generated file. The agent manually wrote
  // the wrapper there for preview, and is responsible for writing the
  // accepted variant to true source (or cleaning up on discard). See
  // "Handle fallback" in live.md.
  if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
    console.log(JSON.stringify({
      handled: false,
      mode: 'fallback',
      file: relFile,
      hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
    }));
    process.exit(0);
  }

  if (isDiscard) {
    const result = handleDiscard(id, lines, targetFile);
    console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
  } else {
    const result = handleAccept(id, variantNum, lines, targetFile, paramValues);
    const acceptedOriginalText = result.acceptedOriginalText || '';
    delete result.acceptedOriginalText;
    // Single-line attention-grabber when cleanup is required. The full
    // five-step checklist lives in reference/live.md (loaded once per
    // session); repeating it per-event would waste tokens.
    if (result.carbonize) {
      result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';
    }
    // Scrub stash entries whose text appeared inside the just-replaced
    // original wrap block. The accept embodies those manual edits (wrap was
    // buffer-aware), so only those scoped ops are redundant.
    if (result.handled !== false) {
      try {
        scrubManualEditsAgainstOriginalBlock(acceptedOriginalText, process.cwd(), pageUrl);
      } catch {
        // Non-fatal; the buffer stays as-is and the user can discard later.
      }
    }
    console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
  }
}

/**
 * After a variant accept rewrites one wrapper, drop only buffer ops whose
 * text appeared inside that wrapper's original block. The previous file-wide
 * scrub dropped unrelated staged edits from other components/files whenever
 * their originalText wasn't present in the just-accepted file.
 *
 * Match both originalText and newText because live-wrap rewrites the original
 * preview block to reflect pending manual edits before variants are generated.
 */
function scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd = process.cwd(), pageUrl = null) {
  const originalBlock = String(originalBlockText || '');
  if (!originalBlock) return;
  if (!pageUrl) return;
  const buffer = readManualEditsBuffer(cwd);
  if (buffer.entries.length === 0) return;
  let mutated = false;
  for (const entry of buffer.entries) {
    if (entry.pageUrl !== pageUrl) continue;
    const before = entry.ops.length;
    entry.ops = entry.ops.filter((op) => {
      return !manualEditOpAppearsInBlock(op, originalBlock);
    });
    if (entry.ops.length !== before) mutated = true;
  }
  buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0);
  if (mutated) writeManualEditsBuffer(cwd, buffer);
}

function manualEditOpAppearsInBlock(op, originalBlock) {
  const candidates = [op?.newText, op?.originalText]
    .filter((text) => typeof text === 'string' && text.length > 0);
  return candidates.some((text) => originalBlockHasExactManualText(originalBlock, text));
}

function originalBlockHasExactManualText(originalBlock, text) {
  const needle = normalizeManualEditText(text);
  if (!needle) return false;
  return manualEditTextSegments(originalBlock).some((segment) => segment === needle);
}

function manualEditTextSegments(source) {
  return String(source || '')
    .replace(/<[^>]*>/g, '\n')
    .replace(/\{\/\*[\s\S]*?\*\/\}/g, '\n')
    .replace(/<!--[\s\S]*?-->/g, '\n')
    .split(/\n+/)
    .map(normalizeManualEditText)
    .filter(Boolean);
}

function normalizeManualEditText(text) {
  return String(text || '').replace(/\s+/g, ' ').trim();
}

// Compatibility export for older tests/callers. The unsafe file-wide scrub was
// removed; callers must pass accepted original-block text for scoped cleanup.
function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalBlockText = '', pageUrl = null) {
  return scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl);
}

// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------

function handleDiscard(id, lines, targetFile) {
  const block = findMarkerBlock(id, lines);
  if (!block) return { handled: false, error: 'Markers not found' };

  const original = extractOriginal(lines, block);
  const isJsx = detectCommentSyntax(targetFile).open === '{/*';
  const replaceRange = expandReplaceRange(block, lines, isJsx);

  // Restore at the line we're actually replacing FROM, not the marker line.
  // For JSX wrappers the marker comments live INSIDE the outer `<div>`, so
  // `block.start` sits 2 spaces deeper than the original element. Using that
  // as the deindent base would push the restored content 2 spaces too far
  // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper
  // line, which is at the original element's indent for both HTML and JSX.
  const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
  const restored = deindentContent(original, indent);

  const newLines = [
    ...lines.slice(0, replaceRange.start),
    ...restored,
    ...lines.slice(replaceRange.end + 1),
  ];
  fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
  return {};
}

// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------

function handleAccept(id, variantNum, lines, targetFile, paramValues) {
  const block = findMarkerBlock(id, lines);
  if (!block) return { handled: false, error: 'Markers not found' };

  const commentSyntax = detectCommentSyntax(targetFile);
  const isJsx = commentSyntax.open === '{/*';
  // Anchor indent on the line we're replacing FROM (the outer wrapper),
  // not on `block.start` — for JSX that's the marker comment 2 spaces
  // deeper than the original element. See handleDiscard for the full
  // rationale.
  const replaceRange = expandReplaceRange(block, lines, isJsx);
  const indent = lines[replaceRange.start].match(/^(\s*)/)[1];

  // Extract the chosen variant's inner content
  const variantContent = extractVariant(lines, block, variantNum);
  if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
  const originalContent = extractOriginal(lines, block);

  // Extract CSS block if present
  const cssContent = extractCss(lines, block, id);

  // Check if carbonizing is needed:
  // - CSS block exists, OR
  // - variant HTML contains helper classes/attributes that need cleanup
  const variantText = variantContent.join('\n');
  const hasHelperAttrs = variantText.includes('data-impeccable-variant');
  const needsCarbonize = !!(cssContent || hasHelperAttrs);

  // Build the replacement
  const restored = deindentContent(variantContent, indent);
  const replacement = [];

  if (cssContent) {
    replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
    // JSX targets need the CSS body wrapped in a template literal so that the
    // `{` and `}` in CSS rules don't get parsed as JSX expressions.
    replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
    // Re-indent CSS content to match
    for (const cssLine of cssContent) {
      replacement.push(indent + cssLine.trimStart());
    }
    replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
    if (paramValues && Object.keys(paramValues).length > 0) {
      // Preserve the user's knob positions for the carbonize-cleanup agent
      // to bake into the final CSS when it collapses scoped rules.
      replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
    }
    replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
  }

  // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
  // carbonize CSS block working visually by re-wrapping the accepted content
  // in a data-impeccable-variant="N" div with `display: contents` (so layout
  // isn't affected). The carbonize agent strips this attribute + wrapper when
  // it moves the CSS to a proper stylesheet.
  //
  // Style attribute syntax has to follow the host file's flavor — JSX files
  // need the object form, otherwise React 19 throws "Failed to set indexed
  // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
  if (cssContent) {
    const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
    replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
    replacement.push(...restored);
    replacement.push(indent + '</div>');
  } else {
    replacement.push(...restored);
  }

  const newLines = [
    ...lines.slice(0, replaceRange.start),
    ...replacement,
    ...lines.slice(replaceRange.end + 1),
  ];
  fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');

  return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
}

// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------

/**
 * Find the start/end marker lines for a session.
 * Returns { start, end } (0-indexed line numbers) or null.
 */
function findMarkerBlock(id, lines) {
  let start = -1;
  let end = -1;
  const startPattern = 'impeccable-variants-start ' + id;
  const endPattern = 'impeccable-variants-end ' + id;

  for (let i = 0; i < lines.length; i++) {
    if (start === -1 && lines[i].includes(startPattern)) start = i;
    if (lines[i].includes(endPattern)) { end = i; break; }
  }

  return (start !== -1 && end !== -1) ? { start, end, id } : null;
}

/**
 * Compute the line range to REPLACE (vs. just the marker range to extract
 * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
 * the `<div data-impeccable-variants="ID">` outer wrapper so the picked
 * element's JSX slot keeps a single child — a Fragment `<></>` would have
 * solved the multi-sibling case but failed inside `asChild` / cloneElement
 * parents with "Invalid prop supplied to React.Fragment".
 *
 * That means the marker block is enclosed by the wrapper `<div>` opener
 * (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
 * walk back to the opener and forward to the closer so accept/discard
 * remove the entire scaffold, not just the inner markers.
 *
 * Marker lines themselves stay where they were so extractOriginal /
 * extractVariant / extractCss continue to walk the same range.
 */
function expandReplaceRange(block, lines, isJsx) {
  if (!isJsx) return { start: block.start, end: block.end };

  let { start, end } = block;

  // Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
  // The attr may sit on a continuation line of a multi-line opening tag, so
  // also walk to the line that actually contains `<div`.
  for (let i = start - 1; i >= 0; i--) {
    if (isVariantEndMarkerLine(lines[i], block.id)) break;
    if (hasVariantWrapperAttr(lines[i], block.id)) {
      let opener = i;
      while (opener > 0 && !/<div\b/.test(lines[opener]) && !isVariantEndMarkerLine(lines[opener], block.id)) {
        opener--;
      }
      if (/<div\b/.test(lines[opener])) start = opener;
      break;
    }
  }

  // Walk forward to the matching `</div>` by div-depth tracking from the
  // wrapper opener. Operate on JOINED text instead of per-line: a
  // multi-line self-closing JSX `<div\n  className="spacer"\n/>` would
  // fool per-line regex tracking (the `<div` line matches openRe but the
  // `/>` line never matches selfCloseRe since it needs `<div` on the same
  // line). That left depth permanently over-counted and the wrapper's
  // outer `</div>` orphaned after accept/discard. Single regex with
  // `[^>]*?` (which spans newlines in JS) handles either form correctly.
  const joined = lines.slice(start).join('\n');
  // Match either `<div … />` (self-close, group 1 is `/`), `<div … >`
  // (open, group 1 is empty), or `</div>`.
  const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
  let depth = 0;
  let m;
  while ((m = tagRe.exec(joined)) !== null) {
    const isClose = m[0].startsWith('</');
    const isSelfClose = !isClose && m[1] === '/';
    if (isClose) depth--;
    else if (!isSelfClose) depth++;
    if (depth <= 0) {
      // m.index is offset within `joined`; convert back to a file line.
      const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
      const candidateEnd = start + linesBefore;
      if (candidateEnd >= end) {
        end = candidateEnd;
        break;
      }
    }
  }

  return { start, end };
}

function escapeRegExp(value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function isVariantEndMarkerLine(line, id) {
  return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line);
}

function hasVariantWrapperAttr(line, id) {
  const escaped = escapeRegExp(id);
  return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line);
}

/**
 * Join wrapper lines into a single string with `<style>` elements removed so
 * marker matching and div-depth tracking aren't confused by:
 *   - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the
 *     HTML marker we're searching for
 *   - JSX self-closing `<style ... />` (no separate `</style>` to close on)
 *   - Same-line `<style>…</style>` blocks
 *   - Multi-line `<style>\n…\n</style>` blocks
 */
function stripStyleAndJoin(lines, block) {
  const out = [];
  let inStyle = false;
  for (let i = block.start; i <= block.end; i++) {
    let line = lines[i];

    if (!inStyle) {
      // Strip any complete <style> elements on this line (self-closed or
      // same-line-closed), including their body content.
      line = line
        .replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')
        .replace(/<style\b[^>]*\/\s*>/g, '');

      // If a <style> opener remains (multi-line body starts here), strip from
      // the opener to end-of-line and flip into skip mode.
      const openerIdx = line.search(/<style\b/);
      if (openerIdx !== -1) {
        line = line.slice(0, openerIdx);
        inStyle = true;
      }
      out.push(line);
    } else {
      // In multi-line style body; drop everything until we see </style>.
      const closeIdx = line.search(/<\/style\s*>/);
      if (closeIdx !== -1) {
        inStyle = false;
        out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
      }
      // else: skip line entirely
    }
  }
  return out.join('\n');
}

/**
 * Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,
 * handling nested same-tag elements via depth counting. `attrMatch` is a
 * regex source fragment that must appear inside the opener tag.
 * Returns the inner string (may be empty), or null if not found.
 */
function extractInnerByAttr(text, attrMatch) {
  const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
  const openMatch = text.match(openerRe);
  if (!openMatch) return null;

  const tagName = openMatch[1];
  const innerStart = openMatch.index + openMatch[0].length;

  // Match any opener or closer of this tag name after innerStart.
  // (Does not match self-closing <TAG … />, which doesn't contribute to depth.)
  const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
  tagRe.lastIndex = innerStart;

  let depth = 1;
  let m;
  while ((m = tagRe.exec(text))) {
    const isClose = m[0].startsWith('</');
    const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
    if (isClose) {
      depth--;
      if (depth === 0) return text.slice(innerStart, m.index);
    } else if (!isSelfClose) {
      depth++;
    }
  }
  return null;
}

/**
 * Extract the original element content from within the variant wrapper.
 * Returns an array of lines.
 */
function extractOriginal(lines, block) {
  const text = stripStyleAndJoin(lines, block);
  const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
  if (inner === null) return [];
  return inner.split('\n');
}

/**
 * Extract a specific variant's inner content (stripping the wrapper div).
 * Returns an array of lines, or null if not found.
 */
function extractVariant(lines, block, variantNum) {
  const text = stripStyleAndJoin(lines, block);
  const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
  if (inner === null) return null;
  const result = inner.split('\n');
  // Collapse a lone empty leading/trailing line (common after string splice).
  while (result.length > 1 && result[0].trim() === '') result.shift();
  while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
  return result.length > 0 ? result : null;
}

/**
 * Extract the colocated <style> block content (between the style tags).
 * Returns an array of CSS lines, or null if no style block found.
 *
 * Handles three shapes of `<style data-impeccable-css="ID" ...>`:
 *   1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).
 *   2. Same-line open+close: `<style>...</style>` — return the inner content.
 *   3. Multi-line: `<style>` on one line, `</style>` on a later line — return
 *      the lines between them.
 */
function extractCss(lines, block, id) {
  const styleAttr = 'data-impeccable-css="' + id + '"';
  let inStyle = false;
  const content = [];

  for (let i = block.start; i <= block.end; i++) {
    const line = lines[i];

    if (!inStyle && line.includes(styleAttr)) {
      // Self-closing: nothing to carbonize.
      if (/<style\b[^>]*\/\s*>/.test(line)) return null;
      // Same-line open + close: extract inner text.
      const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
      if (sameLine) {
        const inner = stripJsxTemplateWrap(sameLine[1]);
        return inner.length > 0 ? inner.split('\n') : null;
      }
      inStyle = true;
      continue; // skip the <style> opening tag
    }

    if (inStyle) {
      // Detect </style> anywhere on the line — JSX template-literal closes
      // (`}</style>`) put the close mid-line, and we don't want to absorb the
      // template-literal punctuation as CSS content.
      const closeIdx = line.indexOf('</style>');
      if (closeIdx !== -1) break;
      content.push(line);
    }
  }

  if (content.length === 0) return null;
  return stripJsxTemplateLines(content);
}

/**
 * Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
 * `<style>` element in a JSX/TSX file. The agent may write the wrap with
 * `{` and `}` directly attached to the `<style>` tags, on their own lines,
 * or attached to the first/last CSS lines — all three are JSX-legal.
 *
 * Stripping is required because handleAccept re-wraps the CSS itself when
 * carbonizing. Without this, two consecutive accepts (or a previously-
 * accepted variants block being carbonized) would produce nested
 * `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
 */
function stripJsxTemplateLines(content) {
  const out = content.slice();

  // Drop any leading blank lines so we don't miss a `{` line buried below
  // them; same for trailing.
  while (out.length > 0 && out[0].trim() === '') out.shift();
  while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
  if (out.length === 0) return null;

  // Leading `{`: own line, or attached to the first CSS line.
  const firstTrim = out[0].trimStart();
  if (firstTrim === '{`') {
    out.shift();
  } else if (firstTrim.startsWith('{`')) {
    const idx = out[0].indexOf('{`');
    out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
    if (out[0].trim() === '') out.shift();
  }
  if (out.length === 0) return null;

  // Trailing `` ` `` `}`: own line, or attached to the last CSS line.
  const lastIdx = out.length - 1;
  const lastTrim = out[lastIdx].trimEnd();
  if (lastTrim === '`}') {
    out.pop();
  } else if (lastTrim.endsWith('`}')) {
    const text = out[lastIdx];
    const idx = text.lastIndexOf('`}');
    out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
    if (out[lastIdx].trim() === '') out.pop();
  }

  return out.length > 0 ? out : null;
}

function stripJsxTemplateWrap(text) {
  const lines = text.split('\n');
  const stripped = stripJsxTemplateLines(lines);
  return stripped ? stripped.join('\n') : '';
}

/**
 * De-indent content that was indented by live-wrap.mjs.
 * The wrap script adds `indent + '    '` (4 extra spaces) to each line.
 * We restore to just `indent` level.
 */
function deindentContent(contentLines, baseIndent) {
  // Find the minimum indentation in the content to determine how much was added
  let minIndent = Infinity;
  for (const line of contentLines) {
    if (line.trim() === '') continue;
    const leadingSpaces = line.match(/^(\s*)/)[1].length;
    minIndent = Math.min(minIndent, leadingSpaces);
  }
  if (minIndent === Infinity) minIndent = 0;

  // Strip the extra indentation and re-add base indent
  return contentLines.map(line => {
    if (line.trim() === '') return '';
    return baseIndent + line.slice(minIndent);
  });
}

function detectCommentSyntax(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  if (ext === '.jsx' || ext === '.tsx') {
    return { open: '{/*', close: '*/}' };
  }
  return { open: '<!--', close: '-->' };
}

// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------

function findSessionFile(id, cwd) {
  const marker = 'impeccable-variants-start ' + id;
  const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
  const seen = new Set();

  for (const dir of searchDirs) {
    const absDir = path.join(cwd, dir);
    if (!fs.existsSync(absDir)) continue;
    const result = searchDir(absDir, marker, seen, 0);
    if (result) {
      const content = fs.readFileSync(result, 'utf-8');
      return { file: result, content, lines: content.split('\n') };
    }
  }
  return null;
}

function searchDir(dir, query, seen, depth) {
  if (depth > 5) return null;
  let realDir;
  try { realDir = fs.realpathSync(dir); } catch { return null; }
  if (seen.has(realDir)) return null;
  seen.add(realDir);

  let entries;
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
  catch { return null; }

  for (const entry of entries) {
    if (!entry.isFile()) continue;
    if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
    const filePath = path.join(dir, entry.name);
    try {
      const content = fs.readFileSync(filePath, 'utf-8');
      if (content.includes(query)) return filePath;
    } catch { /* skip */ }
  }

  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
    const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
    if (result) return result;
  }

  return null;
}

// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------

function argVal(args, flag) {
  const idx = args.indexOf(flag);
  return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}

// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
  acceptCli();
}

export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };
