/**
 * 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 './lib/is-generated.mjs';
import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs';
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs';
import { withSourceLockSync } from './live/source-lock.mjs';
import {
  applyDeferredSvelteComponentAccepts,
  findSvelteComponentManifest,
  inlineSvelteComponentAccept,
  removeSvelteComponentSession,
} from './live/svelte-component.mjs';
import { enterLiveRoot } from './live/roots.mjs';

const ACCEPT_LOCK_WAIT_MS = 1_000;
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
// value arriving over HTTP.
const VARIANT_NUM_PATTERN = /^[0-9]{1,3}$/;

/**
 * A thrown accept/discard is a real failure, not a manual handoff.
 *
 * live/completion.mjs only classifies a result as `error` when it carries
 * `mode: 'error'`; anything else unhandled falls through to `agent_done` with a
 * successful ack, and reference/live.md then tells the agent to finish the edit
 * by hand. That is right for the documented fallback paths and wrong here: a
 * `source_locked` contention needs a retry (hand-editing races the publisher
 * holding the lock), and a crash needs surfacing, not a hand-applied guess.
 */
function operationFailure(err, extra = {}) {
  return { handled: false, mode: 'error', error: err.message, ...extra };
}

/**
 * Mark an unhandled preview-path result as a real failure.
 *
 * operationFailure only covers results built from a *thrown* error. The accept
 * implementations also return `{handled: false, error}` for their own checks
 * (variant missing, template empty, original text ambiguous), and those arrived
 * without `mode`, so completion.mjs classified them as agent_done and
 * reference/live.md routed the agent to "read file, find markers, edit".
 *
 * That handoff only makes sense for a plain wrapper session, which is the one
 * shape with markers in the user's source to edit. Component and isolated
 * artifact previews keep the source clean until Accept, so there is nothing to
 * hand-edit and an unhandled result is always a failure. `previewMode` is
 * exactly that discriminator: only the preview branches set it.
 */
function markPreviewFailure(result) {
  if (result?.handled === false && !result.mode && result.previewMode) {
    return { ...result, mode: 'error' };
  }
  return result;
}

// ---------------------------------------------------------------------------
// 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
  --defer-source-write
                     Deprecated compatibility flag. Svelte component accepts
                     now write the real source immediately.

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); }
  // `id` becomes a path segment (accept receipts, preview manifests, generated
  // component dirs). Reject separators and traversal here so one check covers
  // every downstream sink.
  try { safeSessionId(id); } catch { console.error('Invalid --id'); process.exit(1); }
  if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
  // `variantNum` is interpolated into a RegExp and into the markup written back
  // to source. The browser and the /events schema both constrain it to digits;
  // enforce the same here, or `--variant '.*'` matches the `original` block
  // first and silently accepts the original while reporting success.
  if (!isDiscard && !VARIANT_NUM_PATTERN.test(variantNum)) {
    console.error('Invalid --variant');
    process.exit(1);
  }

  const requestedOperation = isDiscard ? 'discard' : 'accept';
  const priorReceipt = readAcceptReceipt(process.cwd(), id);
  if (priorReceipt) {
    const sameOperation = priorReceipt.operation === requestedOperation
      && (isDiscard || String(priorReceipt.variantId) === String(variantNum));
    console.log(JSON.stringify(sameOperation
      ? { ...priorReceipt.result, handled: true, alreadyApplied: true }
      : {
          // mode: 'error' is what marks this a real failure rather than a manual
          // handoff. Without it, live/completion.mjs classifies the reply as
          // agent_done and reference/live.md tells the agent to "read file, find
          // markers, edit" by hand — which would apply a second, conflicting
          // accept on top of the one the receipt already recorded.
          handled: false,
          mode: 'error',
          error: 'accept_receipt_conflict',
          priorOperation: priorReceipt.operation,
          priorVariantId: priorReceipt.variantId ?? null,
        }));
    return;
  }
  const emitResult = (rawResult) => {
    const result = markPreviewFailure(rawResult);
    if (result?.handled !== false) {
      writeAcceptReceipt(process.cwd(), id, {
        operation: requestedOperation,
        variantId: isDiscard ? null : String(variantNum),
        result,
      });
    }
    console.log(JSON.stringify(result));
  };

  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());
  const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());

  if (!found && !svelteComponentManifest) {
    console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
    process.exit(0);
  }

  if (svelteComponentManifest) {
    if (isDiscard) {
      let result;
      try {
        result = withSourceLockSync(
          path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
          'discard:' + id,
          () => {
            removeSvelteComponentSession(id, process.cwd());
            return { handled: true };
          },
          { waitMs: ACCEPT_LOCK_WAIT_MS },
        );
      } catch (err) {
        result = operationFailure(err);
      }
      emitResult({
        ...result,
        file: svelteComponentManifest.sourceFile,
        carbonize: false,
        previewMode: 'svelte-component',
        componentDir: svelteComponentManifest.componentDir,
      });
      return;
    }

    let result;
    try {
      result = withSourceLockSync(
        path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
        'accept:' + id,
        () => inlineSvelteComponentAccept(
          svelteComponentManifest,
          variantNum,
          paramValues,
          process.cwd(),
        ),
        { waitMs: ACCEPT_LOCK_WAIT_MS },
      );
    } catch (err) {
      result = operationFailure(err, {
        file: svelteComponentManifest.sourceFile,
        sourceFile: svelteComponentManifest.sourceFile,
        previewMode: 'svelte-component',
        componentDir: svelteComponentManifest.componentDir,
      });
    }
    if (result.carbonize) {
      result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
    }
    emitResult({ handled: result.handled !== false, ...result });
    return;
  }

  const { file: targetFile, content, lines } = found;
  const relFile = path.relative(process.cwd(), targetFile);
  const previewBlock = findMarkerBlock(id, lines);
  const sourceShadowPreview = previewBlock
    ? readSourceShadowPreviewMeta(content, id)
    : null;

  if (sourceShadowPreview) {
    console.log(JSON.stringify({
      handled: false,
      error: 'source_shadow_preview_deprecated',
      hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
    }));
    process.exit(0);
  }

  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) {
    let result;
    // handleDiscard takes the source lock, which throws SOURCE_LOCKED under
    // contention. Without this catch the CLI exits non-zero with empty stdout
    // and the agent gets no JSON to act on.
    try {
      result = handleDiscard(id, lines, targetFile);
    } catch (err) {
      emitResult(operationFailure(err, { file: relFile }));
      return;
    }
    emitResult({ handled: true, file: relFile, carbonize: false, ...result });
  } else {
    let result;
    try {
      result = handleAccept(id, variantNum, lines, targetFile, paramValues);
    } catch (err) {
      emitResult(operationFailure(err, { file: relFile }));
      return;
    }
    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.
      }
    }
    emitResult({ 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) {
  return withSourceLockSync(targetFile, 'discard:' + id, () => {
    const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
    return handleDiscardUnlocked(id, lines, targetFile);
  }, { waitMs: ACCEPT_LOCK_WAIT_MS });
}

function handleDiscardUnlocked(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
// ---------------------------------------------------------------------------

/**
 * Build carbonize stitch-in lines. JSX targets occupy a single child slot
 * (ternary branch, return value, etc.) — the same constraint as live-wrap.
 * When isJsx, tuck markers + <style> + variant wrapper inside one outer
 * <div data-impeccable-carbonize> so the slot keeps a single root node.
 */
function buildCarbonizeReplacement({
  indent,
  commentSyntax,
  isJsx,
  id,
  variantNum,
  cssContent,
  paramValues,
  restored,
}) {
  const lines = [];
  if (!cssContent) {
    lines.push(...restored);
    return lines;
  }

  const variantStyleAttr = isJsx
    ? "style={{ display: 'contents' }}"
    : 'style="display: contents"';

  const pushCarbonizeBody = (bodyIndent) => {
    const bodyRestored = reindentContent(restored, indent, bodyIndent + '  ');
    lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
    lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
    for (const cssLine of cssContent) {
      lines.push(bodyIndent + cssLine.trimStart());
    }
    lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
    if (paramValues && Object.keys(paramValues).length > 0) {
      lines.push(
        bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
      );
    }
    lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
    lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
    lines.push(...bodyRestored);
    lines.push(bodyIndent + '</div>');
  };

  if (isJsx) {
    const wrapperStyle = 'style={{ display: "contents" }}';
    lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
    pushCarbonizeBody(indent + '  ');
    lines.push(indent + '</div>');
  } else {
    pushCarbonizeBody(indent);
  }

  return lines;
}

function reindentContent(contentLines, fromIndent, toIndent) {
  return contentLines.map((line) => {
    if (line.trim() === '') return '';
    if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
    return toIndent + line.trimStart();
  });
}

function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
  return withSourceLockSync(targetFile, 'accept:' + id, () => {
    const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
    return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
  }, { waitMs: ACCEPT_LOCK_WAIT_MS });
}

function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
  const built = buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues);
  if (built.handled === false) return built;
  fs.writeFileSync(targetFile, built.content, 'utf-8');
  return {
    carbonize: built.carbonize,
    acceptedOriginalText: built.acceptedOriginalText,
  };
}

function buildAcceptedWrappedSource(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);

  const restored = deindentContent(variantContent, indent);
  const replacement = buildCarbonizeReplacement({
    indent,
    commentSyntax,
    isJsx,
    id,
    variantNum,
    cssContent,
    paramValues,
    restored,
  });

  const newLines = [
    ...lines.slice(0, replaceRange.start),
    ...replacement,
    ...lines.slice(replaceRange.end + 1),
  ];
  return {
    content: newLines.join('\n'),
    carbonize: needsCarbonize,
    acceptedOriginalText: originalContent.join('\n'),
  };
}


function readSourceShadowPreviewMeta(content, id) {
  const escaped = escapeRegExp(id);
  const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
  const match = String(content || '').match(wrapperRe);
  if (!match) return null;
  const tag = match[0];
  if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
  const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
  const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
  const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
  if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
  return { sourceFile, sourceStartLine, sourceEndLine };
}

function readHtmlAttr(tag, name) {
  const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
  if (!match) return null;
  return decodeHtmlAttr(match[2]);
}

function decodeHtmlAttr(value) {
  return String(value || '')
    .replace(/&quot;/g, '"')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&amp;/g, '&');
}

// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------

/**
 * Accept also skips `dist` / `build` outright, where wrap descends into them so
 * its `includeGenerated` second pass can report a `generatedMatch`. Accept has
 * no such pass: a marker found in build output is only ever a stale copy of the
 * marker in source.
 */
const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build'];

function findSessionFile(id, cwd) {
  const result = findSourceFile({
    query: 'impeccable-variants-start ' + id,
    cwd,
    extensions: resolveLiveTemplateExtensions(cwd),
    skipDirs: SEARCH_SKIP_DIRS,
  });
  if (!result) return null;
  const content = fs.readFileSync(result, 'utf-8');
  return { file: result, content, lines: content.split('\n') };
}

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

function acceptReceiptPath(cwd, id) {
  return path.join(getLiveDir(cwd), 'accept-receipts', `${safeSessionId(id)}.json`);
}

function readAcceptReceipt(cwd, id) {
  try { return JSON.parse(fs.readFileSync(acceptReceiptPath(cwd, id), 'utf-8')); } catch { return null; }
}

function writeAcceptReceipt(cwd, id, receipt) {
  const file = acceptReceiptPath(cwd, id);
  fs.mkdirSync(path.dirname(file), { recursive: true });
  const value = {
    id,
    ...receipt,
    completedAt: new Date().toISOString(),
  };
  const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
  fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf-8');
  fs.renameSync(temporary, file);
  return value;
}

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/')) {
  enterLiveRoot();
  acceptCli();
}

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