"""Merge source playwright/cucumber config values into Helix equivalents."""
from __future__ import annotations
import json
import re
import textwrap
from pathlib import Path

from stlc_agents.agent_migration.mapper import _feature_kebab


# Matches a quoted path ending in `.feature` inside a config file, capturing
# (quote, dir-prefix, filename-stem). Excludes globbed segments — a path whose
# basename contains `*` (e.g. `**/*.feature`) is a glob, not a concrete file
# reference, so we leave it untouched.
_FEATURE_PATH_RE = re.compile(
    r"""(['"])(?P<path>[^'"]*?/)?(?P<stem>[^'"/*]+)\.feature\1"""
)


def _rewrite_feature_paths(body: str) -> str:
    """Kebab-case the FILENAME of every concrete `*.feature` path reference.

    Migration renames feature files to kebab-case (mapper._feature_kebab), but
    cucumber profiles copy their `paths: [...]` verbatim — so a source reference
    like `.../loginAndSaveStorageState.feature` would dangle against the renamed
    `.../login-and-save-storage-state.feature`, silently matching 0 scenarios.
    We rewrite only the basename and preserve the directory; globbed paths
    (`**/*.feature`) are left as-is because they don't name a specific file."""
    def _sub(m: re.Match) -> str:
        q, prefix, stem = m.group(1), m.group("path") or "", m.group("stem")
        return f"{q}{prefix}{_feature_kebab(stem)}.feature{q}"

    return _FEATURE_PATH_RE.sub(_sub, body)


# ---------------------------------------------------------------------------
# Playwright config
# ---------------------------------------------------------------------------

def merge_playwright_config(source_content: str, helix_root: str) -> dict:
    """
    Extract key settings from a source playwright.config.js/ts and merge
    them into the Helix playwright.config.ts.

    Returns { content: str, target: str, changes: list[str] }
    """
    settings = _extract_playwright_settings(source_content)
    helix_cfg = Path(helix_root) / "playwright.config.ts"

    if helix_cfg.exists():
        existing = helix_cfg.read_text(encoding="utf-8")
        merged, changes = _merge_pw_into_existing(existing, settings)
    else:
        merged  = _generate_pw_config(settings)
        changes = ["Generated new playwright.config.ts from source settings"]

    return {"content": merged, "target": "playwright.config.ts", "changes": changes}


def _extract_playwright_settings(cfg: str) -> dict:
    settings: dict = {}

    _find = lambda pattern: (re.search(pattern, cfg) or None) and re.search(pattern, cfg).group(1)

    raw_url = _find(r"baseURL\s*:\s*['\"]([^'\"]+)['\"]")
    if raw_url:
        settings["baseURL"] = raw_url

    raw_timeout = _find(r"\btimeout\s*:\s*(\d+)")
    if raw_timeout:
        settings["timeout"] = int(raw_timeout)

    raw_retries = _find(r"\bretries\s*:\s*(\d+)")
    if raw_retries:
        settings["retries"] = int(raw_retries)

    raw_workers = _find(r"\bworkers\s*:\s*(\d+)")
    if raw_workers:
        settings["workers"] = int(raw_workers)

    for k in ("screenshot", "video", "trace"):
        m = re.search(rf"\b{k}\s*:\s*['\"]([^'\"]+)['\"]", cfg)
        if m:
            settings[k] = m.group(1)

    return settings


def _merge_pw_into_existing(existing: str, settings: dict) -> tuple[str, list[str]]:
    changes: list[str] = []
    result = existing

    for key, value in settings.items():
        if re.search(rf"\b{re.escape(key)}\s*:", result):
            continue  # already present

        val_str = f"'{value}'" if isinstance(value, str) else str(value)

        if key in ("baseURL", "screenshot", "video", "trace"):
            use_m = re.search(r"(use\s*:\s*\{)", result, re.DOTALL)
            if use_m:
                pos = use_m.end()
                result = result[:pos] + f"\n    {key}: {val_str}," + result[pos:]
                changes.append(f"Merged use.{key} = {val_str}")
                continue

        # Top-level setting
        close = result.rfind("}")
        if close >= 0:
            result = result[:close] + f"  {key}: {val_str},\n" + result[close:]
            changes.append(f"Merged top-level {key} = {val_str}")

    return result, changes


def _generate_pw_config(settings: dict) -> str:
    base_url  = settings.get("baseURL",    "http://localhost:3000")
    timeout   = settings.get("timeout",    30_000)
    retries   = settings.get("retries",    1)
    workers   = settings.get("workers",    1)
    ss        = settings.get("screenshot", "only-on-failure")
    video     = settings.get("video",      "retain-on-failure")
    trace     = settings.get("trace",      "on-first-retry")

    return (
        "import { defineConfig } from '@playwright/test';\n\n"
        "export default defineConfig({\n"
        "  testDir: './src/test',\n"
        f"  timeout: {timeout},\n"
        f"  retries: {retries},\n"
        f"  workers: {workers},\n"
        "  use: {\n"
        f"    baseURL:    '{base_url}',\n"
        f"    screenshot: '{ss}',\n"
        f"    video:      '{video}',\n"
        f"    trace:      '{trace}',\n"
        "  },\n"
        "});\n"
    )


# ---------------------------------------------------------------------------
# Cucumber config
# ---------------------------------------------------------------------------

def merge_cucumber_config(source_content: str, helix_root: str) -> dict:
    """
    Extract profile definitions from a source cucumber.js/cjs/mjs and emit
    a Helix cucumber.js at config/cucumber.js.

    The file is intentionally written as CommonJS .js (NOT .ts) because:
      • cucumber-js auto-loads its config file BEFORE ts-node is registered,
        so a .ts config can't be parsed unless callers manually bootstrap ts-node.
      • Keeping the path stable (config/cucumber.js) means existing npm scripts
        that reference `--config=config/cucumber.js` continue to work after migration.

    Returns { content: str, target: str, changes: list[str] }
    """
    profiles = _extract_cucumber_profiles(source_content)
    target    = "config/cucumber.js"

    changes = []

    if not profiles:
        return {"content": "module.exports = {};\n", "target": target, "changes": []}

    # Build fresh content — regenerate each profile as properly indented JavaScript
    lines = ["module.exports = {"]
    for name, body in profiles.items():
        changes.append(f"Merged cucumber profile '{name}'")
        dedented = textwrap.dedent(body).strip()
        # Ensure dotenv loads BEFORE ts-node compiles any step / page-object
        # module. Without this, `.env` is never read and runtime config knobs
        # like HEADLESS, HEAL_STORE_PATH, ENABLE_AI_HEALING etc. silently
        # fall back to their compile-time defaults.
        dedented, injected = _ensure_dotenv_in_require_module(dedented)
        if injected:
            changes.append(f"Injected dotenv/config into '{name}' requireModule")
        # Realign concrete `*.feature` path references to the kebab-cased
        # filenames the migration actually produces (the mapper renames feature
        # files; the profile body copied the old name verbatim).
        rewritten = _rewrite_feature_paths(dedented)
        if rewritten != dedented:
            changes.append(f"Rewrote feature path(s) in '{name}' to kebab-case")
            dedented = rewritten
        indented_body = "\n".join(
            "    " + ln if ln.strip() else ln
            for ln in dedented.splitlines()
        )
        lines.append(f"  {name}: {{")
        lines.append(indented_body)
        lines.append("  },")
    lines.append("};\n")

    return {"content": "\n".join(lines), "target": target, "changes": changes}


def _ensure_dotenv_in_require_module(body: str) -> tuple[str, bool]:
    """
    Prepend `"dotenv/config"` to the profile's `requireModule: [ ... ]` array
    so dotenv loads before ts-node / tsconfig-paths bootstrap. If the profile
    has no requireModule key, leave the body alone (signals a non-TS profile
    that doesn't need dotenv pre-loading either).

    Returns (new_body, injected_flag).
    """
    # Already present — nothing to do.
    if re.search(r'requireModule\s*:\s*\[[^\]]*["\']dotenv/config["\']', body):
        return body, False
    pattern = re.compile(r'(requireModule\s*:\s*\[\s*)')
    new, n = pattern.subn(r'\1"dotenv/config", ', body, count=1)
    if n == 0:
        return body, False
    return new, True


def _extract_cucumber_profiles(cfg: str) -> dict[str, str]:
    """
    Extract top-level profile blocks using brace counting so nested objects
    (e.g. formatOptions: { ... }) are captured correctly.
    """
    profiles: dict[str, str] = {}
    # Skip JS builtins/keywords that appear as object/variable names, but NOT
    # "default" — that is a valid cucumber profile name (module.exports.default).
    _skip = {"module", "exports", "require", "const", "let", "var"}

    # Find each  <name>: {  at the top-level of the outer object
    key_re = re.compile(r"\b(\w+)\s*:\s*\{")
    i = 0
    while i < len(cfg):
        m = key_re.search(cfg, i)
        if not m:
            break
        name = m.group(1)
        brace_start = m.end() - 1  # position of the opening '{'

        # Count braces to find the matching closing '}'
        depth = 0
        j = brace_start
        while j < len(cfg):
            if cfg[j] == "{":
                depth += 1
            elif cfg[j] == "}":
                depth -= 1
                if depth == 0:
                    break
            j += 1

        # Keep leading newline so textwrap.dedent can detect common indent
        body = cfg[brace_start + 1: j]

        if name not in _skip:
            profiles[name] = body

        i = j + 1

    return profiles


# ---------------------------------------------------------------------------
# package.json — merge npm scripts
# ---------------------------------------------------------------------------

def merge_package_json(
    source_content: str,
    helix_root: str,
    features: set[str] | None = None,
) -> dict:
    """
    Merge source package.json into the Helix one, preserving everything needed
    to actually run the migrated test suite:

      • scripts that invoke cucumber-js / playwright / cross-env
      • dependencies  — required at runtime by the migrated code
      • devDependencies — required to build / type-check / install browsers
      • engines / type / private fields if present

    Existing Helix entries are NEVER overwritten (target wins on conflict).

    Returns { content: str, target: str, changes: list[str] }
    """
    target = "package.json"
    target_path = Path(helix_root) / target
    changes: list[str] = []

    try:
        source_pkg = json.loads(source_content)
    except Exception as exc:
        return {"content": source_content, "target": target,
                "changes": [f"Could not parse source package.json: {exc}"]}

    if target_path.exists():
        try:
            target_pkg = json.loads(target_path.read_text(encoding="utf-8"))
        except Exception:
            target_pkg = _default_pkg()
    else:
        target_pkg = _default_pkg()

    # ── scripts: bring over runner scripts only ─────────────────────────────
    tgt_scripts = target_pkg.setdefault("scripts", {})
    _RUNNERS = ("cucumber-js", "playwright", "cross-env")
    for name, cmd in (source_pkg.get("scripts") or {}).items():
        if name in tgt_scripts:
            continue
        if not any(r in cmd for r in _RUNNERS):
            continue
        tgt_scripts[name] = cmd
        changes.append(f"Merged npm script '{name}'")

    # ── healix dashboard launchers ──────────────────────────────────────────
    # `healix:dashboard` starts the full read/write HealingDashboard at
    # HEALING_DASHBOARD_PORT (default 7890).
    # `healix:review`    starts just the read-only review server at
    # HEALIX_REVIEW_PORT (default 7891). Both honor whatever values are in
    # .env — the only difference is `healix:review` disables the dashboard
    # write port by default so QA leads can't accidentally approve/reject
    # while running the review-only view.
    #
    # Variant selection is driven by a CLI flag (`--review`) handled inside
    # dashboard-server.ts rather than by `KEY=${VAR:-default}` env-var
    # substitution in the npm script — the latter is bash-only and silently
    # breaks on cmd.exe / PowerShell. The launcher does the defaulting in JS
    # so the npm script works identically on every shell.
    _LAUNCHER = (
        "ts-node -r tsconfig-paths/register src/utils/locators/dashboard-server.ts"
    )
    _HEALIX_SCRIPTS = {
        "healix:dashboard": _LAUNCHER,
        "healix:review":    f"{_LAUNCHER} --review",
        # Offline preview of the exact scrubbed heal-event payload that would
        # be uploaded if STLC_TELEMETRY=on AND telemetry consent is accepted.
        # Prints to stdout — sends nothing.
        "healix:telemetry-preview": "ts-node -r tsconfig-paths/register src/utils/locators/TelemetryClient.ts preview",
    }
    for name, cmd in _HEALIX_SCRIPTS.items():
        if name in tgt_scripts:
            continue
        tgt_scripts[name] = cmd
        changes.append(f"Added healix npm script: {name}")

    # ── dependencies + devDependencies: bring over all unless already set ──
    for key in ("dependencies", "devDependencies"):
        src_block = source_pkg.get(key) or {}
        if not src_block:
            continue
        tgt_block = target_pkg.setdefault(key, {})
        for dep, version in src_block.items():
            if dep in tgt_block:
                continue
            tgt_block[dep] = version
            changes.append(f"Merged {key[:-1]}: {dep}@{version}")

    # ── ensure the migrated framework's runtime deps are present ───────────
    # winston is required by every healer-injected page object / step def.
    deps = target_pkg.setdefault("dependencies", {})
    if "winston" not in deps and "winston" not in target_pkg.get("devDependencies", {}):
        deps["winston"] = "^3.13.0"
        changes.append("Added runtime dep: winston (healer logging)")
    # dotenv is required so the .env at the migrated tree root is loaded
    # BEFORE any module reads process.env. The generated cucumber.js wires
    # it into `requireModule` (see merge_cucumber_config), but the package
    # itself must be installable too.
    if "dotenv" not in deps and "dotenv" not in target_pkg.get("devDependencies", {}):
        deps["dotenv"] = "^16.4.5"
        changes.append("Added runtime dep: dotenv (.env loader for cucumber)")

    # ── TypeScript toolchain — migrated tree is always .ts ─────────────────
    # We always emit TypeScript step defs / page objects / locators, so the
    # migrated project must be able to build and run them. cucumber-js needs
    # ts-node to load .ts files; tsconfig-paths resolves @-aliases at runtime.
    dev_deps = target_pkg.setdefault("devDependencies", {})
    _REQUIRED_DEV: dict[str, str] = {
        "typescript":       "^5.4.5",
        "ts-node":          "^10.9.2",
        "@cucumber/cucumber": "^10.8.0",
        "@types/node":      "^20.14.2",
        "tsconfig-paths":   "^4.2.0",
    }
    for dep, version in _REQUIRED_DEV.items():
        if dep not in dev_deps and dep not in deps:
            dev_deps[dep] = version
            changes.append(f"Added devDependency: {dep}@{version} (TS/BDD toolchain)")

    # ── @types/* companions for JS-only packages the source depends on ─────
    # Curated list of packages that don't ship TypeScript declarations and
    # have an `@types/<name>` counterpart on DefinitelyTyped. When the source
    # uses one of these, the migrated tree needs the types for tsc to resolve
    # the imports under TypeScript. We only add the @types/* when:
    #   1. The package itself appears in deps or devDeps (source or merged), AND
    #   2. The @types counterpart isn't already declared (anywhere).
    #
    # Versions are intentionally generic (`*`) — types packages are loosely
    # versioned and don't need to match the runtime version exactly.
    _TYPES_FOR: dict[str, str] = {
        "fs-extra":      "^11.0.0",
        "lodash":        "^4.17.0",
        "glob":          "^8.1.0",
        "mkdirp":        "^2.0.0",
        "rimraf":        "^4.0.0",
        "uuid":          "^9.0.0",
        "js-yaml":       "^4.0.0",
        "jsonwebtoken":  "^9.0.0",
        "module-alias":  "^2.0.0",
        "cookie":        "^0.6.0",
        "express":       "^4.17.0",
        "cors":          "^2.8.0",
        "node-fetch":    "^2.6.0",
        "minimist":      "^1.2.0",
        "yargs":         "^17.0.0",
        "semver":        "^7.5.0",
    }
    all_pkg_names = set(deps.keys()) | set(dev_deps.keys())
    src_dep_names: set[str] = set()
    for key in ("dependencies", "devDependencies"):
        src_dep_names.update((source_pkg.get(key) or {}).keys())
    needs_types = all_pkg_names | src_dep_names
    for js_pkg, types_version in _TYPES_FOR.items():
        if js_pkg not in needs_types:
            continue
        types_pkg = f"@types/{js_pkg}"
        if types_pkg in dev_deps or types_pkg in deps:
            continue
        dev_deps[types_pkg] = types_version
        changes.append(
            f"Added devDependency: {types_pkg}@{types_version} "
            f"(TS types for {js_pkg})"
        )

    # ── single-field passthroughs the user almost always wants ─────────────
    for field in ("type", "private", "engines"):
        if field in source_pkg and field not in target_pkg:
            target_pkg[field] = source_pkg[field]
            changes.append(f"Carried over field: {field}")

    # ── Optional feature scaffolds (VRT, ...) ──────────────────────────────
    # When --features=vrt is on, the runtime needs:
    #   • npm scripts `vrt:baseline` / `vrt:test`
    #   • runtime deps pngjs + pixelmatch + fs-extra (used by vrtCapture.util.ts)
    #   • @types/* companions so the TS toolchain resolves the imports
    # The .env block, helper/vrt/* files, and step defs are scaffolded by the
    # caller in `_migrate.py` — this function only owns the package.json side.
    if features and "vrt" in features:
        _VRT_SCRIPTS = {
            "vrt:baseline": "cross-env VRT_MODE=baseline cucumber-js --config=config/cucumber.js",
            "vrt:test":     "cross-env VRT_MODE=test cucumber-js --config=config/cucumber.js",
        }
        for name, cmd in _VRT_SCRIPTS.items():
            if name in tgt_scripts:
                continue
            tgt_scripts[name] = cmd
            changes.append(f"Added VRT npm script: {name}")

        _VRT_DEPS = {
            "pngjs":      "^7.0.0",
            "pixelmatch": "^5.3.0",
            "fs-extra":   "^11.2.0",
        }
        for name, version in _VRT_DEPS.items():
            if name in deps or name in dev_deps:
                continue
            deps[name] = version
            changes.append(f"Added VRT runtime dep: {name}@{version}")

        _VRT_TYPES = {
            "@types/pngjs":      "^6.0.0",
            "@types/pixelmatch": "^5.2.0",
            "@types/fs-extra":   "^11.0.0",
        }
        for name, version in _VRT_TYPES.items():
            if name in dev_deps or name in deps:
                continue
            dev_deps[name] = version
            changes.append(f"Added VRT devDependency: {name}@{version}")

        # `cross-env` is needed by the vrt:* scripts above. The TS toolchain
        # block earlier doesn't include it (the source may have brought it).
        if "cross-env" not in dev_deps and "cross-env" not in deps:
            dev_deps["cross-env"] = "^7.0.3"
            changes.append("Added VRT devDependency: cross-env@^7.0.3 (vrt:* scripts)")

    content = json.dumps(target_pkg, indent=2) + "\n"
    return {"content": content, "target": target, "changes": changes}


def _default_pkg() -> dict:
    return {
        "name":    "helix-qa",
        "version": "1.0.0",
        "scripts": {},
    }


# ---------------------------------------------------------------------------
# tsconfig.json — preserve source compiler options, ensure Helix path aliases
# ---------------------------------------------------------------------------

_HELIX_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/*"],
}


def merge_tsconfig_json(source_content: str, helix_root: str) -> dict:
    """
    Take the source tsconfig.json as a baseline and ensure the Helix path
    aliases (@pages, @steps, @helper, @hooks, ...) are all present. Source
    options are preserved verbatim — we only add what's truly missing for
    the migrated tree to load (path aliases, baseUrl).

    Notably: we do NOT add `strict: true` or `strictNullChecks: false`. The
    strictness setting is a project-wide policy decision that belongs to the
    source author; introducing strictness the source didn't have would
    surface cascading errors on code that compiled fine before migration.
    If the source omitted `strict`, the migrated project also omits it
    (TypeScript's default is non-strict).

    Returns { content: str, target: str, changes: list[str] }
    """
    target = "tsconfig.json"
    changes: list[str] = []

    try:
        # Strip // and /* */ comments — tsconfig allows them, json doesn't
        cleaned = _strip_jsonc(source_content)
        cfg     = json.loads(cleaned)
    except Exception as exc:
        # If parsing failed, fall back to a minimal Helix tsconfig.
        cfg = {}
        changes.append(f"Could not parse source tsconfig.json ({exc}); using defaults")

    opts = cfg.setdefault("compilerOptions", {})

    # baseUrl + paths — needed so @-aliased imports resolve under src/.
    opts.setdefault("baseUrl", "src")
    paths = opts.setdefault("paths", {})
    for alias, mapping in _HELIX_PATHS.items():
        if alias not in paths:
            paths[alias] = mapping
            changes.append(f"Added tsconfig alias: {alias}")

    # Toolchain defaults — only when source omits them, so the migrated
    # project can at least parse and emit. These are non-policy options
    # (output format, lib targeting) so adding them is safe.
    opts.setdefault("skipLibCheck", True)
    opts.setdefault("esModuleInterop", True)
    opts.setdefault("resolveJsonModule", True)
    opts.setdefault("target", "ES2021")
    opts.setdefault("module", "commonjs")
    opts.setdefault("lib", ["dom", "es2021"])

    if "include" not in cfg:
        cfg["include"] = ["src"]
        changes.append("Set include: ['src']")

    content = json.dumps(cfg, indent=2) + "\n"
    return {"content": content, "target": target, "changes": changes}


def _strip_jsonc(text: str) -> str:
    """Strip line- and block-comments so json.loads can parse tsconfig."""
    out: list[str] = []
    i = 0
    n = len(text)
    in_str = False
    str_ch = ""
    while i < n:
        c = text[i]
        if in_str:
            out.append(c)
            if c == "\\" and i + 1 < n:
                out.append(text[i + 1])
                i += 2
                continue
            if c == str_ch:
                in_str = False
            i += 1
            continue
        if c in ('"', "'"):
            in_str = True
            str_ch = c
            out.append(c)
            i += 1
            continue
        if c == "/" and i + 1 < n:
            if text[i + 1] == "/":
                # line comment — skip to end of line
                j = text.find("\n", i)
                if j < 0:
                    break
                i = j
                continue
            if text[i + 1] == "*":
                j = text.find("*/", i + 2)
                if j < 0:
                    break
                i = j + 2
                continue
        out.append(c)
        i += 1
    return "".join(out)
