"""
helix_write.py — File-system write tool for the Helix QA framework.

Public API:
  inspect_helix_project(helix_root)              -> dict
  write_files_to_helix(helix_root, files, mode)  -> dict
  read_helix_file(helix_root, relative_path)     -> dict
  list_helix_tree(helix_root)                    -> dict

  Framework existence detection
  inspect_helix_project() returns framework_state: "absent"|"partial"|"present"
  and a recommendation of "scaffold_and_tests" or "tests_only".
  write_files_to_helix() accepts that recommendation as its `mode` argument.

Boilerplate sourcing (scaffold_and_tests mode)
  Missing infra files (all 14 in utils/locators/) are copied from embedded content
  in boilerplate.py — NOT from generated strings and NOT from the filesystem.
  This keeps LocatorHealer.ts (including its inline AX tree healing strategy) in sync
  with the scaffold template. Files already on disk are never touched unless
  force_scaffold=True is passed.

Infrastructure file protection
  In mode="tests_only" all 14 utils/locators/*.ts files are always skipped.
  In mode="scaffold_and_tests" missing infra files are sourced from boilerplate;
  existing ones are skipped unless force_scaffold=True is passed.

Within-file deduplication
  For locators.ts  : new const-object entries are merged; duplicate keys skipped.
  For *.steps.ts   : new step blocks are appended; duplicate regex patterns skipped.
                     Cross-file deduplication is also applied: before writing any
                     *.steps.ts, ALL other *.steps.ts files in src/test/steps/ are
                     scanned for existing patterns.  A pattern already defined in
                     any sibling file is treated as a duplicate — the block is
                     dropped from the incoming content.  This prevents the
                     "Multiple step definitions match" Cucumber error.
  For *.page.ts    : new async methods are appended; duplicate method names skipped.
  For *.feature    : new Scenario blocks are appended; duplicate titles skipped.
                     Cross-file deduplication is also applied: before writing any
                     *.feature file, ALL other *.feature files in
                     src/test/features/ are scanned for existing scenario titles.
                     A title already present in any sibling file is treated as a
                     duplicate and dropped from the incoming content.
  For cucumber.config.ts : new profiles are appended ONLY if the file already exists.
                     If the file does not exist, the profile is skipped entirely.
                     This prevents orphaned config files from being created when
                     no Cucumber config yet exists on disk.

Interface adapter
  The generator emits repo.updateHealed / repo.incrementSuccess / repo.getBBox etc.
  The existing Helix LocatorRepository only has setHealed / getBestSelector / getHealed.
  _adapt_to_helix_interface() rewrites generated content before writing so it
  compiles cleanly against the Helix interface without manual edits.
"""
from __future__ import annotations

import re
from pathlib import Path
from typing import Any

from stlc_agents.agent_helix_writer.tools.boilerplate import BOILERPLATE, INFRA_FILES


def _get_boilerplate_content(file_name: str) -> str | None:
    """Return content of the named boilerplate infra file, or None if not available."""
    return INFRA_FILES.get(file_name)


# ── Infrastructure file names (sourced from embedded INFRA_FILES) ─────────────

_INFRA_FILES: set[str] = set(INFRA_FILES.keys())
_REQUIRED_INFRA = {"LocatorHealer.ts", "LocatorRepository.ts"}
_OPTIONAL_INFRA = _INFRA_FILES - _REQUIRED_INFRA

_INFRA_RE    = re.compile(
    r"(ElementContextHelper|HealApplicator|HealingDashboard|LocatorHealer|LocatorManager"
    r"|LocatorRepository|LocatorRules|LocatorStrategy|PlaywrightHealerLogger|TimingHealer"
    r"|VisualIntentChecker|healix-ci-apply|index|review-server)\.ts$"
)
_LOCATOR_RE  = re.compile(r"locators\.ts$",  re.IGNORECASE)
_PAGE_RE     = re.compile(r"page\.ts$",      re.IGNORECASE)
_STEPS_RE    = re.compile(r"steps\.ts$",     re.IGNORECASE)
_FEATURE_RE  = re.compile(r"\.feature$",     re.IGNORECASE)
_CUCUMBER_RE = re.compile(r"cucumber",       re.IGNORECASE)


# ── Interface adapter ─────────────────────────────────────────────────────

def _adapt_to_helix_interface(content: str) -> str:
    """
    Rewrite generated TypeScript so it compiles against the Helix-QA
    LocatorRepository / LocatorHealer interface.

    Generator emits        → Helix expects
    ─────────────────────────────────────────────────────────────────
    repo.updateHealed(k,s,…) → repo.setHealed(k, s)
    repo.getBBox(key)         → null
    repo.incrementSuccess(k)  → (removed)
    repo.incrementFailure(k)  → (removed)
    repo.queueSuggestion(…)   → (removed)
    repo.updateBoundingBox(…) → (removed)
    this.devtools.captureBoundingBox(…) → (removed)
    fixture().logger          → this.logger
    fixture().locatorRepository → this.repo
    fixture().page            → this.page
    import { Logger } from "winston" → import { HealerLogger }
    import EnvironmentManager → import { environment } from @config/environment
    new EnvironmentManager()  → environment
    this.env.getBaseUrl()     → environment.getConfig().baseUrl
    this.env.getPath('x')     → "x"
    """
    # repo.updateHealed(key, sel, ...) → repo.setHealed(key, sel)
    content = re.sub(
        r"this\.repo\.updateHealed\(\s*([^,)]+),\s*([^,)]+)(?:,[^)]+)?\)",
        r"this.repo.setHealed(\1, \2)",
        content,
    )
    # repo.getBBox(key) → null
    content = re.sub(r"this\.repo\.getBBox\([^)]*\)", "null", content)

    # Remove single-statement method calls that have no Helix equivalent
    for method in ("incrementSuccess", "incrementFailure", "queueSuggestion",
                   "updateBoundingBox", "captureBoundingBox"):
        content = re.sub(
            rf"^\s*(?:this\.repo\.|(?:this\.\w+\.)?){method}\([^)]*\)(?:\.catch\([^)]*\))?;?\s*\n",
            "",
            content,
            flags=re.MULTILINE,
        )

    # fixture() references
    content = re.sub(r"fixture\(\)\.logger\b",            "this.logger", content)
    content = re.sub(r"fixture\(\)\.locatorRepository\b", "this.repo",   content)
    content = re.sub(r"fixture\(\)\.page\b",              "this.page",   content)

    # Winston Logger → Helix HealerLogger
    content = content.replace(
        'import { Logger } from "winston";',
        'import { HealerLogger } from "./LocatorHealer";',
    )
    content = re.sub(r"\bLogger\b(?!\s*=)", "HealerLogger", content)

    # EnvironmentManager → Helix environment singleton
    content = content.replace(
        'import { EnvironmentManager } from "@helper/environment/environmentManager.util";',
        'import { environment } from "@config/environment";',
    )
    # If content uses environment.getConfig() but lacks the import, add it after the last import line
    if "environment.getConfig()" in content and 'from "@config/environment"' not in content:
        import_insert = 'import { environment } from "@config/environment";'
        # Insert after the last import statement
        last_import = content.rfind("\nimport ")
        if last_import != -1:
            end_of_line = content.find("\n", last_import + 1)
            if end_of_line != -1:
                content = content[:end_of_line + 1] + import_insert + "\n" + content[end_of_line + 1:]
    content = re.sub(r"\s*this\.env\s*=\s*new EnvironmentManager\(\);?\s*\n", "\n", content)
    content = content.replace("new EnvironmentManager()", "environment")
    content = content.replace("this.env.getBaseUrl()",    "environment.getConfig().baseUrl")
    content = re.sub(r"this\.env\.getPath\(['\"]([^'\"]+)['\"]\)", r'"\1"', content)

    # Fix super() — cannot access this before super() in TS
    content = content.replace(
        "super(page ?? this.page);",
        "super(page ?? fixture().page);",
    )

    return content


# ── Path resolution ───────────────────────────────────────────────────────

def _resolve_destination(helix_root: Path, file_key: str) -> Path:
    key = file_key.strip()

    if _CUCUMBER_RE.search(key):
        return helix_root / "src" / "config" / "cucumber.config.ts"
    if _INFRA_RE.search(key):
        return helix_root / "src" / "utils" / "locators" / Path(key).name
    if _FEATURE_RE.search(key):
        return helix_root / "src" / "test" / "features" / Path(key).name
    if _STEPS_RE.search(key):
        return helix_root / "src" / "test" / "steps" / Path(key).name
    if _LOCATOR_RE.search(key):
        # Extract the page/feature name from the path
        # Input: src/pages/login-page/locators.ts or src/locators/login-page.locators.ts
        # Output stem: login-page
        path_obj = Path(key)
        
        # First try: if filename is exactly 'locators.ts', use parent directory name
        if path_obj.name == "locators.ts" and path_obj.parent.name:
            stem = path_obj.parent.name
        else:
            # Otherwise extract stem from filename
            fname = path_obj.name
            stem = re.sub(r"\.locators\.ts$", "", fname)
            stem = re.sub(r"\.ts$", "", stem)
            if not stem or stem in ("src", "locators", "pages", "utils"):
                stem = "page"
        
        return helix_root / "src" / "locators" / f"{stem}.locators.ts"
    if _PAGE_RE.search(key):
        return helix_root / "src" / "pages" / Path(key).name

    rel = key.lstrip("/")
    return helix_root / (rel if rel.startswith("src/") else f"src/{rel}")


# ── Within-file merge helpers ─────────────────────────────────────────────

def _merge_locators(existing: str, generated: str) -> tuple[str, list[str], list[str]]:
    """Append locator entries whose keys are not already in existing."""
    existing_keys = set(re.findall(r"^\s{2}(\w+)\s*:", existing, re.MULTILINE))

    new_entries: list[tuple[str, str]] = []
    current_key: str | None = None
    current_lines: list[str] = []

    for line in generated.splitlines():
        key_match = re.match(r"^  (\w+)\s*:\s*\{", line)
        if key_match:
            if current_key:
                new_entries.append((current_key, "\n".join(current_lines)))
            current_key = key_match.group(1)
            current_lines = [line]
        elif current_key:
            current_lines.append(line)
            if re.match(r"^\s*\},?\s*$", line):
                new_entries.append((current_key, "\n".join(current_lines)))
                current_key = None
                current_lines = []

    added: list[str] = []
    skipped: list[str] = []
    append_lines: list[str] = []

    for key, block in new_entries:
        if key in existing_keys:
            skipped.append(key)
        else:
            append_lines.append(block)
            added.append(key)

    if not append_lines:
        return existing, added, skipped

    insertion = "\n" + "\n".join(append_lines)
    merged = re.sub(r"(\n\}\s*as\s+const\s*;)", insertion + r"\1", existing, count=1)
    if merged == existing:
        merged = existing.rstrip() + "\n" + insertion + "\n"
    return merged, added, skipped


def _collect_all_scenario_titles(features_dir: Path, exclude_file: Path | None = None) -> set[str]:
    """Return every scenario title (lower-cased) defined in all *.feature files
    in features_dir, optionally excluding one file (the one currently being written)."""
    _title_re = re.compile(
        r"^\s*Scenario(?:\s+Outline)?\s*:\s*(.+)$", re.MULTILINE | re.IGNORECASE
    )
    titles: set[str] = set()
    if not features_dir.is_dir():
        return titles
    for f in features_dir.glob("*.feature"):
        if exclude_file and f.resolve() == exclude_file.resolve():
            continue
        try:
            titles.update(
                m.group(1).strip().lower()
                for m in _title_re.finditer(f.read_text(encoding="utf-8"))
            )
        except OSError:
            pass
    return titles


def _collect_all_step_patterns(steps_dir: Path, exclude_file: Path | None = None) -> set[str]:
    """Return every /^pattern$/ defined in all *.steps.ts files in steps_dir,
    optionally excluding one file (the one currently being written)."""
    patterns: set[str] = set()
    if not steps_dir.is_dir():
        return patterns
    for f in steps_dir.glob("*.steps.ts"):
        if exclude_file and f.resolve() == exclude_file.resolve():
            continue
        try:
            patterns.update(re.findall(r"/\^([^/]+)\$/", f.read_text(encoding="utf-8")))
        except OSError:
            pass
    return patterns


def _merge_steps(
    existing: str,
    generated: str,
    cross_file_patterns: set[str] | None = None,
) -> tuple[str, list[str], list[str]]:
    """Append step blocks whose regex pattern is not already in existing or in
    any sibling step file (cross_file_patterns)."""
    existing_patterns = set(re.findall(r"/\^([^/]+)\$/", existing))
    forbidden = existing_patterns | (cross_file_patterns or set())

    step_block_re = re.compile(r"^(Given|When|Then)\(", re.MULTILINE)
    parts = step_block_re.split(generated)

    blocks: list[tuple[str, str]] = []
    i = 1
    while i + 1 < len(parts):
        keyword = parts[i]
        body    = parts[i + 1]
        blocks.append((keyword, keyword + "(" + body))
        i += 2

    added:      list[str] = []
    skipped:    list[str] = []
    new_blocks: list[str] = []

    for _kw, block in blocks:
        pat_match = re.search(r"/\^([^/]+)\$/", block)
        pattern = pat_match.group(1) if pat_match else block[:40]
        if pattern in forbidden:
            skipped.append(pattern)
        else:
            new_blocks.append(block)
            added.append(pattern)
            # Track newly added so subsequent blocks in the same batch
            # don't get written twice either
            forbidden.add(pattern)

    merged = existing.rstrip() + ("\n\n" + "\n".join(new_blocks) if new_blocks else "") + "\n"
    return merged, added, skipped


def _parse_feature_blocks(content: str) -> tuple[str, list[str]]:
    """
    Parse a Gherkin feature file into a header string and a list of scenario blocks.

    Each scenario block includes that scenario's own @tag lines so they are
    preserved when the block is appended to an existing file.  The header
    contains everything before the first scenario (Feature:, description, Background:,
    and any feature-level @tags).

    The split is line-by-line so that @tag lines are always attributed to the
    scenario that *follows* them, not the scenario above — which is what a
    regex lookahead over Scenario: would break.
    """
    _is_tag      = re.compile(r"^[ \t]*@")
    _is_scenario = re.compile(r"^[ \t]*Scenario(?:[ \t]+Outline)?[ \t]*:", re.IGNORECASE)

    lines        = content.splitlines(keepends=True)
    header:      list[str] = []
    blocks:      list[str] = []
    current:     list[str] = []
    pending_tags: list[str] = []
    in_scenario  = False

    for line in lines:
        if _is_tag.match(line):
            if in_scenario:
                # This @tag line belongs to the NEXT scenario — flush the current one
                blocks.append("".join(current))
                current      = []
                in_scenario  = False
            pending_tags.append(line)
        elif _is_scenario.match(line):
            if in_scenario:
                # Back-to-back scenarios with no @tag between them (unusual but valid)
                blocks.append("".join(current))
                current = []
            # Start a new scenario block; claim the pending @tags as its own
            current      = pending_tags + [line]
            pending_tags = []
            in_scenario  = True
        else:
            if in_scenario:
                current.append(line)
            else:
                # Line belongs to the header (description, Background, blank lines…)
                header.extend(pending_tags)
                pending_tags = []
                header.append(line)

    # Flush the last scenario
    if current:
        blocks.append("".join(current))
    # Trailing tags (e.g. a file that ends with a @tag but no Scenario) go to header
    if pending_tags:
        header.extend(pending_tags)

    return "".join(header), blocks


def _merge_feature_scenarios(
    existing: str,
    generated: str,
    cross_file_titles: set[str] | None = None,
) -> tuple[str, list[str], list[str]]:
    """
    Append Scenario / Scenario Outline blocks whose titles are not already present
    in this file or in any sibling feature file (cross_file_titles).

    Deduplication is by title (case-insensitive exact match).  When a collision is
    found the existing scenario — including its original step wording — is kept and
    the generated version is discarded.  The Feature: header from the generated
    content is always ignored; only newly-titled scenario blocks are appended.

    @tag lines are correctly attributed to the scenario that follows them so
    new scenarios are appended with their own tags intact and no dangling tags
    are left behind from skipped duplicates.
    """
    _scenario_title_re = re.compile(
        r"^\s*Scenario(?:\s+Outline)?\s*:\s*(.+)$", re.MULTILINE | re.IGNORECASE
    )

    existing_titles = {
        m.group(1).strip().lower() for m in _scenario_title_re.finditer(existing)
    }
    forbidden = existing_titles | (cross_file_titles or set())

    _, gen_blocks = _parse_feature_blocks(generated)

    added:      list[str] = []
    skipped:    list[str] = []
    new_blocks: list[str] = []

    for block in gen_blocks:
        title_match = _scenario_title_re.search(block)
        if not title_match:
            continue
        title = title_match.group(1).strip()
        if title.lower() in forbidden:
            skipped.append(title)
        else:
            new_blocks.append(block.rstrip())
            added.append(title)
            forbidden.add(title.lower())

    if not new_blocks:
        return existing, added, skipped

    merged = existing.rstrip() + "\n\n" + "\n\n".join(new_blocks) + "\n"
    return merged, added, skipped


def _merge_page_methods(existing: str, generated: str) -> tuple[str, list[str], list[str]]:
    """Append async methods whose names are not already in existing."""
    existing_methods = set(re.findall(r"async\s+(\w+)\s*\(", existing))

    method_re = re.compile(r"(  async\s+\w+\s*\([^)]*\)[^{]*\{)", re.MULTILINE)
    raw_parts = method_re.split(generated)

    method_blocks: list[tuple[str, str]] = []
    i = 1
    while i + 1 < len(raw_parts):
        sig          = raw_parts[i]
        body_and_rest = raw_parts[i + 1]
        depth, end = 1, 0
        for ch in body_and_rest:
            if ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    break
            end += 1
        method_blocks.append((sig, sig + body_and_rest[: end + 1]))
        i += 2

    added:       list[str] = []
    skipped:     list[str] = []
    new_methods: list[str] = []

    for sig, block in method_blocks:
        name_match = re.search(r"async\s+(\w+)\s*\(", sig)
        name = name_match.group(1) if name_match else sig[:30]
        if name in existing_methods:
            skipped.append(name)
        else:
            new_methods.append(block)
            added.append(name)

    if not new_methods:
        return existing, added, skipped

    insertion = "\n" + "\n\n".join(new_methods) + "\n"
    merged = re.sub(r"\n\}\s*\n?$", insertion + "\n}\n", existing, count=1)
    if merged == existing:
        merged = existing.rstrip() + insertion + "}\n"
    return merged, added, skipped


# ── Framework state inspection ────────────────────────────────────────────

def inspect_helix_project(helix_root: str) -> dict[str, Any]:
    """
    Examine the Helix-QA project root and return a machine-readable verdict.

    framework_state: "absent" | "partial" | "present"
    recommendation:  "scaffold_and_tests" | "tests_only"

    "absent"   helix_root missing, or src/ absent, or both required infra files absent.
    "partial"  src/ exists but one or both required infra files missing.
    "present"  Both LocatorHealer.ts and LocatorRepository.ts exist on disk.
    """
    root = Path(helix_root).expanduser().resolve()

    if not root.exists():
        return {
            "framework_state": "absent",
            "missing_infra": sorted(_INFRA_FILES),
            "existing_infra": [],
            "has_src": False,
            "recommendation": "scaffold_and_tests",
            "message": (
                f"helix_root '{root}' does not exist. "
                "Create the directory and call write_helix_files with mode='scaffold_and_tests' — "
                "all 14 infra files will be copied from the boilerplate automatically."
            ),
        }

    infra_dir = root / "src" / "utils" / "locators"
    has_src   = (root / "src").exists()

    existing_infra   = sorted(f for f in _INFRA_FILES     if (infra_dir / f).exists())
    missing_infra    = sorted(f for f in _INFRA_FILES     if f not in existing_infra)
    missing_required = sorted(f for f in _REQUIRED_INFRA  if f not in existing_infra)

    if not has_src or missing_required:
        state = "absent" if not has_src else "partial"
        return {
            "framework_state": state,
            "missing_infra": missing_infra,
            "existing_infra": existing_infra,
            "has_src": has_src,
            "recommendation": "scaffold_and_tests",
            "message": (
                f"Helix-QA framework is {state}. "
                f"Missing files: {missing_infra}. "
                "Call write_helix_files with mode='scaffold_and_tests' — "
                "missing infra files will be copied from the boilerplate automatically."
            ),
        }

    return {
        "framework_state": "present",
        "missing_infra": missing_infra,
        "existing_infra": existing_infra,
        "has_src": True,
        "recommendation": "tests_only",
        "message": (
            "Helix-QA framework is present. "
            "Infrastructure files will not be touched. "
            "Only test files will be written or merged."
        ),
    }


# ── Main write function ────────────────────────────────────────────────────

def write_files_to_helix(
    helix_root:     str,
    files:          dict[str, str],
    mode:           str  = "scaffold_and_tests",
    force_scaffold: bool = False,
) -> dict[str, Any]:
    """
    Write generated files into the Helix-QA project with full deduplication.

    mode="tests_only"          write locators, pages, steps, features — merge
                               into existing files, skip infra files always.
    mode="scaffold_and_tests"  also write infra files that do not yet exist.
    force_scaffold=True        overwrite existing infra files (use deliberately).
    """
    root = Path(helix_root).expanduser().resolve()
    if not root.exists():
        return {
            "success": False,
            "error": f"helix_root does not exist: {root}",
            "written": [], "skipped": [],
        }

    written:       list[dict] = []
    skipped:       list[dict] = []
    deduplication: dict[str, Any] = {}

    # ── Auto-scaffold missing infra files from boilerplate ────────────────
    # Runs before the files dict loop.  Boilerplate is the source of truth for
    # all 14 utils/locators/ files — generated strings (scaffold_locator_repository)
    # are intentionally NOT used for infra writes when boilerplate content exists.
    _written_from_boilerplate: set[str] = set()
    if mode == "scaffold_and_tests":
        infra_dir = root / "src" / "utils" / "locators"
        for infra_file in sorted(_INFRA_FILES):
            dest_bp = infra_dir / infra_file
            if dest_bp.exists() and not force_scaffold:
                continue
            bp_content = _get_boilerplate_content(infra_file)
            if bp_content is None:
                continue
            try:
                action = "overwritten" if dest_bp.exists() else "created"
                dest_bp.parent.mkdir(parents=True, exist_ok=True)
                dest_bp.write_text(bp_content, encoding="utf-8")
                dest_rel_bp = str(dest_bp.relative_to(root))
                _written_from_boilerplate.add(dest_rel_bp)
                written.append({
                    "file_key": f"src/utils/locators/{infra_file}",
                    "dest":     dest_rel_bp,
                    "bytes":    len(bp_content.encode()),
                    "action":   action,
                    "source":   "boilerplate",
                })
            except OSError as exc:
                skipped.append({
                    "file_key": f"src/utils/locators/{infra_file}",
                    "dest":     str(infra_dir / infra_file),
                    "reason":   str(exc),
                })

    for file_key, raw_content in files.items():
        if not raw_content or not raw_content.strip():
            skipped.append({"file_key": file_key, "reason": "empty content"})
            continue

        # Apply interface adapter to all TypeScript content
        content = _adapt_to_helix_interface(raw_content)

        dest     = _resolve_destination(root, file_key)
        dest.parent.mkdir(parents=True, exist_ok=True)
        dest_rel = str(dest.relative_to(root))

        # ── Infrastructure files ───────────────────────────────────────────
        if _INFRA_RE.search(file_key):
            if mode == "tests_only":
                skipped.append({
                    "file_key": file_key, "dest": dest_rel,
                    "reason": "infrastructure file — skipped in tests_only mode",
                })
                continue
            # Already written from boilerplate in the pre-loop above
            if dest_rel in _written_from_boilerplate:
                skipped.append({
                    "file_key": file_key, "dest": dest_rel,
                    "reason": "sourced from boilerplate (passed content ignored)",
                })
                continue
            if dest.exists() and not force_scaffold:
                skipped.append({
                    "file_key": file_key, "dest": dest_rel,
                    "reason": "infrastructure file already exists (pass force_scaffold=True to overwrite)",
                })
                continue
            try:
                action = "overwritten" if dest.exists() else "created"
                dest.write_text(content, encoding="utf-8")
                written.append({"file_key": file_key, "dest": dest_rel,
                                 "bytes": len(content.encode()), "action": action})
            except OSError as exc:
                skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})
            continue

        # ── Cucumber config: append profile only if file exists, never create ────────────
        if _CUCUMBER_RE.search(file_key):
            if not dest.exists():
                # RULE: if cucumber config does not exist, skip creation
                skipped.append({
                    "file_key": file_key, "dest": dest_rel,
                    "reason": "cucumber.config.ts does not exist; only append to existing configs",
                })
                continue
            try:
                existing_text  = dest.read_text(encoding="utf-8")
                profile_match  = re.match(r"\s*(\w+)\s*:", content.strip())
                profile_name   = profile_match.group(1) if profile_match else None
                if profile_name and profile_name in existing_text:
                    skipped.append({
                        "file_key": file_key, "dest": dest_rel,
                        "reason": f"profile '{profile_name}' already exists in cucumber.config.ts",
                    })
                    continue
                dest.write_text(
                    existing_text.rstrip() + "\n\n// --- generated profile ---\n" + content,
                    encoding="utf-8",
                )
                written.append({"file_key": file_key, "dest": dest_rel,
                                "bytes": len(content.encode()), "action": "appended"})
            except OSError as exc:
                skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})
            continue

        # ── Feature files: merge scenarios, deduplicate by title ─────────────
        if _FEATURE_RE.search(file_key):
            try:
                if dest.exists():
                    existing_text = dest.read_text(encoding="utf-8")
                    features_dir = root / "src" / "test" / "features"
                    cross = _collect_all_scenario_titles(features_dir, exclude_file=dest)
                    merged, added, dup = _merge_feature_scenarios(existing_text, content, cross)
                    deduplication[dest_rel] = {
                        "type": "feature",
                        "added_scenarios":   added,
                        "skipped_scenarios": dup,
                    }
                    dest.write_text(merged, encoding="utf-8")
                    written.append({"file_key": file_key, "dest": dest_rel,
                                     "bytes": len(merged.encode()), "action": "merged"})
                else:
                    dest.write_text(content, encoding="utf-8")
                    written.append({"file_key": file_key, "dest": dest_rel,
                                     "bytes": len(content.encode()), "action": "created"})
            except OSError as exc:
                skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})
            continue

        # ── Merge-aware write for locators / page / steps ─────────────────
        try:
            if not dest.exists():
                dest.write_text(content, encoding="utf-8")
                written.append({"file_key": file_key, "dest": dest_rel,
                                 "bytes": len(content.encode()), "action": "created"})
                continue

            existing_text = dest.read_text(encoding="utf-8")

            if _LOCATOR_RE.search(file_key):
                merged, added, dup = _merge_locators(existing_text, content)
                deduplication[dest_rel] = {
                    "type": "locators", "added_keys": added, "skipped_keys": dup,
                }
            elif _STEPS_RE.search(file_key):
                steps_dir = root / "src" / "test" / "steps"
                cross = _collect_all_step_patterns(steps_dir, exclude_file=dest)
                merged, added, dup = _merge_steps(existing_text, content, cross)
                deduplication[dest_rel] = {
                    "type": "steps", "added_patterns": added, "skipped_patterns": dup,
                }
            elif _PAGE_RE.search(file_key):
                merged, added, dup = _merge_page_methods(existing_text, content)
                deduplication[dest_rel] = {
                    "type": "page", "added_methods": added, "skipped_methods": dup,
                }
            else:
                merged = content
                deduplication[dest_rel] = {"type": "unknown", "action": "overwritten"}

            dest.write_text(merged, encoding="utf-8")
            written.append({"file_key": file_key, "dest": dest_rel,
                             "bytes": len(merged.encode()), "action": "merged"})

        except OSError as exc:
            skipped.append({"file_key": file_key, "dest": dest_rel, "reason": str(exc)})

    return {
        "success": len(written) > 0 or len(skipped) == 0,
        "helix_root": str(root),
        "written": written,
        "skipped": skipped,
        "deduplication": deduplication,
        "summary": {
            "requested":               len(files),
            "written":                 len(written),
            "skipped":                 len(skipped),
            "scaffolded_from_boilerplate": len(_written_from_boilerplate),
        },
    }


# ── Read / list helpers ───────────────────────────────────────────────────

def read_helix_file(helix_root: str, relative_path: str) -> dict[str, Any]:
    root   = Path(helix_root).expanduser().resolve()
    target = (root / relative_path.lstrip("/")).resolve()
    try:
        target.relative_to(root)
    except ValueError:
        return {"success": False, "error": "Path escapes helix_root"}
    if not target.exists():
        return {"success": False, "exists": False, "path": relative_path}
    try:
        content = target.read_text(encoding="utf-8")
        return {"success": True, "exists": True, "path": relative_path,
                "content": content, "bytes": len(content.encode())}
    except OSError as exc:
        return {"success": False, "error": str(exc)}


def update_helix_file(
    helix_root:     str,
    relative_path:  str,
    content:        str,
    force_overwrite: bool = False,
) -> dict[str, Any]:
    """
    Write or merge a single file in the Helix-QA project.

    Applies the same interface adapter and merge logic as write_files_to_helix
    but targets one file directly, avoiding a full-dict write call.

    force_overwrite=True  — replace the entire file without merging.
    """
    root   = Path(helix_root).expanduser().resolve()
    target = (root / relative_path.lstrip("/")).resolve()
    # Prevent path traversal
    try:
        target.relative_to(root)
    except ValueError:
        return {"success": False, "error": "relative_path escapes helix_root — path traversal rejected"}

    if not content or not content.strip():
        return {"success": False, "error": "content is empty — nothing to write"}

    adapted = _adapt_to_helix_interface(content)
    target.parent.mkdir(parents=True, exist_ok=True)
    dest_rel = str(target.relative_to(root))

    try:
        if not target.exists() or force_overwrite:
            action = "overwritten" if target.exists() else "created"
            target.write_text(adapted, encoding="utf-8")
            return {
                "success": True,
                "path": dest_rel,
                "action": action,
                "bytes": len(adapted.encode()),
                "deduplication": None,
            }

        existing_text = target.read_text(encoding="utf-8")
        file_key = relative_path

        if _LOCATOR_RE.search(file_key):
            merged, added, dup = _merge_locators(existing_text, adapted)
            dedup = {"type": "locators", "added_keys": added, "skipped_keys": dup}
        elif _STEPS_RE.search(file_key):
            steps_dir = root / "src" / "test" / "steps"
            cross = _collect_all_step_patterns(steps_dir, exclude_file=target)
            merged, added, dup = _merge_steps(existing_text, adapted, cross)
            dedup = {"type": "steps", "added_patterns": added, "skipped_patterns": dup}
        elif _PAGE_RE.search(file_key):
            merged, added, dup = _merge_page_methods(existing_text, adapted)
            dedup = {"type": "page", "added_methods": added, "skipped_methods": dup}
        elif _FEATURE_RE.search(file_key):
            features_dir = root / "src" / "test" / "features"
            cross = _collect_all_scenario_titles(features_dir, exclude_file=target)
            if not target.exists():
                target.write_text(adapted, encoding="utf-8")
                return {
                    "success": True,
                    "path": dest_rel,
                    "action": "created",
                    "bytes": len(adapted.encode()),
                    "deduplication": None,
                }
            existing_text = target.read_text(encoding="utf-8")
            merged, added, dup = _merge_feature_scenarios(existing_text, adapted, cross)
            dedup = {"type": "feature", "added_scenarios": added, "skipped_scenarios": dup}
        else:
            merged = adapted
            dedup  = {"type": "unknown", "action": "overwritten"}

        target.write_text(merged, encoding="utf-8")
        return {
            "success": True,
            "path": dest_rel,
            "action": "merged",
            "bytes": len(merged.encode()),
            "deduplication": dedup,
        }

    except OSError as exc:
        return {"success": False, "error": str(exc), "path": dest_rel}


def list_helix_tree(helix_root: str) -> dict[str, Any]:
    root = Path(helix_root).expanduser().resolve()
    if not root.exists():
        return {"success": False, "error": f"helix_root does not exist: {root}"}

    tree: dict[str, list[str]] = {
        "features": [], "steps": [], "pages": [],
        "locators": [], "utils_locators": [], "other": [],
    }
    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        if path.suffix not in (".ts", ".feature", ".js"):
            continue
        if any(p in ("node_modules", "dist", "test-results", ".git") for p in path.parts):
            continue
        rel = str(path.relative_to(root))
        if "test/features" in rel or rel.endswith(".feature"):
            tree["features"].append(rel)
        elif "test/steps" in rel and rel.endswith(".ts"):
            tree["steps"].append(rel)
        elif "src/pages" in rel and rel.endswith(".ts"):
            tree["pages"].append(rel)
        elif "src/locators" in rel and rel.endswith(".ts"):
            tree["locators"].append(rel)
        elif "utils/locators" in rel and rel.endswith(".ts"):
            tree["utils_locators"].append(rel)
        else:
            tree["other"].append(rel)

    return {"success": True, "helix_root": str(root), "tree": tree}