"""
Core migration pipeline — shared by both the MCP server and the CLI.

Pipeline per file:
  1. JS → TS conversion     (only when source is .js)
  2. Locator modernisation  (locators, page objects, step defs)
  3. Healer injection       (page objects and step defs)
  4. Import path fixing     (all .ts files)

Config files are handled separately via merge helpers.
"""
from __future__ import annotations
import json
import importlib.util
import re
import shutil
import sys
import types as pytypes
from pathlib import Path

from .transformer.js_to_ts import convert_js_to_ts
from .transformer.locator_moderniser import modernise_locators
from .transformer.locator_registrar import (
    transform_locator_file,
    collect_object_keys,
    collect_testid_consumed_keys,
    compute_wrapped_keys,
    fix_selector_accesses,
    split_locator_file_into_pages,
)
from .transformer.local_var_hoister import (
    HoistAction,
    hoist_inline_locators,
    append_entries_to_locator_file,
)
from .transformer.healer_injector import inject_healers
from .transformer.currentfixture_remover import remove_currentfixture_aliases
from .transformer.import_fixer import fix_imports
from .transformer.spec_to_bdd import convert_spec_to_bdd
from .transformer.js_to_ts import _fix_tobe_typo, _cast_intl_format_options  # type: ignore
from .transformer.config_merger import (
    merge_playwright_config,
    merge_cucumber_config,
    merge_package_json,
    merge_tsconfig_json,
)
from .reporter import generate_report
from ._vrt import (
    scaffold_vrt as _scaffold_vrt_files,
    PAGE_FIXTURE_VRT_TS,
    HOOKS_VRT_TS,
    HOOKS_WIRING_SNIPPET,
)

_FIXTURE_SERVICE_FIELDS: dict[str, str] = {
        "locatorRepository": "LocatorRepository",
        "locatorHealer": "LocatorHealer",
        "locatorTiming": "TimingHealer",
        "locatorVisual": "VisualIntentChecker",
}

_FIXTURE_HOOK_MARKERS: dict[str, str] = {
        "locatorRepository": "new LocatorRepository(",
        "locatorHealer": "new LocatorHealer(",
        "locatorTiming": "new TimingHealer(",
        "locatorVisual": "new VisualIntentChecker(",
}

_PAGE_FIXTURE_WIRING_SNIPPET = '''\
// In src/hooks/pageFixture.ts — add the missing imports, fields, and a small service builder:
import { LocatorRepository } from "@utils/locators/LocatorRepository";
import { LocatorHealer } from "@utils/locators/LocatorHealer";
import { TimingHealer } from "@utils/locators/TimingHealer";
import { VisualIntentChecker } from "@utils/locators/VisualIntentChecker";

export interface PageFixture {
    // ... existing fields ...
    locatorRepository: LocatorRepository;
    locatorHealer: LocatorHealer;
    locatorTiming: TimingHealer;
    locatorVisual: VisualIntentChecker;
}

export function buildFixtureServices(page: Page, logger: FixtureLogger, scenarioName?: string) {
    const locatorRepository = new LocatorRepository();
    const locatorHealer = new LocatorHealer(page, logger, locatorRepository);
    locatorHealer.currentScenario = scenarioName;

    return {
        locatorRepository,
        locatorHealer,
        locatorTiming: new TimingHealer(page, logger, locatorRepository),
        locatorVisual: new VisualIntentChecker(page, logger, locatorRepository),
    };
}
'''

_PAGE_FIXTURE_CLASS_WIRING_SNIPPET = '''\
// In src/hooks/pageFixture.ts — add the missing imports, class fields, and a single initializer:
import { LocatorRepository } from "@utils/locators/LocatorRepository";
import { LocatorHealer } from "@utils/locators/LocatorHealer";
import { TimingHealer } from "@utils/locators/TimingHealer";
import { VisualIntentChecker } from "@utils/locators/VisualIntentChecker";

export class PageFixture {
    // ... existing fields ...
    public locatorRepository!: LocatorRepository;
    public locatorHealer!: LocatorHealer;
    public locatorTiming!: TimingHealer;
    public locatorVisual!: VisualIntentChecker;

    public initializeLocatorServices(page: Page, logger: FixtureLogger, scenarioName?: string): void {
        const locatorRepository = new LocatorRepository();
        const locatorHealer = new LocatorHealer(page, logger, locatorRepository);
        locatorHealer.currentScenario = scenarioName;

        this.locatorRepository = locatorRepository;
        this.locatorHealer = locatorHealer;
        this.locatorTiming = new TimingHealer(page, logger, locatorRepository);
        this.locatorVisual = new VisualIntentChecker(page, logger, locatorRepository);
    }
}
'''

_HOOKS_WIRING_SNIPPET = '''\
// In src/hooks/hooks.ts — alongside your existing Before hook:
import { buildFixtureServices } from "./pageFixture";

Before(async function (scenario) {
    // ... existing browser/context/page setup ...
    const services = buildFixtureServices(page, logger, scenario?.pickle?.name);

    setFixture({
        // ... existing fixture fields ...
        page,
        browser,
        context,
        logger,
        ...services,
    });
});
'''

_HOOKS_INITIALIZER_WIRING_SNIPPET = '''\
// In src/hooks/hooks.ts — integrate into your existing fixture bootstrap.
// If this repo already creates `const fixture = new PageFixture()` and then
// calls initializeFixture(page, scenarioName, userType, fixture), extend that
// flow instead of replacing it with setFixture(...).
// Add a helper like `fixture.initializeLocatorServices(page, fixture.logger, scenarioName)`
// on the PageFixture class, then call it after your existing bootstrap step.

await initializeFixture(page, scenarioName, userType, fixture);
fixture.initializeLocatorServices(page, fixture.logger, scenarioName);
'''

_TRANSFORM_ROLES = {"locator", "page_object", "step_def", "helper", "api_client"}

# Roles that are TypeScript/JavaScript code and at minimum need JS→TS conversion
# (if .js) plus import-path rewriting. `helper` and `api_client` files also go
# through the generic locator/healer/currentFixture cleanup path because many
# real-world suites hide page interactions behind helper classes and API-ish
# service wrappers instead of page objects.
_CODE_COPY_ROLES = {"helper", "hook", "fixture", "support", "api_client"}

_HOIST_ROLES = {"page_object", "step_def", "helper", "api_client"}
_HEALER_ROLES = {"page_object", "step_def", "helper", "api_client"}

# Roles that are copied byte-for-byte (no source transformation).
# "unknown" is the catch-all: any file the mapper didn't classify (README.md,
# LICENSE, .gitignore, vrt-baselines, arbitrary docs) lands here and is
# preserved at its original relative path so the migration never silently
# drops content from the source project.
_RAW_COPY_ROLES = {"docker", "env", "ci", "script", "unknown"}


def _merge_object_keys(target: dict[str, set[str]], source: dict[str, set[str]]) -> None:
    for name, keys in source.items():
        target.setdefault(name, set()).update(keys)


def _load_existing_locator_object_keys(root: Path) -> dict[str, set[str]]:
    object_keys_by_name: dict[str, set[str]] = {}
    locator_root = root / "src/locators"
    if not locator_root.exists():
        return object_keys_by_name

    for locator_path in locator_root.rglob("*.locators.ts"):
        try:
            content = locator_path.read_text(encoding="utf-8")
            modernised, *_ = modernise_locators(content)
            transformed, *_ = transform_locator_file(modernised)
            _merge_object_keys(object_keys_by_name, collect_object_keys(transformed))
        except Exception as exc:
            print(
                f"warning: object_keys fallback failed for {locator_path}: {exc}",
                file=sys.stderr,
            )

    return object_keys_by_name


def run_migration(
    source_dir: str,
    helix_root: str,
    file_mappings: list[dict],
    conflict_strategy: str = "interactive",
    framework: str = "playwright-bdd-ts",
    integration: str = "both",
    features: set[str] | None = None,
    prior_outputs: set[str] | None = None,
) -> dict:
    """
    Execute the full migration for a list of file mappings.

    conflict_strategy:
        "overwrite"   — always replace existing Helix files
        "skip"        — never touch existing Helix files
        "interactive" — report conflicts in result; caller decides per file

    integration:
        "ado"   — install ADO skills (qa-test-case-manager + shared)
        "jira"  — install Jira skills (qa-jira-manager + shared)
        "both"  — install everything (default — every agent reachable)

    Returns {
        written:    list[dict],
        skipped:    list[dict],
        conflicts:  list[dict],
        todo_count: int,
        todos:      list[str],
        report_path: str | None,
    }
    """
    written:    list[dict] = []
    skipped:    list[dict] = []
    conflicts:  list[dict] = []
    todos:      list[str]  = []
    features = features or set()
    root = Path(helix_root)

    # Authoritative lookup so import_fixer can rewrite imports against the
    # mapper's real output paths instead of re-predicting them (which would
    # drift apart after disambiguation, kebab tweaks, etc.). Keyed by the
    # resolved absolute source path; value is the helix-relative target.
    source_to_target: dict[str, str] = {
        str(Path(m["source"]).resolve()): m["target"]
        for m in file_mappings
        if m.get("target")
    }

    # Accumulator for HoistActions emitted while transforming page_object /
    # step_def files. Applied to locator files in a post-pass so all hoisted
    # entries land in their target xxxLocators block.
    hoist_buffer: dict[str, list[HoistAction]] = {}

    # Pre-pass: scan every locator-source file so we know which keys are
    # object-valued ({selector, intent, stability}) versus string-valued
    # (display labels, URLs). The healer must not wrap references whose key
    # is string-valued — `.selector` would not exist on the plain string.
    object_keys_by_name: dict[str, set[str]] = {}
    for mp in file_mappings:
        if mp.get("role") != "locator":
            continue
        try:
            src_content = Path(mp["source"]).read_text(encoding="utf-8")
            # modernise_locators returns (content, changes, todo_count) —
            # only the first matters here. transform_locator_file returns
            # (content, changes). Use `*_` to absorb any future trailing
            # values so a signature change can't silently re-break this.
            modernised, *_ = modernise_locators(src_content)
            transformed, *_ = transform_locator_file(modernised)
            for name, keys in collect_object_keys(transformed).items():
                object_keys_by_name.setdefault(name, set()).update(keys)
        except Exception as exc:
            # Surface the failure: a silent `pass` here was hiding a tuple
            # unpacking bug for who knows how long, which left every
            # consumer's `object_keys` map empty (so the healer wrapped
            # everything indiscriminately, including string-valued labels).
            import sys
            print(f"warning: object_keys pre-pass failed for {mp['relative']}: {exc}", file=sys.stderr)

    if not object_keys_by_name:
        _merge_object_keys(object_keys_by_name, _load_existing_locator_object_keys(root))

    # Pre-pass: scan EVERY source .ts file for locators consumed via a
    # *ByTestId call (getByTestId / waitAndClickByTestId / ...). Those entries
    # store a bare test-id NAME, not a CSS selector — the source supplied the
    # "treat as test-id" semantics at the call site. The healer resolves stored
    # selectors via page.locator(...), which has no test-id engine, so a bare
    # name would match nothing. We collect the (object, key) pairs here and
    # rewrite their values to `[data-testid="..."]` during registration.
    testid_keys_by_object: dict[str, set[str]] = {}
    for mp in file_mappings:
        src = mp.get("source")
        if not src or not str(src).endswith((".ts", ".tsx")):
            continue
        try:
            text = Path(src).read_text(encoding="utf-8")
        except Exception:
            continue
        for obj, keys in collect_testid_consumed_keys(text).items():
            testid_keys_by_object.setdefault(obj, set()).update(keys)

    # Derive which (object, key) pairs will actually be WRAPPED to the
    # `[data-testid="..."]` form (test-id-consumed AND a bare-name value). The
    # injector needs this so surviving `getByTestId(obj.key.selector)` call
    # sites are converted to `.locator(...)` — feeding the wrapped CSS selector
    # to getByTestId would be invalid (double-wrap corruption).
    wrapped_testid_keys: dict[str, set[str]] = {}
    for mp in file_mappings:
        if mp.get("role") != "locator":
            continue
        src = mp.get("source")
        if not src:
            continue
        try:
            text = Path(src).read_text(encoding="utf-8")
        except Exception:
            continue
        for obj, keys in compute_wrapped_keys(text, testid_keys_by_object).items():
            wrapped_testid_keys.setdefault(obj, set()).update(keys)

    for mapping in file_mappings:
        role = mapping["role"]

        if role.startswith("config_"):
            _process_config(mapping, source_dir, helix_root, written, skipped, features)
            continue

        source_path = Path(mapping["source"])
        target_rel  = mapping["target"]
        if not target_rel:
            skipped.append({"source": mapping["relative"], "reason": f"no target for role={role}"})
            continue

        # Per-app `.env.<APP>` files are NOT raw-copied — their values are
        # folded into the single `.env.example` at helix-qa root by the
        # _write_root_env_example post-pass below. Skipping prevents stale
        # per-app files from sticking around alongside the consolidated file.
        if role == "env" and source_path.name.startswith(".env.") and source_path.name != ".env.example":
            skipped.append({
                "source": mapping["relative"],
                "reason": "per-app .env folded into single .env.example",
            })
            continue

        # ── Playwright spec → feature + steps split ─────────────────────────
        # Non-BDD Playwright spec files are converted into a Gherkin .feature
        # file plus a Cucumber .steps.ts file. The .steps.ts goes to the
        # target_rel computed by the mapper; the .feature is derived from it.
        if role == "playwright_spec":
            try:
                content = source_path.read_text(encoding="utf-8")
                feature_content, steps_content, changes = convert_spec_to_bdd(
                    content, Path(target_rel).stem.replace(".steps", ""),
                )
                if not steps_content:
                    skipped.append({
                        "source": mapping["relative"],
                        "reason": "no test() or test.describe() blocks found",
                    })
                    continue
                # Run import_fixer on the generated step file so imports of
                # page_objects/helpers (preserved from the source's require())
                # get rewritten to the new Helix locations.
                steps_content, imp_changes = fix_imports(
                    steps_content, mapping["source"], target_rel, source_dir,
                    source_to_target=source_to_target,
                )
                # Apply the targeted JS→TS fixes that also matter inside step
                # bodies (typo normalisation, Intl.* option casts). The other
                # js_to_ts passes are skipped because they're aimed at class
                # bodies, not free-standing step callbacks.
                steps_content, tobe_changes = _fix_tobe_typo(steps_content)
                steps_content, intl_changes2 = _cast_intl_format_options(steps_content)
                changes.extend(tobe_changes)
                changes.extend(intl_changes2)
                # Steps file
                steps_path = root / target_rel
                steps_path.parent.mkdir(parents=True, exist_ok=True)
                steps_path.write_text(steps_content, encoding="utf-8")
                # Feature file — derive from steps target.
                feature_rel = target_rel.replace("src/test/steps/", "src/test/features/", 1)
                feature_rel = feature_rel.replace(".steps.ts", ".feature")
                feature_path = root / feature_rel
                feature_path.parent.mkdir(parents=True, exist_ok=True)
                feature_path.write_text(feature_content, encoding="utf-8")
                written.append({
                    "source": mapping["relative"],
                    "target": target_rel,
                    "role":   "step_def",
                    "change_count": len(changes) + len(imp_changes),
                })
                written.append({
                    "source": mapping["relative"],
                    "target": feature_rel,
                    "role":   "feature",
                    "change_count": len(changes),
                })
            except Exception as exc:
                skipped.append({
                    "source": mapping["relative"],
                    "reason": f"spec→BDD conversion error: {exc}",
                })
            continue

        # ── Raw-copy roles: Dockerfile, .env, CI yaml, shell scripts ────────
        # Also raw-copy JSON data files under helper/ (test data, fixtures).
        is_raw_copy = role in _RAW_COPY_ROLES or (
            role in _CODE_COPY_ROLES and source_path.suffix == ".json"
        )
        if is_raw_copy:
            try:
                _copy_raw(source_path, root / target_rel)
                written.append({
                    "source": mapping["relative"],
                    "target": target_rel,
                    "role":   role,
                    "change_count": 0,
                })
            except Exception as exc:
                skipped.append({"source": mapping["relative"], "reason": f"raw-copy error: {exc}"})
            continue

        try:
            content = source_path.read_text(encoding="utf-8")
        except Exception as exc:
            skipped.append({"source": mapping["relative"], "reason": f"read error: {exc}"})
            continue

        # ── Transformation pipeline ─────────────────────────────────────
        change_count = 0
        file_todos:  list[str] = []

        # 1. JS → TS
        if mapping.get("needs_js_to_ts"):
            content, js_changes = convert_js_to_ts(content)
            change_count += len(js_changes)

        # 2. Locator modernisation
        if role in _TRANSFORM_ROLES:
            content, loc_changes, loc_todos = modernise_locators(content)
            change_count += len(loc_changes)
            for _ in range(loc_todos):
                file_todos.append(
                    f"`{target_rel}` — locator needs manual review (complex CSS/XPath kept as-is)"
                )

        # 2b. Locator registration — convert plain string values to { selector, intent, stability }
        if role == "locator":
            content, reg_changes = transform_locator_file(content, testid_keys_by_object)
            change_count += len(reg_changes)

            # 2b.i Split source-side monoliths.
            # A source file that declares ≥2 `xxxPageLocators` consts becomes a
            # 2,800-line touchpoint on the target side — every selector edit
            # produces a merge conflict (real-world: SC-Web's
            # `locators.locators.ts` carried 29 page-locator consts in one
            # file). Emit one file per const + a barrel; keep the original
            # `target_rel` as a re-export so existing `@locators/<old>` imports
            # still resolve without touching every consumer.
            split = split_locator_file_into_pages(content)
            if split is not None:
                per_page, barrel = split
                locators_dir = root / "src" / "locators"
                locators_dir.mkdir(parents=True, exist_ok=True)
                for stem, body in per_page:
                    pp_path = locators_dir / f"{stem}.locators.ts"
                    if pp_path.exists() and conflict_strategy == "skip":
                        skipped.append({
                            "source": mapping["relative"],
                            "reason": f"split target {pp_path.name} exists (skipped per strategy)",
                        })
                        continue
                    pp_path.write_text(body, encoding="utf-8")
                    written.append({
                        "source":       f'{mapping["relative"]} [split:{stem}]',
                        "target":       str(pp_path.relative_to(root)),
                        "role":         role,
                        "change_count": 0,
                    })
                # Emit / refresh the barrel.
                idx_path = locators_dir / "index.ts"
                if not (idx_path.exists() and conflict_strategy == "skip"):
                    idx_path.write_text(barrel, encoding="utf-8")
                    written.append({
                        "source":       f'{mapping["relative"]} [barrel]',
                        "target":       str(idx_path.relative_to(root)),
                        "role":         role,
                        "change_count": 0,
                    })
                # Original target_rel becomes a thin re-export so existing
                # `@locators/<original>` imports keep resolving without the
                # import-fixer needing to chase down every call-site.
                content = barrel

        # 2c. Hoist inline-string locators from local variables into the
        # centralised xxxLocators. Buffered; applied to locator files in a
        # post-pass. Must run BEFORE healer injection so the rewritten RHS
        # (xxxLocators.foo.selector) is visible to healer_injector's var_map.
        # Helpers and helper-side API wrappers participate too because older
        # frameworks often hide raw page interactions behind service classes
        # rather than page-object methods.
        if role in _HOIST_ROLES:
            content, hoist_actions, hoist_changes = hoist_inline_locators(content)
            change_count += len(hoist_changes)
            for action in hoist_actions:
                hoist_buffer.setdefault(action.locator_object, []).append(action)
                # Hoisted entries are by construction object-valued, so register
                # them in object_keys_by_name immediately. Without this, the
                # healer call below would not recognise them as wrappable.
                object_keys_by_name.setdefault(action.locator_object, set()).add(action.entry_name)

        # 3. Healer injection
        if role in _HEALER_ROLES:
            # Derive a per-file slug from the target path basename so the
            # healer's synthesised `dyn.*` keys don't collide across files
            # (eight callers all emitting `dyn.po.1` was a real bug in
            # migrated frameworks). Path is whatever the migration pipeline
            # already computed — no hardcoded values here.
            from pathlib import PurePosixPath
            _basename = PurePosixPath(target_rel).name
            for _suffix in (".page.ts", ".steps.ts", ".ts", ".js"):
                if _basename.endswith(_suffix):
                    _basename = _basename[: -len(_suffix)]
                    break
            content, heal_changes, heal_todos = inject_healers(
                content, role, object_keys=object_keys_by_name,
                file_slug=_basename,
                wrapped_testid_keys=wrapped_testid_keys,
            )
            change_count += len(heal_changes)
            if heal_todos:
                file_todos.append(
                    f"`{target_rel}` — healer init needed (see inline TODO comment)"
                )

            # 3b. Strip `currentFixture` aliasing. The healer injector emits
            #     `fixture().X` directly, so any pre-existing
            #     `const currentFixture = fixture();` cache is dead weight —
            #     and `this.currentFixture` helper/page-object fields are too. This
            #     pass collapses the three patterns to direct `fixture()`
            #     access. Bootstrap (`global.currentFixture`) is preserved.
            content, cf_changes = remove_currentfixture_aliases(content)
            change_count += len(cf_changes)

        # 4. Import path fixing (applies to all code roles, including helper/hook/fixture/support)
        content, imp_changes = fix_imports(
            content, mapping["source"], target_rel, source_dir,
            source_to_target=source_to_target,
        )
        change_count += len(imp_changes)

        todos.extend(file_todos)

        # ── Conflict check ──────────────────────────────────────────────
        target_path = root / target_rel

        if target_path.exists():
            if conflict_strategy == "skip":
                skipped.append({
                    "source": mapping["relative"],
                    "reason": "target already exists (skipped per strategy)",
                })
                continue
            elif conflict_strategy == "overwrite":
                conflicts.append({"path": target_rel, "resolution": "overwritten"})
            else:  # interactive
                conflicts.append({
                    "path":       target_rel,
                    "source":     mapping["relative"],
                    "resolution": "pending — awaiting user decision",
                })
                # In interactive mode we still write; MCP callers can re-invoke
                # with conflict_strategy="skip" for specific files if desired.

        # ── Write ──────────────────────────────────────────────────────
        target_path.parent.mkdir(parents=True, exist_ok=True)
        target_path.write_text(content, encoding="utf-8")

        written.append({
            "source":       mapping["relative"],
            "target":       target_rel,
            "role":         role,
            "change_count": change_count,
        })

    # ── Post-pass: ensure tsconfig.json exists ──────────────────────────────
    # JS source projects (e.g. playwright-js) don't ship a tsconfig.json, but
    # the migrated tree is always TypeScript — synthesise one so the user can
    # `tsc` / `cucumber-js` against the migrated code without manual setup.
    _ensure_tsconfig(root)

    # ── Post-pass: flush buffered hoist actions into the centralised locator files ──
    # During the per-file loop we rewrote local-var RHS to reference xxxLocators
    # entries that don't exist yet. This post-pass appends those entries to the
    # right xxxLocators block in the right .locators.ts file on disk.
    _flush_hoist_buffer(root, hoist_buffer)

    # ── Post-pass: fix locator .selector accesses in page objects / step defs ──
    # Locator files are already written with { selector, intent, stability } format.
    # Now read them all to collect which keys are objects, then update page objects.
    _fix_selector_accesses_in_tree(root, written)

    # ── Post-pass: emit single `.env.example` at helix-qa root ───────────────
    # Folds any source per-app `.env.<APP>` files into KEY_<APP>=… entries so
    # the framework runs from one consolidated env file.
    _write_root_env_example(root, written, source_dir, conflict_strategy, features)

    # ── Post-pass: install the single-source env loader ──────────────────────
    # Replaces the migrated `environment.util.ts` with one that reads root .env
    # and resolves APP-suffixed vars (DOMAIN_<APP> → DOMAIN) at runtime.
    _patch_environment_util(root)

    # ── Post-pass: align Docker Playwright base image to package.json ────────
    # package.json's @playwright/test is finalized by now (merged in the loop).
    # Rewrite any copied Dockerfile's `mcr.microsoft.com/playwright:vX.Y.Z` tag
    # to that version so the container's browsers match the npm dep — a mismatch
    # fails the run inside Docker.
    _align_dockerfile_playwright_version(root, written)

    # ── Post-pass: scaffold the self-healing locator infrastructure ─────────
    # All migrated page objects / step defs import from @utils/locators/* — they
    # need LocatorHealer, LocatorRepository, TimingHealer, VisualIntentChecker
    # and HealingDashboard to be present in the target tree. The scaffold
    # honors the same conflict strategy as the main migration loop so users
    # who pass --conflict overwrite get a fresh scaffold that picks up
    # tool-side enhancements (new env-var wiring, new strategies, etc.).
    _scaffold_locators(root, written, conflict_strategy)

    # ── Scaffold pageFixture.ts when missing ────────────────────────────────
    # Spec→BDD-generated step files import `fixture` from `@hooks/pageFixture`.
    # When the source didn't carry its own hook (typical for non-BDD Playwright
    # projects), drop in a minimal fixture so the generated steps resolve.
    # When `vrt` is in features, the VRT-aware variants are emitted so the
    # scaffolded hooks initialise VRTCapture per scenario.
    fixture_wiring_gaps = _scaffold_page_fixture(
        root, written, vrt_enabled=("vrt" in features),
    )
    if fixture_wiring_gaps["page_fixture_missing"]:
        todos.append(
            "Fixture wiring: extend src/hooks/pageFixture.ts with "
            + ", ".join(fixture_wiring_gaps["page_fixture_missing"])
            + " so migrated pages can call fixture().locator* services "
              "(see MIGRATION-REPORT.md snippet)."
        )
    if fixture_wiring_gaps["hooks_missing"]:
        todos.append(
            "Fixture wiring: update src/hooks/hooks.ts to initialise "
            + ", ".join(fixture_wiring_gaps["hooks_missing"])
            + " and pass them through setFixture(...) "
              "(see MIGRATION-REPORT.md snippet)."
        )

    # ── Drop VRT runtime assets when feature enabled ────────────────────────
    # src/helper/vrt/{vrtCapture,vrtReport}.util.ts + src/test/steps/vrt.steps.ts
    # The .env.example VRT block above and the package.json scripts/deps
    # injected by `merge_package_json` are meaningless without these.
    vrt_hooks_owned_by_source = False
    if "vrt" in features:
        _scaffold_vrt_files(root, written, conflict_strategy)
        # If the source brought its own hooks files (i.e. they existed before
        # `_scaffold_page_fixture` ran), the operator has to wire VRTCapture
        # themselves — record that in MIGRATION-REPORT.md via the todo list.
        # Detect by checking whether the file's content contains the marker
        # we know our scaffold emits.
        hooks_file = root / "src" / "hooks" / "hooks.ts"
        if hooks_file.exists() and "VRTReportGenerator" not in hooks_file.read_text(encoding="utf-8"):
            vrt_hooks_owned_by_source = True
            todos.append(
                "VRT: source ships its own hooks.ts — wire VRTCapture manually "
                "(see MIGRATION-REPORT.md for snippet)."
            )

    # ── Scaffold globals.d.ts for browser-context identifiers ───────────────
    # page.evaluate() callbacks run in the browser and may reference functions
    # defined by the SUT (e.g. `toggleGridFiltersMenu()`). TypeScript can't
    # see those — emit `declare const` shims so tsc doesn't flag them.
    _scaffold_browser_globals(root)

    # ── Fill in the full Helix-QA boilerplate ──────────────────────────────
    # The qa-helix-writer / qa-playwright-generator agents expect a fully
    # populated Helix-QA tree (base page, base locators, AI assistant
    # helpers, retry/wait/logger utils, auth manager, templates, ...). The
    # tool's own scaffold only emits the 6 core locator-infra files; the
    # remaining ~25 files come from agent_helix_writer.boilerplate.BOILERPLATE,
    # which is the authoritative source the agents themselves use when they
    # bootstrap a fresh project. Fill in any file that the migration hasn't
    # already produced (and never clobber the enhanced scaffold variants).
    _fill_helix_boilerplate(root, written, conflict_strategy)

    # ── Install agent skills + MCP config + governance docs ─────────────────
    # The migration's whole point is to produce a tree the QA STLC agents
    # (qa-test-case-manager, qa-gherkin-generator, qa-playwright-generator,
    # qa-helix-writer, qa-jira-manager) can operate against. Drop in:
    #   • .claude/skills/<name>/SKILL.md  — instructions consumed by Claude Code
    #   • .mcp.json                       — MCP server registry (auto-discovered)
    #   • AGENT-BEHAVIOR.md / ORCHESTRATION_RULES.md — governance documents
    # Honors the same conflict strategy so re-migrations refresh stale files.
    _install_agent_assets(root, written, integration, conflict_strategy)

    # ── Auto-remove orphans in role dirs we own (overwrite mode only) ───────
    # When --conflict overwrite is on, the migrated tree is treated as
    # managed: any file in features/steps/pages/locators that the current
    # mapping didn't produce is stale (e.g. left over from a prior run with
    # a different layout — flat → preserve-tree). Other dirs (helper, hooks,
    # utils/locators, fixtures, support) are NOT swept because they mix
    # source-mapped, scaffolded, and operator-added files in ways the
    # migration can't safely distinguish.
    removed_orphans: list[str] = _clean_role_dir_orphans(
        root, written, conflict_strategy, prior_outputs,
    )

    # ── Post-pass: clean up source files that the mapping relocated ─────────
    # In-place migrations leave the SOURCE file in place even after the
    # mapper routes it to a different target path. Real-world impact: the
    # old `src/pages/scweb/locators.ts` (995 lines) survived after the
    # migrator emitted its content as `src/locators/...` per-page files,
    # so the tree ended up with both the new and the (now-orphan) old
    # file lingering.
    relocated_sources = _cleanup_relocated_sources(
        root, source_dir, written, conflict_strategy, prior_outputs,
    )
    removed_orphans.extend(relocated_sources)

    # ── Post-pass: dedupe fixture imports + refresh page barrels ───────────
    # Some source specs/steps already import `fixture()` and then pick up a
    # second import during healer injection or import rewriting. Clean those
    # before tsc sees duplicate identifiers. Also regenerate page barrels only
    # AFTER orphan cleanup so they don't point at files this same run removed.
    _dedupe_fixture_imports_in_tree(root)
    _emit_pages_barrels(root)

    # ── Report ─────────────────────────────────────────────────────────────
    report_path: str | None = None
    try:
        report_path = generate_report(
            helix_root=helix_root,
            source_dir=source_dir,
            framework=framework,
            written=written,
            skipped=skipped,
            conflicts=conflicts,
            todos=todos,
        )
        if vrt_hooks_owned_by_source and report_path:
            with open(report_path, "a", encoding="utf-8") as fh:
                fh.write(
                    "\n## VRT — manual wiring required\n\n"
                    "The source project ships its own `src/hooks/hooks.ts` and/or "
                    "`pageFixture.ts`, so the migration left them in place rather "
                    "than overwriting your scenario setup. To make `VRT_MODE=baseline|test` "
                    "actually do anything, paste the following into your hooks:\n\n"
                    "```ts\n"
                    f"{HOOKS_WIRING_SNIPPET}"
                    "```\n"
                )
        if report_path and (
            fixture_wiring_gaps["page_fixture_missing"]
            or fixture_wiring_gaps["hooks_missing"]
        ):
            page_fixture_snippet = _render_page_fixture_wiring_snippet(root)
            hooks_wiring_snippet = _render_hooks_wiring_snippet(root)
            with open(report_path, "a", encoding="utf-8") as fh:
                fh.write("\n## Hooks and page fixture wiring required\n\n")
                fh.write(
                    "The migration rewrote page objects and generated step files to use "
                    "`fixture().locatorRepository`, `fixture().locatorHealer`, "
                    "`fixture().locatorTiming`, and `fixture().locatorVisual`. "
                    "Your current hook files do not declare all of those services yet, "
                    "so `npx tsc --noEmit` will fail until the missing wiring is added.\n\n"
                )
                fh.write(
                    "Use the snippet below as an integration pattern, not as a blind replacement. "
                    "If the repo already has a custom fixture lifecycle, such as a `PageFixture` class, "
                    "`global.currentFixture`, or an `initializeFixture(...)` helper, add these services "
                    "to that existing flow instead of introducing a second fixture mechanism.\n\n"
                )
                if fixture_wiring_gaps["page_fixture_missing"]:
                    fh.write(
                        "Missing in `src/hooks/pageFixture.ts`: "
                        + ", ".join(
                            f"`{name}`"
                            for name in fixture_wiring_gaps["page_fixture_missing"]
                        )
                        + "\n\n```ts\n"
                        + page_fixture_snippet
                        + "```\n"
                    )
                if fixture_wiring_gaps["hooks_missing"]:
                    fh.write(
                        "Missing in `src/hooks/hooks.ts`: "
                        + ", ".join(
                            f"`{name}`"
                            for name in fixture_wiring_gaps["hooks_missing"]
                        )
                        + "\n\n```ts\n"
                        + hooks_wiring_snippet
                        + "```\n"
                    )
        if removed_orphans and report_path:
            with open(report_path, "a", encoding="utf-8") as fh:
                fh.write("\n## Removed orphan files\n\n")
                fh.write(
                    "Files in role-owned directories that were not part of "
                    "this migration's output. Cleaned because `--conflict "
                    "overwrite` is on. Most commonly these are left over from "
                    "a previous run with a different layout (flat → preserve-tree).\n\n"
                )
                for orphan in sorted(removed_orphans):
                    fh.write(f"- `{orphan}`\n")
    except Exception:
        pass

    # ── Manifest ───────────────────────────────────────────────────────────
    # Record every helix-relative path this run produced so an in-place
    # re-migration (source == helix) can skip them during source scanning
    # instead of re-processing migration outputs as if they were source files.
    # Union with `prior_outputs` so the manifest grows monotonically across
    # runs — a re-run with --conflict skip only writes a handful of files but
    # must keep claiming ownership of everything the original run produced.
    _write_migration_manifest(root, written, framework, prior_outputs)

    return {
        "written":     written,
        "skipped":     skipped,
        "conflicts":   conflicts,
        "todo_count":  len(todos),
        "todos":       todos,
        "report_path": report_path,
        "removed_orphans": removed_orphans,
    }


# ---------------------------------------------------------------------------
# Orphan cleanup in managed role dirs
# ---------------------------------------------------------------------------

# (role_dir, allowed_suffixes) — only files matching one of the suffixes are
# considered orphans. Non-matching files (READMEs, JSON test data, etc.)
# survive even when the migration didn't write them.
_ORPHAN_ROLE_DIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
    ("src/test/features", (".feature",)),
    ("src/test/steps",    (".steps.ts", ".steps.js")),
    ("src/pages",         (".page.ts", ".page.js")),
    ("src/locators",      (".locators.ts", ".locators.js")),
)


def _clean_role_dir_orphans(
    root: Path,
    written: list[dict],
    conflict_strategy: str,
    prior_outputs: set[str] | None = None,
) -> list[str]:
    """
    Sweep the file-mapping role dirs for files this migration did not write
    and remove them. Active only when conflict_strategy == "overwrite", so
    operators using "skip" or "interactive" keep all existing files.

    After file removal, empty subdirectories under each role dir are also
    removed so a flat→preserve-tree (or vice versa) transition leaves no
    dead structure behind.

    Returns the list of removed file paths (helix-relative).

    `prior_outputs` is the set of files we know the migration owns from
    previous runs (loaded from `.helix-migration.json`). They're added to the safe set
    so an in-place re-run with `--conflict overwrite` doesn't delete the
    183 files the manifest just told us to skip during source scanning —
    which would otherwise look like orphans because they're not in
    `written`.
    """
    if conflict_strategy != "overwrite":
        return []

    written_targets = {w["target"] for w in written if w.get("target")}

    # Boilerplate files the migration *would* write are only appended to
    # `written` on a fresh fill. On subsequent runs they're already on disk
    # and `_fill_helix_boilerplate` no-ops — they'd look like orphans.
    # Treat every allow-listed boilerplate target as legitimately owned.
    written_targets |= set(_BOILERPLATE_FILL_ALLOWLIST)

    # Files the previous migration produced (loaded from the manifest)
    # are owned by the tool too — even if we skipped them at
    # source-scan time this run.
    if prior_outputs:
        written_targets |= prior_outputs

    removed: list[str] = []

    for role_dir, suffixes in _ORPHAN_ROLE_DIRS:
        dir_path = root / role_dir
        if not dir_path.exists():
            continue
        for f in dir_path.rglob("*"):
            if not f.is_file():
                continue
            name = f.name.lower()
            if not any(name.endswith(s) for s in suffixes):
                continue
            rel = f.relative_to(root).as_posix()
            if rel in written_targets:
                continue
            try:
                f.unlink()
                removed.append(rel)
            except Exception:
                pass

    # Sweep empty dirs (deepest first so we don't leave hollow parents).
    for role_dir, _ in _ORPHAN_ROLE_DIRS:
        dir_path = root / role_dir
        if not dir_path.exists():
            continue
        dirs = sorted(
            (d for d in dir_path.rglob("*") if d.is_dir()),
            key=lambda p: -len(p.parts),
        )
        for d in dirs:
            try:
                if not any(d.iterdir()):
                    d.rmdir()
            except Exception:
                pass

    return removed


def _cleanup_relocated_sources(
    root: Path,
    source_dir: str,
    written: list[dict],
    conflict_strategy: str,
    prior_outputs: set[str] | None = None,
) -> list[str]:
    """
    Delete source files whose mapped target ended up at a different path
    during an in-place migration.

    Why this exists
    ---------------
    When source_dir == helix_root, the migrator writes its outputs into
    the same tree it scanned. A source file at `src/pages/scweb/locators.ts`
    that the mapper routed to `src/locators/locators.locators.ts` ends up
    on disk at BOTH locations — the new target is written, but the old
    source path is never touched. The old file then bitrots silently
    because nothing imports from it anymore.

    `_clean_role_dir_orphans` doesn't catch these because it walks role
    DIRECTORIES with role-specific SUFFIX filters (`.page.ts` for
    src/pages/, etc.), so a `locators.ts` lurking under `src/pages/scweb/`
    slips through.

    Behaviour
    ---------
    Only runs when:
      - source_dir resolves to the same path as helix root (in-place), AND
      - conflict_strategy == "overwrite" (caller opted in to mutations).

    Returns the list of deleted source paths (helix-relative) for the
    migration report. Failures are swallowed silently — best-effort.
    """
    if conflict_strategy != "overwrite":
        return []
    try:
        if Path(source_dir).resolve() != root.resolve():
            return []
    except Exception:
        return []

    # Every target the migration wrote in this run + everything previous
    # runs claimed. Sources that map to themselves (e.g. raw-copied
    # config files) live in `targets`, so the moved-vs-same check below
    # already excludes them.
    targets: set[str] = {w.get("target") for w in written if w.get("target")}  # type: ignore[assignment]
    if prior_outputs:
        targets |= prior_outputs

    removed: list[str] = []
    seen_sources: set[str] = set()
    for entry in written:
        src_rel = entry.get("source")
        tgt_rel = entry.get("target")
        if not src_rel or not tgt_rel or src_rel == tgt_rel:
            continue
        # Synthetic markers added by the per-page locator split (e.g.
        # "<original> [split:login]") don't refer to a real source file.
        if "[split:" in src_rel or "[barrel]" in src_rel:
            continue
        if src_rel in seen_sources:
            continue
        seen_sources.add(src_rel)

        # Defensive: if the same path also happens to be a written
        # target (file moved into itself by another mapping), keep it.
        if src_rel in targets:
            continue

        src_path = root / src_rel
        if not src_path.is_file():
            continue
        try:
            src_path.unlink()
            removed.append(src_rel)
        except OSError:
            pass

    return removed


# ---------------------------------------------------------------------------
# Migration manifest
# ---------------------------------------------------------------------------
# Every successful run writes `.helix-migration.json` at helix root listing
# the helix-relative path of every file the migration produced. Subsequent
# in-place re-runs (source == helix) load the manifest and skip those files
# during source scanning — so previously-migrated artefacts aren't re-
# processed as if they were original source, which is what produced
# duplicates like `base.page.ts` (from `BasePage.ts`) and the mangled
# `locators-scweb-locators.locators.ts`.
#
# For projects that pre-date the manifest, MIGRATION-REPORT.md is parsed as
# a fallback so existing trees can be made idempotent without a clean run.

_MIGRATION_MANIFEST_NAME    = ".helix-migration.json"
_MIGRATION_MANIFEST_VERSION = 1


def _load_migration_manifest(root: Path) -> set[str]:
    """
    Return the set of helix-relative paths previously written by the
    migration tool, or an empty set if no manifest exists / it's unreadable.
    """
    manifest_path = root / _MIGRATION_MANIFEST_NAME
    if not manifest_path.is_file():
        return set()
    try:
        data = json.loads(manifest_path.read_text(encoding="utf-8"))
    except Exception:
        return set()
    outputs = data.get("outputs")
    if not isinstance(outputs, list):
        return set()
    return {p for p in outputs if isinstance(p, str)}


_BOOTSTRAP_SNAPSHOT_DIRS = (
    "src/pages",
    "src/locators",
    "src/test/features",
    "src/test/steps",
    "src/utils/locators",
    "src/hooks",
    "src/helper",
    "src/config",
)

_BOOTSTRAP_ROOT_FILES = (
    ".env.example",
    "package.json",
    "tsconfig.json",
    "AGENT-BEHAVIOR.md",
    "ORCHESTRATION_RULES.md",
    "MIGRATION-REPORT.md",
)


def _bootstrap_outputs_from_report(root: Path) -> set[str]:
    """
    Pre-manifest fallback for in-place re-runs. Returns the set of helix-
    relative paths to skip during source scanning by combining two sources:

      1. The `target` column of the "Migrated Files" table in
         MIGRATION-REPORT.md — authoritative when the report is fresh.
      2. A snapshot of files currently living under the directories the
         migration tool owns (src/pages, src/locators, src/test/features,
         src/test/steps, src/utils/locators, src/hooks, src/helper,
         src/config) plus a small allow-list of root files. The snapshot
         fills the gap when MIGRATION-REPORT.md was rewritten by a partial
         re-run (and now lists only a handful of entries).

    Over-inclusion is safe in the in-place case: skipping a file the
    migration would have produced just means the existing file is left
    alone, which is exactly what an idempotent re-run should do.
    """
    outputs: set[str] = set()

    # Source 1 — MIGRATION-REPORT.md table extraction.
    report_path = root / "MIGRATION-REPORT.md"
    if report_path.is_file():
        try:
            text = report_path.read_text(encoding="utf-8")
        except Exception:
            text = ""
        if text:
            in_migrated_files = False
            table_row = re.compile(r"^\|\s*`[^`]+`\s*\|\s*`([^`]+)`\s*\|")
            for line in text.splitlines():
                stripped = line.strip()
                if stripped.startswith("## "):
                    in_migrated_files = stripped.lower().startswith("## migrated files")
                    continue
                if not in_migrated_files:
                    continue
                m = table_row.match(line)
                if m:
                    target = m.group(1)
                    # Skip informational placeholders like
                    # "(removed — folded into .env.example)" that leak into
                    # the report's target column for non-file rows.
                    if target.startswith("("):
                        continue
                    outputs.add(target)

    # Source 2 — tree snapshot of managed dirs (skips dotfiles + node_modules
    # via the mapper's existing _SKIP_DIRS list, applied here directly).
    skip_dirs = {
        "node_modules", "dist", ".venv", "__pycache__", ".git",
        "build", "coverage", ".next", "out", ".nyc_output",
        "test-results", "playwright-report", "allure-results", "allure-report",
    }
    for managed in _BOOTSTRAP_SNAPSHOT_DIRS:
        d = root / managed
        if not d.is_dir():
            continue
        for f in d.rglob("*"):
            if not f.is_file():
                continue
            if any(s in f.parts for s in skip_dirs):
                continue
            outputs.add(f.relative_to(root).as_posix())

    for name in _BOOTSTRAP_ROOT_FILES:
        f = root / name
        if f.is_file():
            outputs.add(name)

    return outputs


def _write_migration_manifest(
    root: Path,
    written: list[dict],
    framework: str,
    prior_outputs: set[str] | None = None,
) -> None:
    """
    Persist the helix-relative output paths from this run so the next
    in-place re-run can skip them at source-scan time.

    Unioned with `prior_outputs` so the manifest is monotonic: a re-run
    that writes few new files still records ownership of everything earlier
    runs produced. Without the union, --conflict skip + manifest would
    shrink the recorded set on every re-run and re-introduce the
    duplicate-output problem the manifest exists to prevent.
    """
    fresh = {
        w["target"] for w in written
        if w.get("target")
        and w.get("role") != "env_cleanup"
        and not w["target"].startswith("(")
    }
    combined = fresh | (prior_outputs or set())
    targets = sorted(combined)
    if not targets:
        return
    data = {
        "version":   _MIGRATION_MANIFEST_VERSION,
        "framework": framework,
        "outputs":   targets,
    }
    manifest_path = root / _MIGRATION_MANIFEST_NAME
    try:
        manifest_path.write_text(
            json.dumps(data, indent=2, ensure_ascii=False) + "\n",
            encoding="utf-8",
        )
    except Exception:
        pass


# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------

def _process_config(
    mapping: dict,
    source_dir: str,
    helix_root: str,
    written: list[dict],
    skipped: list[dict],
    features: set[str] | None = None,
) -> None:
    role        = mapping["role"]
    source_path = Path(mapping["source"])

    try:
        content = source_path.read_text(encoding="utf-8")
    except Exception as exc:
        skipped.append({"source": mapping["relative"], "reason": f"read error: {exc}"})
        return

    try:
        if role == "config_playwright":
            # The migrated tree is always cucumber-js BDD (runner = cucumber-js,
            # not @playwright/test). The Playwright Test runner config is dead
            # weight and confuses tsc, so skip emitting it.
            skipped.append({
                "source": mapping["relative"],
                "reason": "playwright.config not needed in cucumber-js BDD output",
            })
            return
        elif role == "config_cucumber":
            result = merge_cucumber_config(content, helix_root)
        elif role == "config_package":
            result = merge_package_json(content, helix_root, features=features)
        elif role == "config_tsconfig":
            result = merge_tsconfig_json(content, helix_root)
        else:
            skipped.append({"source": mapping["relative"], "reason": f"config type '{role}' not handled"})
            return

        target = Path(helix_root) / result["target"]
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(result["content"], encoding="utf-8")

        written.append({
            "source":       mapping["relative"],
            "target":       result["target"],
            "role":         role,
            "change_count": len(result.get("changes", [])),
        })
    except Exception as exc:
        skipped.append({"source": mapping["relative"], "reason": f"config merge error: {exc}"})


# ---------------------------------------------------------------------------
# Raw-copy helper (Docker, .env, CI yaml, scripts)
# ---------------------------------------------------------------------------

def _copy_raw(source: Path, target: Path) -> None:
    """Byte-for-byte copy preserving file mode (e.g. executable bit for .sh)."""
    import shutil
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(source, target)


# ---------------------------------------------------------------------------
# Project-root .env.example template
# ---------------------------------------------------------------------------

_ENV_EXAMPLE_HEADER = """\
# ──────────────────────────────────────────────────────────────────────────────
# Helix-QA — runtime configuration (single source of truth)
#
# Copy this file to `.env` and fill in the values you need.
# Variables prefixed with the active APP (e.g. DOMAIN_AMPLIFY) are resolved to
# their canonical names (DOMAIN) automatically by environment.util.ts based on
# the APP env var supplied by the npm script.
# Lines starting with # are comments — do NOT put inline comments after values.
# ──────────────────────────────────────────────────────────────────────────────
"""

_ENV_EXAMPLE_TAIL = """\

# ── Azure Key Vault (test data + secret resolution) ──────────────────────────
# Required by `currentFixture.azureKeyVault.getSecrets(...)` calls in helpers.
# In CI, these are typically set by the npm scripts via cross-env. Locally:
# AZURE_TENANT_ID=
# AZURE_CLIENT_ID=
# AZURE_CLIENT_SECRET=


# ── Self-healing AI vision (strategy 6 of LocatorHealer) ─────────────────────
#
# ENABLE_AI_HEALING
#   true  (default) — strategy 6 is active; calls the provider below on failure
#   false           — strategy 6 is skipped entirely; strategies 7 & 8
#                     (CDPSession AX tree + bounding box) still run
#
# AI_HEALING_PROVIDER
#   anthropic   (default) — direct Anthropic API; requires AI_HEALING_API_KEY
#   copilot               — GitHub Copilot chat completions; requires GITHUB_TOKEN
#   claude-code           — Claude Code local proxy; requires ANTHROPIC_API_KEY
ENABLE_AI_HEALING=true
AI_HEALING_PROVIDER=anthropic

# Required when AI_HEALING_PROVIDER=anthropic
# AI_HEALING_API_KEY=sk-ant-...

# Required when AI_HEALING_PROVIDER=copilot
# GITHUB_TOKEN=ghp_...

# Required when AI_HEALING_PROVIDER=claude-code
# (also set automatically inside a `claude` CLI session — no manual key needed)
# ANTHROPIC_API_KEY=sk-ant-...


# ── Healing Dashboard ────────────────────────────────────────────────────────
# Set HEALING_DASHBOARD_PORT=0 to disable the HTTP dashboard in CI pipelines.
# HEALIX_REVIEW_PORT, when set, starts a second READ-ONLY HTTP server that
# mirrors the dashboard's GET endpoints but rejects POST. Use it for QA leads
# / managers / external dashboards that need to inspect heals without write
# access. Off by default (0 = disabled).
# HEALING_DASHBOARD_PORT=7890
# HEALIX_REVIEW_PORT=7891


# ── LocatorRepository (heal store) ───────────────────────────────────────────
# HEAL_STORE_PATH        Path to the heal-store JSON. The LocatorRepository
#                        reads it on startup and writes (debounced) on every
#                        successful heal. Default: ./self-heals/healed-locators.json
# HEAL_LOG_PATH          One-line-per-heal audit log (tab-separated:
#                        timestamp / key / strategy / selector). Defaults to
#                        ./self-heals/heal.log next to the heal store.
# HEAL_LOG_DISABLED      Set to "true" to skip the audit log entirely.
# ENV / HELIX_ENV        When either is set, the heal store path is auto-
#                        suffixed with the env slug — `healed-locators.json`
#                        → `healed-locators.qa4.json`. Stops parallel runs
#                        against different test envs from trampling each
#                        other's healed selectors.
# ENABLE_LOCATOR_VERSIONING  "true" (default) keeps the full healingHistory[]
#                            per locator. "false" keeps only the latest heal
#                            entry (smaller file).
# HEAL_HISTORY_CAP       Cap on healingHistory length when versioning is on
#                        (default 50; set to 0 for unlimited).
# HEAL_STORE_PATH=./self-heals/healed-locators.json
# HEAL_LOG_PATH=./self-heals/heal.log
# ENABLE_LOCATOR_VERSIONING=true
# HEAL_HISTORY_CAP=50


# ── LocatorHealer chain tuning ───────────────────────────────────────────────
# LOCATOR_TIMEOUT        Per-strategy attach-probe timeout (ms). Overrides
#                        the default 3000ms. Higher values give slow pages
#                        more headroom; lower values fail faster.
# LOCATOR_HEAL_ATTEMPTS  Max probes the resolver runs across the chain
#                        before falling through to AI Vision. 0 = unlimited
#                        (default, original behaviour).
# HEAL_STRATEGY_ORDER    CSV of strategy names to run, in order. Valid names:
#                          attribute, type-hint, role, label, text
#                        Default order is the same five strategies left-to-
#                        right. Omit a name to disable it, or reorder to
#                        prioritise a strategy your app favours.
# LOCATOR_TIMEOUT=3000
# LOCATOR_HEAL_ATTEMPTS=0
# HEAL_STRATEGY_ORDER=attribute,type-hint,role,label,text


# ── Browser / runtime flags ──────────────────────────────────────────────────
# Usually set by the npm script via cross-env. These are fallbacks for ad-hoc runs.
# APP=amplify              # amplify | parishsoft
# ENV=dev1                 # dev1 | test1 | stg | prod | localhost
# BROWSER=chrome           # chrome | firefox | webkit | edge
# DOCKER=false
# CI=false
"""

# Optional VRT block — spliced in by `_write_root_env_example` only when the
# `vrt` feature is enabled (explicitly via --features=vrt or auto-detected).
# Without the runtime scaffold (src/helper/vrt/*) and the npm scripts these
# env vars are meaningless, so we keep them out by default.
_ENV_EXAMPLE_VRT_BLOCK = """\

# ── Visual Regression Testing ────────────────────────────────────────────────
# VRT_MODE              baseline | test | off — overridden by `npm run vrt:*`
# VRT_SPRINT            sprint identifier appended to baseline filenames
# VRT_THRESHOLD         pixelmatch threshold (0.0–1.0, default 0.1)
# VRT_FAILURE_THRESHOLD percentage of diff pixels above which a test fails
# VRT_STABILISATION_DELAY  ms to wait before screenshot (default 500)
# VRT_GLOBAL_MASK_SELECTORS  CSV of CSS selectors masked before every capture
# VRT_DISMISS_SELECTORS  CSV of CSS selectors auto-dismissed before capture
# VRT_MODE=off
# VRT_SPRINT=
# VRT_THRESHOLD=0.1
# VRT_FAILURE_THRESHOLD=1.0
# VRT_STABILISATION_DELAY=500
# VRT_GLOBAL_MASK_SELECTORS=
# VRT_DISMISS_SELECTORS=
"""

# Single-source-of-truth env.util.ts template. The migration tool always
# replaces the migrated copy with this so the loader semantics match the
# generated .env.example.
_ENV_UTIL_TEMPLATE = '''\
import * as dotenv from "dotenv";
import * as path from "path";

/**
 * Single source of truth: project-root `.env`.
 *
 * Per-app values live in the same file with `_<APP>` suffixes
 * (e.g. DOMAIN_AMPLIFY=..., DOMAIN_PARISHSOFT=...). After loading, the
 * APP-suffixed variant for the active app is resolved into its canonical
 * unsuffixed name (DOMAIN, HEADLESS, ...) so existing helpers keep working.
 *
 * Cross-env / shell-exported values are NEVER overridden — they win over
 * the .env file in all cases.
 */
const _APP_SCOPED_VARS = ["DOMAIN", "HEADLESS"] as const;

export const getEnvFile = (): void => {
  dotenv.config({ path: path.resolve(process.cwd(), ".env"), override: false });

  if (!process.env.ENV) {
    console.error("NO ENV PASSED!");
  }

  const app = (process.env.APP || "amplify").toUpperCase();
  for (const key of _APP_SCOPED_VARS) {
    const scoped = `${key}_${app}`;
    if (process.env[scoped] !== undefined && !process.env[key]) {
      process.env[key] = process.env[scoped];
    }
  }
};
'''


def _patch_environment_util(root: Path) -> None:
    """
    Replace `src/helper/environment/environment.util.ts` with the
    single-source-of-truth loader template.

    Idempotent: skips files already matching the template.
    """
    target = root / "src" / "helper" / "environment" / "environment.util.ts"
    if not target.exists():
        return
    try:
        existing = target.read_text(encoding="utf-8")
    except Exception:
        return
    if existing == _ENV_UTIL_TEMPLATE:
        return
    target.write_text(_ENV_UTIL_TEMPLATE, encoding="utf-8")


# A Microsoft Playwright Docker base image: `mcr.microsoft.com/playwright:vX.Y.Z-<flavor>`.
# Captures (version, flavor-suffix) so we can swap the version while preserving
# the OS flavor (jammy / noble / focal) and any digest.
_PW_DOCKER_FROM_RE = re.compile(
    r"""(?P<prefix>FROM\s+(?:--platform=\S+\s+)?mcr\.microsoft\.com/playwright:v)"""
    r"""(?P<version>\d+\.\d+\.\d+)"""
    r"""(?P<suffix>[-\w.]*)""",
    re.IGNORECASE,
)

# `"@playwright/test": "^1.60.0"` → 1.60.0 (strip range operators / pre-release).
_PW_PKG_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)")


def _read_playwright_test_version(root: Path) -> str | None:
    """Return the concrete `@playwright/test` version (X.Y.Z) from the migrated
    package.json, or None if absent/unparseable. package.json is the source of
    truth for the Playwright version; the Docker base image is aligned to it."""
    pkg_path = root / "package.json"
    if not pkg_path.exists():
        return None
    try:
        pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
    except Exception:
        return None
    for block in ("devDependencies", "dependencies"):
        spec = (pkg.get(block) or {}).get("@playwright/test")
        if spec:
            m = _PW_PKG_VERSION_RE.search(spec)
            if m:
                return m.group(1)
    return None


def _align_dockerfile_playwright_version(root: Path, written: list[dict]) -> None:
    """Align every copied Dockerfile's Playwright base-image tag to the
    `@playwright/test` version in package.json.

    The browsers a container ships are fixed by the `mcr.microsoft.com/playwright`
    base image, so when the npm dep and the image disagree the run fails inside
    Docker ("Executable doesn't exist … browser was installed for a different
    Playwright version"). package.json is treated as the source of truth and the
    image `FROM` tag is rewritten to match (OS flavor preserved). Idempotent and
    fail-safe: files without a Playwright base image, or already matching, are
    left untouched."""
    version = _read_playwright_test_version(root)
    if not version:
        return

    for dockerfile in root.rglob("Dockerfile*"):
        if not dockerfile.is_file():
            continue
        try:
            content = dockerfile.read_text(encoding="utf-8")
        except Exception:
            continue
        if "mcr.microsoft.com/playwright:v" not in content:
            continue  # not a Playwright-based image — leave it alone

        def _swap(m: re.Match) -> str:
            if m.group("version") == version:
                return m.group(0)  # already aligned
            return f"{m.group('prefix')}{version}{m.group('suffix')}"

        new_content, n = _PW_DOCKER_FROM_RE.subn(_swap, content)
        if n and new_content != content:
            dockerfile.write_text(new_content, encoding="utf-8")
            rel = dockerfile.relative_to(root).as_posix()
            written.append({
                "source":       "(dockerfile-align)",
                "target":       rel,
                "role":         "config_dockerfile",
                "change_count": n,
            })


def _harvest_per_app_env(source_dir: str) -> list[tuple[str, dict[str, str]]]:
    """
    Find any `.env.<APP>` files in the source tree and return their values as
    [(app, {KEY: VALUE, ...}), ...]. App is upper-cased.
    Used to fold legacy per-app env into the single-file .env.example.
    """
    src = Path(source_dir)
    if not src.exists():
        return []
    results: list[tuple[str, dict[str, str]]] = []
    for p in src.rglob(".env.*"):
        if not p.is_file():
            continue
        app = p.name[len(".env."):]
        if not app or app == "example":
            continue
        try:
            text = p.read_text(encoding="utf-8")
        except Exception:
            continue
        kv: dict[str, str] = {}
        for line in text.splitlines():
            stripped = line.strip()
            if not stripped or stripped.startswith("#") or "=" not in stripped:
                continue
            k, _, v = stripped.partition("=")
            kv[k.strip()] = v.strip()
        if kv:
            results.append((app.upper(), kv))
    return results


def _load_scaffold_locator_repository_fn():
    """
    Load Agent 3's canonical locator scaffold generator, even in source-tree
    runs where optional MCP runtime deps (mcp/python-dotenv) are not installed.

    Order:
      1. Normal import from agent_playwright_generator.server
      2. Shimmed import with lightweight stubs for dotenv + mcp modules
    """
    try:
        from stlc_agents.agent_playwright_generator.server import _scaffold_locator_repository

        return _scaffold_locator_repository
    except Exception:
        pass

    try:
        if "dotenv" not in sys.modules:
            dotenv_stub = pytypes.ModuleType("dotenv")
            dotenv_stub.load_dotenv = lambda *args, **kwargs: None
            sys.modules["dotenv"] = dotenv_stub

        if "requests" not in sys.modules:
            requests_stub = pytypes.ModuleType("requests")

            def _requests_unavailable(*args, **kwargs):
                raise RuntimeError("requests is unavailable in migration shim mode")

            requests_stub.get = _requests_unavailable
            requests_stub.post = _requests_unavailable
            requests_stub.put = _requests_unavailable
            requests_stub.patch = _requests_unavailable
            requests_stub.delete = _requests_unavailable
            requests_stub.request = _requests_unavailable
            sys.modules["requests"] = requests_stub

        if "mcp" not in sys.modules:
            mcp_mod = pytypes.ModuleType("mcp")
            server_mod = pytypes.ModuleType("mcp.server")
            stdio_mod = pytypes.ModuleType("mcp.server.stdio")
            types_mod = pytypes.ModuleType("mcp.types")

            class _DummyServer:
                def __init__(self, *args, **kwargs):
                    pass

                def list_tools(self):
                    def _decorator(fn):
                        return fn

                    return _decorator

                def call_tool(self):
                    def _decorator(fn):
                        return fn

                    return _decorator

            async def _dummy_stdio_server(*args, **kwargs):
                raise RuntimeError("stdio_server is unavailable in migration shim mode")

            server_mod.Server = _DummyServer
            stdio_mod.stdio_server = _dummy_stdio_server
            types_mod.TextContent = object
            types_mod.Tool = object

            mcp_mod.server = server_mod
            mcp_mod.types = types_mod

            sys.modules["mcp"] = mcp_mod
            sys.modules["mcp.server"] = server_mod
            sys.modules["mcp.server.stdio"] = stdio_mod
            sys.modules["mcp.types"] = types_mod

        mod_name = "stlc_agents.agent_playwright_generator.server_scaffold_shim"
        module = sys.modules.get(mod_name)
        if module is None:
            server_path = Path(__file__).resolve().parents[1] / "agent_playwright_generator" / "server.py"
            spec = importlib.util.spec_from_file_location(mod_name, server_path)
            if spec is None or spec.loader is None:
                return None
            module = importlib.util.module_from_spec(spec)
            sys.modules[mod_name] = module
            spec.loader.exec_module(module)

        return getattr(module, "_scaffold_locator_repository", None)
    except Exception:
        return None


def _scaffold_locators(
    root: Path,
    written: list[dict],
    conflict_strategy: str = "interactive",
) -> None:
    """
    Emit the LocatorHealer / LocatorRepository / TimingHealer / VisualIntentChecker
    / HealingDashboard scaffolds into src/utils/locators/.

    All migrated page objects + step defs reference these modules — without the
    scaffold, the project fails to compile with TS2307 "Cannot find module
    '@utils/locators/...'".

    Conflict handling:
      • "overwrite"   — replace every scaffold file with the freshly generated
                        version. Lets users pick up generator improvements
                        (new env-var wiring, expanded strategy chain, etc.).
      • "skip"        — only write scaffold files that are missing.
      • "interactive" — same as "skip" but appended to the conflicts list so
                        the caller can decide per-file.

    Defaults are conservative: AI vision + timing healing + visual checker all on,
    repository persisted under ./self-heals/healed-locators.json, dashboard on port 7890.
    """
    files: dict[str, str] = {}

    scaffold_locator_repository = _load_scaffold_locator_repository_fn()
    if scaffold_locator_repository is None:
        raise RuntimeError(
            "Cannot load canonical locator scaffold generator from "
            "stlc_agents.agent_playwright_generator.server. "
            "Run make install-both (or install required Python deps) before migration."
        )

    result = scaffold_locator_repository(
        output_dir="src/utils/locators",
        enable_ai_vision=True,
        repository_path="./self-heals/healed-locators.json",
        dashboard_port=7890,
        enable_timing_healing=True,
        enable_visual_regression=True,
    )
    files.update(result.get("files", {}))

    required_runtime_paths = (
        "src/utils/locators/LocatorHealer.ts",
        "src/utils/locators/LocatorRepository.ts",
        "src/utils/locators/TimingHealer.ts",
        "src/utils/locators/VisualIntentChecker.ts",
        "src/utils/locators/HealingDashboard.ts",
        "src/utils/locators/dashboard-server.ts",
        "src/utils/locators/clickAndAwaitNavigation.ts",
        "src/utils/locators/Scrubber.ts",
        "src/utils/locators/TelemetryClient.ts",
    )
    missing_runtime_paths = [p for p in required_runtime_paths if p not in files]
    if missing_runtime_paths:
        raise RuntimeError(
            "Canonical locator scaffold returned an incomplete runtime: "
            + ", ".join(missing_runtime_paths)
        )

    if not files:
        return

    for rel_path, body in files.items():
        target = root / rel_path
        already_exists = target.exists()
        if already_exists and conflict_strategy != "overwrite":
            # Skip (matches the historical "missing-only" behaviour). The
            # caller stays in charge of when to refresh the scaffold.
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(body, encoding="utf-8")
        written.append({
            "source":       "(scaffold)",
            "target":       rel_path,
            "role":         "scaffold",
            "change_count": 0,
        })


def _write_root_env_example(
    root: Path,
    written: list[dict],
    source_dir: str | None = None,
    conflict_strategy: str = "interactive",
    features: set[str] | None = None,
) -> None:
    """
    Emit a single `.env.example` at the helix-qa root.

    If the source project has per-app `.env.<APP>` files, their values are
    folded into the template as `KEY_<APP>` entries — preserving the existing
    project's app-specific config in the single new file.

    Conflict handling matches the main migration loop:
      • "overwrite"   — refresh the file so users pick up new template keys
                        (e.g. new healing env vars).
      • else          — skip when the file already exists.
    """
    target = root / ".env.example"
    if target.exists() and conflict_strategy != "overwrite":
        # Even when we leave .env.example alone, the per-app `.env.<APP>`
        # files are obsolete now that the root `.env` is the single source
        # of truth — clean them up regardless of conflict strategy.
        if source_dir:
            _remove_per_app_env_files(root, written)
        return

    per_app_section = ""
    if source_dir:
        harvested = _harvest_per_app_env(source_dir)
        if harvested:
            # Collect every key seen across all apps so we can group nicely.
            apps_sorted = sorted({app for app, _ in harvested})
            keys_sorted: list[str] = []
            seen: set[str] = set()
            for _, kv in harvested:
                for k in kv:
                    if k not in seen:
                        keys_sorted.append(k)
                        seen.add(k)

            lines = ["# ── Per-app overrides ────────────────────────────────────────────────────────",
                     "# Resolved to the canonical name (DOMAIN, HEADLESS, ...) by environment.util.ts",
                     "# based on the active APP. Each row is grouped by the original variable name.",
                     ""]
            for k in keys_sorted:
                for app in apps_sorted:
                    val = dict(harvested).get(app, {}).get(k)
                    if val is not None:
                        lines.append(f"{k}_{app}={val}")
                lines.append("")
            per_app_section = "\n".join(lines).rstrip() + "\n\n"

    body = _ENV_EXAMPLE_HEADER + "\n" + per_app_section + _ENV_EXAMPLE_TAIL
    if features and "vrt" in features:
        body += _ENV_EXAMPLE_VRT_BLOCK
    target.write_text(body, encoding="utf-8")
    written.append({
        "source":       "(generated)",
        "target":       ".env.example",
        "role":         "env",
        "change_count": 0,
    })

    # Now that the values are folded into root `.env.example`, remove the
    # legacy per-app `.env.<APP>` files from inside the helix tree so the
    # root `.env` (created from `.env.example`) is the single source of
    # truth. Skips `.env` / `.env.example`. For source ≠ helix this is a
    # no-op because the per-app files were never copied across in the first
    # place; for in-place runs they live under e.g.
    # `src/helper/environment/.env.amplify` and should be cleaned up.
    if source_dir:
        _remove_per_app_env_files(root, written)


def _remove_per_app_env_files(root: Path, written: list[dict]) -> None:
    """
    Delete every `.env.<APP>` file under the helix tree (excluding `.env`
    and `.env.example`). Idempotent — silently ignores missing files.
    """
    for env_path in root.rglob(".env.*"):
        if not env_path.is_file():
            continue
        name = env_path.name
        if name == ".env.example":
            continue
        # Skip anything inside a directory the scanner already ignores so we
        # don't reach into node_modules etc.
        if any(s in env_path.parts for s in {
            "node_modules", "dist", ".venv", "__pycache__", ".git",
            "build", "coverage", ".next", "out",
        }):
            continue
        try:
            rel = env_path.relative_to(root).as_posix()
        except ValueError:
            continue
        try:
            env_path.unlink()
        except Exception:
            continue
        written.append({
            "source":       rel,
            "target":       "(removed — folded into .env.example)",
            "role":         "env_cleanup",
            "change_count": 0,
        })


# ---------------------------------------------------------------------------
# Post-pass: fix locator .selector accesses
# ---------------------------------------------------------------------------

def _scaffold_browser_globals(root: Path) -> None:
    """
    Walk every migrated .ts file, find `page.evaluate(...)` callbacks, and
    collect any bare identifier function calls inside them. These reference
    browser-context functions defined by the SUT — TypeScript can't see them
    without ambient declarations. Emit `src/types/browser-globals.d.ts` with
    `declare const <name>: any;` for each name.

    Idempotent: rewrites the file each run with the union of discovered names.
    """
    import re as _re

    # Recurse over .ts files under the migrated tree (skip node_modules etc.).
    skip_dirs = {"node_modules", "dist", "build", ".git", "test-results"}
    files: list[Path] = []
    for ts_file in root.rglob("*.ts"):
        if any(s in ts_file.parts for s in skip_dirs):
            continue
        files.append(ts_file)
    if not files:
        return

    # Always include Playwright Test runner fixtures that the converted steps
    # may reference (request, testInfo) — there's no clean Cucumber equivalent
    # to inject them, but declaring them as `any` makes tsc happy and the user
    # can wire them through later.
    seed_names: set[str] = {"request", "testInfo"}

    # Collect names called as functions inside page.evaluate callbacks. We use
    # a balanced-paren scan to extract the callback text, then look for bare
    # `<ident>(` calls in it.
    evaluate_open_re = _re.compile(r"\.evaluate\s*\(")
    call_re          = _re.compile(r"\b([A-Za-z_$][\w$]*)\s*\(")
    builtin_blacklist = {
        "if", "for", "while", "return", "switch", "case", "throw", "new",
        "typeof", "instanceof", "delete", "void", "await", "async", "function",
        "Array", "Object", "Math", "JSON", "Promise", "Date", "Number", "String",
        "Boolean", "RegExp", "Error", "Map", "Set", "Symbol", "console",
        "window", "document", "globalThis", "parseInt", "parseFloat",
        "isNaN", "isFinite", "encodeURIComponent", "decodeURIComponent",
        "setTimeout", "setInterval", "clearTimeout", "clearInterval",
        "fetch", "btoa", "atob", "alert", "confirm", "prompt",
        "Array", "Object", "Boolean", "Number", "String", "Symbol",
        "Reflect", "Proxy", "WeakMap", "WeakSet", "DataView",
        "Int8Array", "Uint8Array", "Float32Array", "Float64Array",
    }

    names: set[str] = set(seed_names)
    for f in files:
        try:
            txt = f.read_text(encoding="utf-8")
        except Exception:
            continue
        # Walk each .evaluate( call; extract its callback body via paren-counting.
        for m in evaluate_open_re.finditer(txt):
            paren_open = m.end() - 1
            depth = 1
            i = paren_open + 1
            in_str: str | None = None
            n = len(txt)
            while i < n and depth > 0:
                c = txt[i]
                if in_str:
                    if c == "\\":
                        i += 2; continue
                    if c == in_str:
                        in_str = None
                    i += 1; continue
                if c in ("'", '"', "`"):
                    in_str = c
                    i += 1; continue
                if c == "(":
                    depth += 1
                elif c == ")":
                    depth -= 1
                i += 1
            if depth != 0:
                continue
            callback = txt[paren_open + 1 : i - 1]
            for cm in call_re.finditer(callback):
                name = cm.group(1)
                if name in builtin_blacklist:
                    continue
                # Skip names that are clearly local: declared with `function`,
                # `let`, `const`, `var` immediately preceding them, OR named
                # exports / class member calls (`.foo()`).
                start = cm.start()
                # Skip member-access calls — `foo.bar(`.
                if start > 0 and callback[start - 1] == ".":
                    continue
                names.add(name)

    if not names:
        return

    types_dir = root / "src" / "types"
    types_dir.mkdir(parents=True, exist_ok=True)
    out_path = types_dir / "browser-globals.d.ts"
    body = "// Auto-generated by stlc-migrate.\n"
    body += "// Ambient declarations for identifiers referenced inside\n"
    body += "// page.evaluate(...) callbacks. These functions live in the\n"
    body += "// browser context (defined by the SUT), not the Node test code,\n"
    body += "// so TypeScript can't see them — declare them as `any` shims.\n"
    body += "//\n"
    body += "// Safe to edit: replace `any` with real signatures, or remove\n"
    body += "// entries whose underlying call sites have been rewritten.\n\n"
    for name in sorted(names):
        body += f"declare const {name}: any;\n"
    out_path.write_text(body, encoding="utf-8")


def _detect_fixture_wiring_gaps(root: Path) -> dict[str, list[str]]:
    fixture_path = root / "src" / "hooks" / "pageFixture.ts"
    hooks_path = root / "src" / "hooks" / "hooks.ts"

    page_fixture_missing: list[str] = []
    hooks_missing: list[str] = []
    fixture_body = ""
    hooks_body = ""

    if fixture_path.exists():
        fixture_body = fixture_path.read_text(encoding="utf-8")
        for field, type_name in _FIXTURE_SERVICE_FIELDS.items():
            if field not in fixture_body or type_name not in fixture_body:
                page_fixture_missing.append(field)

    if hooks_path.exists():
        hooks_body = hooks_path.read_text(encoding="utf-8")
        uses_custom_bootstrap = _uses_custom_fixture_bootstrap(hooks_body, fixture_body)
        uses_initializer_helper = "initializeLocatorServices(" in hooks_body
        if not (uses_custom_bootstrap and uses_initializer_helper):
            for field, marker in _FIXTURE_HOOK_MARKERS.items():
                if field not in hooks_body or marker not in hooks_body:
                    hooks_missing.append(field)
        if not uses_custom_bootstrap and "setFixture(" not in hooks_body:
            hooks_missing.append("setFixture(...)")

    return {
        "page_fixture_missing": page_fixture_missing,
        "hooks_missing": hooks_missing,
    }


def _uses_custom_fixture_bootstrap(hooks_body: str, fixture_body: str) -> bool:
    return any((
        "initializeFixture(" in hooks_body,
        "const fixture = new PageFixture()" in hooks_body,
        "fixtures.set(" in hooks_body,
        "global.currentFixture" in fixture_body,
        "export class PageFixture" in fixture_body,
    ))


def _render_page_fixture_wiring_snippet(root: Path) -> str:
    fixture_path = root / "src" / "hooks" / "pageFixture.ts"
    if fixture_path.exists():
        fixture_body = fixture_path.read_text(encoding="utf-8")
        if "export class PageFixture" in fixture_body or "class PageFixture" in fixture_body:
            return _PAGE_FIXTURE_CLASS_WIRING_SNIPPET
    return _PAGE_FIXTURE_WIRING_SNIPPET


def _render_hooks_wiring_snippet(root: Path) -> str:
    hooks_path = root / "src" / "hooks" / "hooks.ts"
    fixture_path = root / "src" / "hooks" / "pageFixture.ts"
    hooks_body = hooks_path.read_text(encoding="utf-8") if hooks_path.exists() else ""
    fixture_body = fixture_path.read_text(encoding="utf-8") if fixture_path.exists() else ""
    if _uses_custom_fixture_bootstrap(hooks_body, fixture_body):
        return _HOOKS_INITIALIZER_WIRING_SNIPPET
    return _HOOKS_WIRING_SNIPPET


def _insert_import_lines(body: str, import_lines: list[str]) -> str:
    lines = body.splitlines()
    last_import = -1
    for idx, line in enumerate(lines):
        stripped = line.strip()
        if stripped.startswith("import ") or stripped.startswith("import type "):
            last_import = idx
            continue
        if last_import >= 0 and stripped and not stripped.startswith("//"):
            break

    insert_at = last_import + 1 if last_import >= 0 else 0
    for import_line in import_lines:
        if import_line in body:
            continue
        lines.insert(insert_at, import_line)
        insert_at += 1
    result = "\n".join(lines)
    return result + ("\n" if body.endswith("\n") else "")


def _insert_before_constructor(body: str, block: str) -> str:
    match = re.search(r"^\s*constructor\s*\(", body, re.MULTILINE)
    if match:
        return body[:match.start()] + block + body[match.start():]
    class_end = body.rfind("\n}")
    if class_end >= 0:
        return body[:class_end] + block + body[class_end:]
    return body + block


def _patch_existing_page_fixture_class(fixture_path: Path) -> bool:
    if not fixture_path.exists():
        return False

    body = fixture_path.read_text(encoding="utf-8")
    original = body
    if "class PageFixture" not in body:
        return False

    body = _insert_import_lines(body, [
        'import { LocatorRepository } from "@utils/locators/LocatorRepository";',
        'import { LocatorHealer } from "@utils/locators/LocatorHealer";',
        'import { TimingHealer } from "@utils/locators/TimingHealer";',
        'import { VisualIntentChecker } from "@utils/locators/VisualIntentChecker";',
    ])

    missing_fields: list[str] = []
    if "locatorRepository" not in body:
        missing_fields.append("  public locatorRepository!: LocatorRepository;\n")
    if "locatorHealer" not in body:
        missing_fields.append("  public locatorHealer!: LocatorHealer;\n")
    if "locatorTiming" not in body:
        missing_fields.append("  public locatorTiming!: TimingHealer;\n")
    if "locatorVisual" not in body:
        missing_fields.append("  public locatorVisual!: VisualIntentChecker;\n")
    if missing_fields:
        body = _insert_before_constructor(body, "".join(missing_fields) + "\n")

    if "initializeLocatorServices(" not in body:
        method_block = (
            "  public initializeLocatorServices(page: any, logger: any, scenarioName?: string): void {\n"
            "    const locatorRepository = new LocatorRepository();\n"
            "    const locatorHealer = new LocatorHealer(page, logger, locatorRepository);\n"
            "    locatorHealer.currentScenario = scenarioName;\n"
            "\n"
            "    this.locatorRepository = locatorRepository;\n"
            "    this.locatorHealer = locatorHealer;\n"
            "    this.locatorTiming = new TimingHealer(page, logger, locatorRepository);\n"
            "    this.locatorVisual = new VisualIntentChecker(page, logger, locatorRepository);\n"
            "  }\n\n"
        )
        body = _insert_before_constructor(body, method_block)

    if body == original:
        return False
    fixture_path.write_text(body, encoding="utf-8")
    return True


def _patch_existing_hooks_bootstrap(hooks_path: Path) -> bool:
    if not hooks_path.exists():
        return False

    body = hooks_path.read_text(encoding="utf-8")
    original = body

    if (
        "async function initializeFixture(" in body
        and "fixture.initializeLocatorServices(page, fixture.logger, scenarioName);" not in body
    ):
        init_marker = "  // Initialize all components using the fixture methods\n"
        if init_marker in body:
            body = body.replace(
                init_marker,
                "  fixture.initializeLocatorServices(page, fixture.logger, scenarioName);\n\n" + init_marker,
                1,
            )

    if (
        "switchUserContext(" in body
        and "fixture.initializeLocatorServices(newPage, fixture.logger, fixture.scenarioId);" not in body
    ):
        switch_marker = (
            "    // Step 7: Re-initialize pages and utils with new page\n"
            "    fixture.initializePages();"
        )
        if switch_marker in body:
            body = body.replace(
                switch_marker,
                "    // Step 7: Re-initialize pages and utils with new page\n"
                "    fixture.initializeLocatorServices(newPage, fixture.logger, fixture.scenarioId);\n"
                "    fixture.initializePages();",
                1,
            )

    if body == original:
        return False
    hooks_path.write_text(body, encoding="utf-8")
    return True


def _dedupe_fixture_imports_in_tree(root: Path) -> None:
    src_dir = root / "src"
    if not src_dir.exists():
        return

    pattern = re.compile(
        r'^\s*import\s+\{([^}]+)\}\s+from\s+["\']@hooks/pageFixture["\'];?\s*$'
    )

    for ts_file in src_dir.rglob("*.ts"):
        try:
            body = ts_file.read_text(encoding="utf-8")
        except Exception:
            continue

        lines = body.splitlines()
        matches: list[tuple[int, list[str]]] = []
        for idx, line in enumerate(lines):
            match = pattern.match(line)
            if not match:
                continue
            names = [name.strip() for name in match.group(1).split(",") if name.strip()]
            if "fixture" in names:
                matches.append((idx, names))

        if len(matches) <= 1:
            continue

        merged_names: list[str] = []
        for _, names in matches:
            for name in names:
                if name not in merged_names:
                    merged_names.append(name)

        first_idx = matches[0][0]
        lines[first_idx] = f'import {{ {", ".join(merged_names)} }} from "@hooks/pageFixture";'
        for idx, _ in reversed(matches[1:]):
            del lines[idx]

        ts_file.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _scaffold_page_fixture(
    root: Path,
    written: list[dict],
    vrt_enabled: bool = False,
) -> dict[str, list[str]]:
    """
    Drop an enhanced `src/hooks/pageFixture.ts` (and matching Before/After
    hooks in `src/hooks/hooks.ts`) when they are missing. Generated step files
    and migrated page objects import `fixture` from `@hooks/pageFixture`; the
    scaffold must therefore expose the shared locator services the transformed
    code calls at runtime.

    When `vrt_enabled`, the VRT-aware variants (with `vrtCapture` field and
    VRTReportGenerator wiring) are emitted instead — but only for files we
    own, i.e. ones that didn't already exist. Source-shipped hooks files are
    left alone; the operator gets a wiring snippet in MIGRATION-REPORT.md.

    Idempotent: preserves existing source-owned files and returns a report of
    any missing fixture-service wiring they still need.
    """
    hooks_dir = root / "src" / "hooks"
    fixture_path = hooks_dir / "pageFixture.ts"
    hooks_path   = hooks_dir / "hooks.ts"
    hooks_dir.mkdir(parents=True, exist_ok=True)
    if not fixture_path.exists():
        if vrt_enabled:
            fixture_path.write_text(PAGE_FIXTURE_VRT_TS, encoding="utf-8")
        else:
            # The transformed code calls fixture().locatorRepository /
            # locatorHealer / locatorTiming / locatorVisual directly, so the
            # scaffolded interface has to declare the full shared-service
            # surface rather than only page + logger.
            fixture_path.write_text(
                'import { Page, Browser, BrowserContext } from "@playwright/test";\n'
                'import { LocatorRepository } from "@utils/locators/LocatorRepository";\n'
                'import { LocatorHealer } from "@utils/locators/LocatorHealer";\n'
                'import { TimingHealer } from "@utils/locators/TimingHealer";\n'
                'import { VisualIntentChecker } from "@utils/locators/VisualIntentChecker";\n'
                "\n"
                "export interface FixtureLogger {\n"
                "  info(msg: string): void;\n"
                "  warn(msg: string): void;\n"
                "  error(msg: string): void;\n"
                "}\n"
                "\n"
                "export interface PageFixture {\n"
                "  page: Page;\n"
                "  browser?: Browser;\n"
                "  context?: BrowserContext;\n"
                "  logger: FixtureLogger;\n"
                "  locatorRepository: LocatorRepository;\n"
                "  locatorHealer: LocatorHealer;\n"
                "  locatorTiming: TimingHealer;\n"
                "  locatorVisual: VisualIntentChecker;\n"
                "}\n"
                "\n"
                "export interface FixtureServices {\n"
                "  locatorRepository: LocatorRepository;\n"
                "  locatorHealer: LocatorHealer;\n"
                "  locatorTiming: TimingHealer;\n"
                "  locatorVisual: VisualIntentChecker;\n"
                "}\n"
                "\n"
                "export function buildFixtureServices(page: Page, logger: FixtureLogger, scenarioName?: string): FixtureServices {\n"
                "  const locatorRepository = new LocatorRepository();\n"
                "  const locatorHealer = new LocatorHealer(page, logger, locatorRepository);\n"
                "  locatorHealer.currentScenario = scenarioName;\n"
                "  return {\n"
                "    locatorRepository,\n"
                "    locatorHealer,\n"
                "    locatorTiming: new TimingHealer(page, logger, locatorRepository),\n"
                "    locatorVisual: new VisualIntentChecker(page, logger, locatorRepository),\n"
                "  };\n"
                "}\n"
                "\n"
                "let _current: PageFixture | undefined;\n"
                "\n"
                "export function setFixture(f: PageFixture): void {\n"
                "  _current = f;\n"
                "}\n"
                "\n"
                "export function fixture(): PageFixture {\n"
                '  if (!_current) throw new Error("PageFixture not initialised — Before hook must call setFixture(...)");\n'
                "  return _current;\n"
                "}\n",
                encoding="utf-8",
            )
        written.append({
            "source": "(migration scaffold) pageFixture.ts",
            "target": str(fixture_path.relative_to(root)),
            "role": "hook",
            "change_count": 0,
        })
    if not hooks_path.exists():
        if vrt_enabled:
            hooks_path.write_text(HOOKS_VRT_TS, encoding="utf-8")
        else:
            # The Before hook owns the shared locator services for every
            # scenario. BasePage and the generated steps reach them through
            # fixture(), so the scaffold wires them once here.
            hooks_path.write_text(
                'import { Before, After, BeforeAll, AfterAll } from "@cucumber/cucumber";\n'
                'import { chromium, Browser, Page } from "@playwright/test";\n'
                'import { setFixture, fixture, FixtureLogger, buildFixtureServices } from "./pageFixture";\n'
                "\n"
                "let browser: Browser;\n"
                "let page:    Page;\n"
                "\n"
                "const defaultLogger: FixtureLogger = {\n"
                "  info:  (m) => console.log(`  ℹ  ${m}`),\n"
                "  warn:  (m) => console.warn(` ⚠  ${m}`),\n"
                "  error: (m) => console.error(` ✖  ${m}`),\n"
                "};\n"
                "\n"
                "BeforeAll(async () => {\n"
                "  browser = await chromium.launch({ headless: process.env.HEADLESS !== 'false' });\n"
                "});\n"
                "\n"
                "Before(async function () {\n"
                "  const context = await browser.newContext();\n"
                "  page = await context.newPage();\n"
                "  const services = buildFixtureServices(page, defaultLogger);\n"
                "  setFixture({\n"
                "    page,\n"
                "    browser,\n"
                "    context,\n"
                "    logger: defaultLogger,\n"
                "    ...services,\n"
                "  });\n"
                "});\n"
                "\n"
                "After(async function () {\n"
                "  await fixture().context?.close();\n"
                "});\n"
                "\n"
                "AfterAll(async () => {\n"
                "  await browser.close();\n"
                "});\n",
                encoding="utf-8",
            )
        written.append({
            "source": "(migration scaffold) hooks.ts",
            "target": str(hooks_path.relative_to(root)),
            "role": "hook",
            "change_count": 0,
        })

    if fixture_path.exists() and hooks_path.exists() and _uses_custom_fixture_bootstrap(
        hooks_path.read_text(encoding="utf-8"), fixture_path.read_text(encoding="utf-8")
    ):
        if _patch_existing_page_fixture_class(fixture_path):
            written.append({
                "source": "(migration patch) pageFixture.ts",
                "target": str(fixture_path.relative_to(root)),
                "role": "hook",
                "change_count": 1,
            })
        if _patch_existing_hooks_bootstrap(hooks_path):
            written.append({
                "source": "(migration patch) hooks.ts",
                "target": str(hooks_path.relative_to(root)),
                "role": "hook",
                "change_count": 1,
            })

    return _detect_fixture_wiring_gaps(root)


def _ensure_tsconfig(root: Path) -> None:
    """
    If the migrated tree has no tsconfig.json (e.g. when the source was a
    pure-JS Playwright project), write a minimal one that wires the same
    Helix path aliases used elsewhere in the migrator. Idempotent: no-op
    when a tsconfig already exists.
    """
    target = root / "tsconfig.json"
    if target.exists():
        return
    cfg = {
        "compilerOptions": {
            "target":              "ES2021",
            "module":              "commonjs",
            "lib":                 ["dom", "es2021"],
            "esModuleInterop":     True,
            "resolveJsonModule":   True,
            "skipLibCheck":        True,
            # Pragmatic defaults for a synthesised tsconfig — most source
            # projects we migrate are JS-flavoured Playwright/cucumber repos
            # that wouldn't survive `strict: true`. The user can tighten any
            # of these once they've added explicit types.
            "strict":              False,
            "noImplicitAny":       False,
            "strictNullChecks":    False,
            "allowJs":             True,
            "checkJs":             False,
            "baseUrl":             "src",
            "paths": {
                "@pages/*":    ["pages/*"],
                "@steps/*":    ["test/steps/*"],
                "@features/*": ["test/features/*"],
                # `@locators` (bare) targets the barrel produced when a source
                # monolith is split into per-page files. `@locators/*` keeps
                # working for direct per-file imports.
                "@locators":   ["locators/index"],
                "@locators/*": ["locators/*"],
                "@config/*":   ["config/*"],
                "@utils/*":    ["utils/*"],
                "@helper/*":   ["helper/*"],
                "@hooks/*":    ["hooks/*"],
            },
        },
        # The migrated tree may have raw-copied helpers/page_objects/scripts
        # at the project root (not under src/), so use a broad include with an
        # explicit exclude list rather than the default `["src"]`.
        "include": ["**/*.ts", "**/*.js"],
        "exclude": ["node_modules", "dist", "build", "test-results"],
        "ts-node": {
            "transpileOnly": True,
            "require": ["tsconfig-paths/register"],
        },
    }
    import json as _json
    target.write_text(_json.dumps(cfg, indent=2) + "\n", encoding="utf-8")


def _flush_hoist_buffer(root: Path, hoist_buffer: dict[str, list[HoistAction]]) -> None:
    """
    Append buffered HoistActions to the .locators.ts file(s) on disk that
    contain the matching xxxLocators block.

    Strategy: walk every .locators.ts under <root>/src/locators/, look for
    `export const xxxLocators = {` blocks, and pass through any actions
    whose `locator_object` matches.
    """
    if not hoist_buffer:
        return

    locator_dir = root / "src" / "locators"
    if not locator_dir.exists():
        return

    for locator_path in locator_dir.glob("*.ts"):
        try:
            content = locator_path.read_text(encoding="utf-8")
        except Exception:
            continue
        # Skip files that don't define any of our target xxxLocators.
        relevant = {
            name: actions
            for name, actions in hoist_buffer.items()
            if f"export const {name}" in content
        }
        if not relevant:
            continue
        new_content, _changes = append_entries_to_locator_file(content, relevant)
        if new_content != content:
            try:
                locator_path.write_text(new_content, encoding="utf-8")
            except Exception:
                pass


def _emit_pages_barrels(root: Path) -> None:
    """
    Walk every directory under `src/pages/` that contains `*.page.ts` files
    and write an `index.ts` barrel re-exporting each page class.

    The barrel re-emits two shapes depending on what each source file uses:
      • `export default class Foo` → `export { default as Foo } from "./foo.page";`
      • `export class Foo`         → `export { Foo } from "./foo.page";`

    Re-writing is unconditional — the barrel always reflects the *current*
    tree, so removed pages drop out of the index on re-migration without
    leaving stale entries.

    No-op when `src/pages/` is absent (some projects have no UI tests).
    """
    import re as _re
    pages_dir = root / "src" / "pages"
    if not pages_dir.exists():
        return

    # Find every dir that DIRECTLY contains `*.page.ts` (not recursively;
    # each app/feature group gets its own barrel).
    by_dir: dict[Path, list[Path]] = {}
    for ts in pages_dir.rglob("*.page.ts"):
        by_dir.setdefault(ts.parent, []).append(ts)

    if not by_dir:
        return

    for dirpath, page_files in by_dir.items():
        lines = [
            "// Auto-generated barrel — re-exports every page-object class so",
            "// callers can do `import * as P from \"@pages/<app>\"` and reference",
            "// `P.XxxPage`. Regenerated by the migration agent on every run.",
            "",
        ]
        exports: list[tuple[str, str, bool]] = []  # (class_name, rel, is_default)
        for ts in sorted(page_files):
            try:
                text = ts.read_text(encoding="utf-8")
            except Exception:
                continue
            m = _re.search(r"^export\s+(default\s+)?class\s+(\w+Page)\b",
                           text, _re.MULTILINE)
            if not m:
                continue
            is_default = bool(m.group(1))
            class_name = m.group(2)
            rel_no_ext = ts.relative_to(dirpath).with_suffix("").as_posix()
            exports.append((class_name, rel_no_ext, is_default))

        if not exports:
            continue

        exports.sort(key=lambda e: e[0])
        for class_name, rel, is_default in exports:
            if is_default:
                lines.append(f'export {{ default as {class_name} }} from "./{rel}";')
            else:
                lines.append(f'export {{ {class_name} }} from "./{rel}";')
        lines.append("")

        try:
            (dirpath / "index.ts").write_text("\n".join(lines), encoding="utf-8")
        except Exception:
            pass


def _fix_selector_accesses_in_tree(root: Path, written: list[dict]) -> None:
    """
    After all files are written, collect structured keys from locator files and
    rewrite page_object / step_def files so that `locatorGroup.key` becomes
    `locatorGroup.key.selector` wherever the key is now a { selector, intent, stability } object.
    """
    # Collect object keys from all written locator files
    object_keys_by_name: dict[str, set[str]] = {}
    for entry in written:
        if entry.get("role") != "locator":
            continue
        locator_path = root / entry["target"]
        try:
            lc = locator_path.read_text(encoding="utf-8")
            object_keys_by_name.update(collect_object_keys(lc))
        except Exception:
            pass

    if not object_keys_by_name:
        return

    # Apply fixes to every TS/JS code role that may reference locators
    _FIXABLE_ROLES = {"page_object", "step_def", "helper", "hook", "fixture", "support", "api_client"}
    for entry in written:
        if entry.get("role") not in _FIXABLE_ROLES:
            continue
        target_path = root / entry["target"]
        try:
            content = target_path.read_text(encoding="utf-8")
            new_content, changes = fix_selector_accesses(content, object_keys_by_name)
            if changes:
                target_path.write_text(new_content, encoding="utf-8")
                entry["change_count"] = entry.get("change_count", 0) + len(changes)
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Fill missing files from agent_helix_writer's BOILERPLATE
# ---------------------------------------------------------------------------

# Allow-list of BOILERPLATE entries that drop cleanly into a migrated tree.
# The goal is a *complete* framework scaffold: every file the qa-helix-writer /
# qa-playwright-generator agents expect to find (base page class, page-object
# templates, example feature, common navigation steps, global setup/teardown,
# storage-state auth helpers, AI-assistant helpers, and the locator-strategy
# layer) is filled in when absent.
#
# Compatibility with the enhanced LocatorHealer / LocatorRepository emitted by
# `_scaffold_locator_repository` is preserved:
#   - BasePage uses `fixture().locatorRepository ?? new LocatorRepository()`
#     and `new LocatorHealer(page, this.logger, this.repo)` — both supported by
#     the enhanced scaffold + the richer `_scaffold_page_fixture` pageFixture
#     interface (which now declares `logger` and `locatorRepository`).
#   - common.steps.ts uses `fixture().logger.info(...)` — provided by the
#     default console-backed logger injected in the scaffolded hooks.ts.
#
# Held back (still listed in MIGRATION-REPORT.md):
#   • `src/utils/locators/index.ts` — re-exports `HealerLogger` /
#     `HealEvent` types that exist only in the BOILERPLATE scaffold, not in
#     the enhanced LocatorHealer.
#   • `src/utils/locators/PlaywrightHealerLogger.ts` — imports the same
#     missing `HealerLogger` type.
_BOILERPLATE_FILL_ALLOWLIST = frozenset({
    # Base locator file referenced by generated page objects from
    # qa-playwright-generator. Pure addition; doesn't conflict.
    "src/locators/base.locators.ts",
    # Page-object base class. Compiles against the enhanced LocatorHealer
    # (3-arg ctor) and the optional locatorRepository field on the scaffolded
    # pageFixture. Template files and example/common test scaffolding are
    # intentionally NOT filled in — the migration brings the user's own
    # features/steps/pages, so synthetic templates and examples would just be
    # noise the operator has to delete.
    "src/pages/BasePage.ts",
    # Playwright global setup / teardown — create test-results / logs /
    # storage-state directories before the first scenario runs.
    "src/config/global-setup.ts",
    "src/config/global-teardown.ts",
    # Storage-state auth helpers — persistent browser-state login pattern.
    "src/utils/storage-state/AuthManager.ts",
    "src/utils/storage-state/AuthSetup.ts",
    # AI-assistant layer used by qa-playwright-generator for AI-driven
    # locator generation, self-healing fallback, and test-case synthesis.
    "src/utils/ai-assistant/AILocatorGenerator.ts",
    "src/utils/ai-assistant/AISelfHealing.ts",
    "src/utils/ai-assistant/AITestGenerator.ts",
    # Healing-infrastructure layer the agents reference but the enhanced
    # scaffold doesn't replace — pure additions, each independently
    # compilable against the enhanced LocatorHealer / LocatorRepository.
    "src/utils/locators/ElementContextHelper.ts",
    "src/utils/locators/HealApplicator.ts",
    "src/utils/locators/LocatorManager.ts",
    "src/utils/locators/LocatorRules.ts",
    "src/utils/locators/LocatorStrategy.ts",
    "src/utils/locators/healix-ci-apply.ts",
    "src/utils/locators/review-server.ts",
    # Helpers the locator-strategy layer depends on. Logger imports the
    # config module via a *relative* path, so we ship environment.ts in
    # the same drop — without depending on any `@config/*` tsconfig alias.
    # baseUrl.ts is the env→URL resolver imported by environment.ts.
    "src/config/baseUrl.ts",
    "src/config/environment.ts",
    "src/utils/helpers/Logger.ts",
    "src/utils/helpers/RetryHandler.ts",
    "src/utils/helpers/WaitHelper.ts",
})


def _fill_helix_boilerplate(
    root: Path,
    written: list[dict],
    conflict_strategy: str,
) -> None:
    """
    Drop in every file the qa-helix-writer / qa-playwright-generator agents
    expect a fully-populated Helix-QA tree to contain — base classes,
    AI-assistant helpers, locator strategy primitives, auth setup, retry /
    wait / logger utilities, templates, example specs.

    Source of truth: `agent_helix_writer.tools.boilerplate.BOILERPLATE` (the
    same map the agents use when bootstrapping a fresh project). The
    migration's own scaffold takes precedence for files we've enhanced
    (LocatorHealer, LocatorRepository, TimingHealer, VisualIntentChecker,
    HealingDashboard, dashboard-server) — those land first and we never
    overwrite them with the BOILERPLATE version.

    Top-level config files (package.json, tsconfig.json, .env, etc.) are
    skipped here — they're already produced by the merge passes.

    Honors `--conflict overwrite` for everything else: missing files always
    get written; existing files are only refreshed when overwrite is on.
    """
    try:
        from stlc_agents.agent_helix_writer.tools.boilerplate import BOILERPLATE
    except Exception:
        # Boilerplate package not importable — nothing to fill.
        return

    # FILL-ONLY: never overwrite an existing file regardless of
    # conflict_strategy. Only files in the allowlist are even considered,
    # because the broader BOILERPLATE set contains components that assume
    # API surfaces the user's migrated code doesn't expose.
    for rel_path, body in BOILERPLATE.items():
        if rel_path not in _BOILERPLATE_FILL_ALLOWLIST:
            continue
        target = root / rel_path
        if target.exists():
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(_rewrite_boilerplate_aliases(body, rel_path), encoding="utf-8")
        written.append({
            "source":       "(boilerplate) agent_helix_writer",
            "target":       rel_path,
            "role":         "boilerplate",
            "change_count": 0,
        })


def _rewrite_boilerplate_aliases(body: str, rel_path: str) -> str:
    """
    Rewrite `@config/...` imports in filled-in boilerplate files to a relative
    path. The user's tsconfig.json may already map `@config/*` to a different
    location (e.g. project-root `../config/*`), in which case our boilerplate
    files would fail to resolve `environment` etc. Using a relative path
    sidesteps any tsconfig alias collision.

    Only `@config/*` is rewritten — `@utils/*`, `@hooks/*`, `@pages/*`,
    `@locators/*` are the standard Helix aliases the migrator already
    guarantees match the helix layout.
    """
    import re as _re

    # Compute the relative path from this file's directory to `src/config/`.
    rel = Path(rel_path)
    here = rel.parent.as_posix().split("/")
    target = "src/config".split("/")
    common = 0
    while common < len(here) and common < len(target) and here[common] == target[common]:
        common += 1
    ups = ["..", ] * (len(here) - common)
    downs = target[common:]
    prefix = "/".join(ups + downs) if (ups or downs) else "."

    def _sub(m: "_re.Match[str]") -> str:
        return f'{m.group(1)}{prefix}/{m.group(2)}{m.group(3)}'

    # Match `from "@config/<x>"` and `from '@config/<x>'`.
    pat = _re.compile(r'(from\s+["\'])@config/([^"\']+)(["\'])')
    return pat.sub(_sub, body)


# ---------------------------------------------------------------------------
# Agent-asset installation (skills, .mcp.json, governance docs)
# ---------------------------------------------------------------------------

_ADO_SKILL_DIRS = (
    "generate-test-cases",
    "generate-gherkin",
    "generate-playwright-code",
    "write-helix-files",
    "deduplication-protocol",
    "migrate-framework",
)
_JIRA_SKILL_DIRS = (
    "qa-jira-manager",
    "generate-gherkin",
    "generate-playwright-code",
    "write-helix-files",
    "deduplication-protocol",
    "migrate-framework",
)


def _stlc_agents_root() -> Path:
    """
    Absolute path to the stlc-agents package install — used to locate the
    skill source tree, governance docs, and agent binaries. We resolve from
    *this* file's location so the same code works whether stlc-agents is
    installed editable (`pip install -e .`) or from a wheel.
    """
    # _migrate.py lives at <root>/src/stlc_agents/agent_migration/_migrate.py
    return Path(__file__).resolve().parents[3]


def _agent_binary_path(name: str) -> str:
    """
    Locate an installed agent's console-script binary. Falls back to a bare
    name (which only works when the user has stlc-agents' venv on PATH) so
    the .mcp.json is always emittable.
    """
    venv_bin = Path(sys.executable).parent
    candidate = venv_bin / name
    return str(candidate) if candidate.exists() else name


def _install_agent_assets(
    root: Path,
    written: list[dict],
    integration: str,
    conflict_strategy: str,
) -> None:
    """
    Drop in the assets every QA-STLC agent expects to find in a Helix-QA tree:

      • `.claude/skills/<name>/SKILL.md`   per integration (ado / jira / both)
      • `.claude/AGENT-BEHAVIOR.md`        zero-inference contract
      • `AGENT-BEHAVIOR.md` (root)         readable from the project root
      • `ORCHESTRATION_RULES.md`           multi-step workflow rules (if present)
      • `.mcp.json`                        MCP server registry — Claude Code,
                                           Cursor, and other MCP-aware clients
                                           auto-discover agents from this file
      • `.github/copilot-instructions/`    same skills, flattened for VS Code

    Behavior is gated on conflict_strategy:
      "overwrite" → always refresh (picks up tool-side improvements)
      else        → only write missing files
    """
    stlc_root  = _stlc_agents_root()
    skills_dir = stlc_root / "skills"
    if not skills_dir.exists():
        # stlc-agents not installed editable & wheel didn't ship skills/ —
        # nothing to do (no warning since this path is rare).
        return

    overwrite = conflict_strategy == "overwrite"
    chosen: tuple[str, ...]
    if integration == "jira":
        chosen = _JIRA_SKILL_DIRS
    elif integration == "ado":
        chosen = _ADO_SKILL_DIRS
    else:  # "both" / default
        chosen = tuple(dict.fromkeys((*_ADO_SKILL_DIRS, *_JIRA_SKILL_DIRS)))

    # 1. Claude Code: nested SKILL.md inside .claude/skills/<name>/
    claude_skills = root / ".claude" / "skills"
    claude_skills.mkdir(parents=True, exist_ok=True)
    for name in chosen:
        src = skills_dir / name / "SKILL.md"
        if not src.exists():
            continue
        dst = claude_skills / name / "SKILL.md"
        if dst.exists() and not overwrite:
            continue
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(src, dst)
        written.append({
            "source":       f"(stlc-agents) skills/{name}/SKILL.md",
            "target":       str(dst.relative_to(root)),
            "role":         "skill",
            "change_count": 0,
        })

    # 2. VS Code: flat .github/copilot-instructions/<name>.md
    vscode_skills = root / ".github" / "copilot-instructions"
    vscode_skills.mkdir(parents=True, exist_ok=True)
    for name in chosen:
        src = skills_dir / name / "SKILL.md"
        if not src.exists():
            continue
        dst = vscode_skills / f"{name}.md"
        if dst.exists() and not overwrite:
            continue
        shutil.copyfile(src, dst)
        written.append({
            "source":       f"(stlc-agents) skills/{name}/SKILL.md",
            "target":       str(dst.relative_to(root)),
            "role":         "skill",
            "change_count": 0,
        })

    # 3. Governance docs (AGENT-BEHAVIOR.md, ORCHESTRATION_RULES.md)
    for doc_name in ("AGENT-BEHAVIOR.md", "ORCHESTRATION_RULES.md"):
        src = stlc_root / doc_name
        # AGENT-BEHAVIOR.md actually lives under skills/ — try both paths.
        if not src.exists():
            src = skills_dir / doc_name
        if not src.exists():
            continue
        for dst in (root / doc_name, root / ".claude" / doc_name,
                    root / ".github" / "copilot-instructions" / doc_name):
            if dst.exists() and not overwrite:
                continue
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copyfile(src, dst)
            written.append({
                "source":       f"(stlc-agents) {src.relative_to(stlc_root)}",
                "target":       str(dst.relative_to(root)),
                "role":         "agent-doc",
                "change_count": 0,
            })

    # 4. .mcp.json — MCP server registry (Claude Code / Cursor pick this up)
    mcp_path = root / ".mcp.json"
    if not mcp_path.exists() or overwrite:
        mcp_path.write_text(_render_mcp_config(integration), encoding="utf-8")
        written.append({
            "source":       "(stlc-agents) auto-generated",
            "target":       ".mcp.json",
            "role":         "mcp-config",
            "change_count": 0,
        })


def _render_mcp_config(integration: str) -> str:
    """
    Emit a .mcp.json listing the agent MCP servers for the selected
    integration. Always includes the shared agents (gherkin / playwright /
    helix / migration) and the Playwright MCP. ADO/Jira-specific entries
    are added based on `integration`.
    """
    servers: dict[str, dict] = {
        "qa-gherkin-generator":    {"command": _agent_binary_path("qa-gherkin-generator")},
        "qa-playwright-generator": {"command": _agent_binary_path("qa-playwright-generator")},
        "qa-helix-writer":         {"command": _agent_binary_path("qa-helix-writer")},
        "qa-migration":            {"command": _agent_binary_path("stlc-migrate")},
        "playwright":              {"command": "npx", "args": ["@playwright/mcp@latest", "--isolated"]},
    }
    if integration in ("ado", "both"):
        servers["qa-test-case-manager"] = {"command": _agent_binary_path("qa-test-case-manager")}
    if integration in ("jira", "both"):
        servers["qa-jira-manager"] = {
            "command": _agent_binary_path("qa-jira-manager"),
            "env": {
                "JIRA_CLIENT_ID":     "${JIRA_CLIENT_ID}",
                "JIRA_CLIENT_SECRET": "${JIRA_CLIENT_SECRET}",
                "JIRA_CLOUD_ID":      "${JIRA_CLOUD_ID}",
            },
        }
    return json.dumps({"mcpServers": servers}, indent=2) + "\n"
