"""Project context: per-project config, file readers, skills sync, gitignore.

Groups everything that reads or modifies the *project directory* itself
(as opposed to the autobuilder internals or the agent).
"""

import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple

from common import (
    AUTOBUILDER_DIR,
    BUILDER_SKILLS_DIR,
    REVIEW_STRATEGIES,
    SKILLS_TARGET,
    SKILLS_TARGET_LEGACY,
)
from frontmatter import parse_bool

# ---------------------------------------------------------------------------
# Per-project config (.autobuilder/config.yaml)
# ---------------------------------------------------------------------------

_PROJECT_CONFIG_DEFAULTS: Dict[str, object] = {
    "no_push": False,
    "review_polish_weight": 0.9,
    "review_max_tasks_polish": 5,
    "review_max_tasks_feature": 3,
    "review_strategies": None,
    "strategy_max_tasks": {},
    "custom_review_strategies": [],
    # Early-complete mode: review only functionality + critical UX,
    # converge after N consecutive idle iterations and exit cleanly.
    "early_complete": False,
    "early_complete_idle_threshold": 1,
    # Per-phase executor/model overrides. Internal shape:
    #   {"review": {"executor": "claude", "model": "sonnet"}, ...}
    # Phases not listed inherit the daemon-level --executor / --model.
    "phase_executors": {},
    # Per-task fanout: how many tasks to bundle into a single agent call.
    # ``null`` (default) = batch every pending task into one prompt —
    # cheapest under per-token billing because the iteration header isn't
    # replayed once per task via the chat-history. Set to ``1`` for
    # one-task-per-prompt (strongest checkpoints, tightest context, but
    # input tokens grow ~quadratically with task count); any positive
    # int = up to N tasks per prompt.
    "plan_batch_size": None,
    "execute_batch_size": None,
}

_VALID_EXECUTORS = {"cursor", "codex", "claude", "opencode"}
_VALID_PHASES = {"review", "plan", "execute"}


def _parse_config_yaml(text: str) -> Dict[str, object]:
    """Parse the autobuilder ``config.yaml`` format.

    Supports scalars (``key: value``), string lists (indented ``- item``),
    simple dicts (indented ``key: value``), and lists-of-dicts
    (``- key: val`` with indented continuation keys).
    """
    result: Dict[str, object] = {}
    lines = text.splitlines()
    i = 0

    while i < len(lines):
        line = lines[i]
        stripped = line.strip()

        if not stripped or stripped.startswith("#"):
            i += 1
            continue

        if line[0].isspace():
            i += 1
            continue

        m = re.match(r"^([A-Za-z0-9_\-]+)\s*:\s*(.*?)\s*$", stripped)
        if not m:
            i += 1
            continue

        key = m.group(1).replace("-", "_")
        inline_value = m.group(2)
        i += 1

        if inline_value:
            result[key] = inline_value
            continue

        content_lines: List[str] = []
        while i < len(lines):
            bl = lines[i]
            bs = bl.strip()
            if not bs or bs.startswith("#"):
                i += 1
                continue
            if not bl[0].isspace():
                break
            content_lines.append(bl)
            i += 1

        if not content_lines:
            continue

        first = content_lines[0].strip()
        if first.startswith("- "):
            items: List[object] = []
            cur_dict: Optional[Dict[str, str]] = None
            for cl in content_lines:
                cs = cl.strip()
                if cs.startswith("- "):
                    if cur_dict is not None:
                        items.append(cur_dict)
                        cur_dict = None
                    item_text = cs[2:].strip()
                    dm = re.match(
                        r"^([A-Za-z0-9_\-]+)\s*:\s+(.+?)\s*$", item_text,
                    )
                    if dm:
                        cur_dict = {
                            dm.group(1).replace("-", "_"):
                                dm.group(2).strip("\"'"),
                        }
                    else:
                        items.append(item_text)
                elif cur_dict is not None:
                    dm = re.match(
                        r"^([A-Za-z0-9_\-]+)\s*:\s+(.*?)\s*$", cs,
                    )
                    if dm:
                        cur_dict[dm.group(1).replace("-", "_")] = (
                            dm.group(2).strip("\"'")
                        )
            if cur_dict is not None:
                items.append(cur_dict)
            result[key] = items
        else:
            d: Dict[str, str] = {}
            for cl in content_lines:
                cs = cl.strip()
                dm = re.match(r"^([A-Za-z0-9_\-]+)\s*:\s*(.*?)\s*$", cs)
                if dm:
                    d[dm.group(1)] = dm.group(2).strip("\"'")
            result[key] = d

    return result


def load_project_config(project_dir: Path) -> Dict[str, object]:
    """Load per-project config from ``.autobuilder/config.yaml``.

    Returns a dict with defaults for any missing keys.  The config file
    is optional — when absent, all defaults apply.
    """
    config: Dict[str, object] = dict(_PROJECT_CONFIG_DEFAULTS)
    config_path = project_dir / ".autobuilder" / "config.yaml"
    if not config_path.exists():
        return config

    try:
        text = config_path.read_text(encoding="utf-8")
    except Exception:
        return config

    raw = _parse_config_yaml(text)

    if "no_push" in raw:
        config["no_push"] = parse_bool(raw["no_push"])
    if "early_complete" in raw:
        config["early_complete"] = parse_bool(raw["early_complete"])
    if "early_complete_idle_threshold" in raw:
        try:
            config["early_complete_idle_threshold"] = max(
                1, int(raw["early_complete_idle_threshold"]),
            )
        except (ValueError, TypeError):
            pass
    if "review_polish_weight" in raw:
        try:
            config["review_polish_weight"] = float(raw["review_polish_weight"])
        except (ValueError, TypeError):
            pass
    for int_key in ("review_max_tasks_polish", "review_max_tasks_feature"):
        if int_key in raw:
            try:
                config[int_key] = int(raw[int_key])
            except (ValueError, TypeError):
                pass

    for batch_key in ("plan_batch_size", "execute_batch_size"):
        if batch_key in raw:
            raw_val = raw[batch_key]
            if (
                raw_val is None
                or (isinstance(raw_val, str)
                    and raw_val.strip().lower() in ("null", "none", "~", ""))
            ):
                config[batch_key] = None
            else:
                try:
                    n = int(raw_val)
                    config[batch_key] = n if n >= 1 else None
                except (ValueError, TypeError):
                    pass

    if "review_strategies" in raw and isinstance(raw["review_strategies"], list):
        config["review_strategies"] = [
            s for s in raw["review_strategies"] if isinstance(s, str)
        ]

    if "strategy_max_tasks" in raw and isinstance(raw["strategy_max_tasks"], dict):
        converted: Dict[str, int] = {}
        for k, v in raw["strategy_max_tasks"].items():
            try:
                converted[k] = int(v)
            except (ValueError, TypeError):
                pass
        config["strategy_max_tasks"] = converted

    if "phase_executors" in raw and isinstance(raw["phase_executors"], list):
        pe: Dict[str, Dict[str, str]] = {}
        for item in raw["phase_executors"]:
            if not isinstance(item, dict):
                continue
            phase = str(item.get("phase", "")).strip().lower()
            if phase not in _VALID_PHASES:
                continue
            entry: Dict[str, str] = {}
            # Preserve raw values verbatim (lower-cased for executor) so
            # validate_executor_config can report bad values clearly.
            # Empty strings are still skipped — they carry no info.
            if "executor" in item:
                ex = str(item["executor"]).strip().lower()
                if ex:
                    entry["executor"] = ex
            if "model" in item:
                md = str(item["model"]).strip()
                if md:
                    entry["model"] = md
            if entry:
                pe[phase] = entry
        config["phase_executors"] = pe

    if "custom_review_strategies" in raw and isinstance(
        raw["custom_review_strategies"], list,
    ):
        valid: List[Dict[str, object]] = []
        for item in raw["custom_review_strategies"]:
            if isinstance(item, dict) and "name" in item and "focus" in item:
                entry: Dict[str, object] = {
                    "name": str(item["name"]),
                    "focus": str(item["focus"]),
                }
                if "max_tasks" in item:
                    try:
                        entry["max_tasks"] = int(item["max_tasks"])
                    except (ValueError, TypeError):
                        pass
                valid.append(entry)
        config["custom_review_strategies"] = valid

    return config


def ensure_project_config(project_dir: Path) -> Path:
    """Create ``.autobuilder/config.yaml`` with defaults if absent.

    Returns the path to the config file (existing or newly created).
    """
    config_path = project_dir / ".autobuilder" / "config.yaml"
    if config_path.exists():
        return config_path

    strategy_lines = "\n".join(f"  - {s['name']}" for s in REVIEW_STRATEGIES)
    content = (
        "# Autobuilder Project Configuration\n"
        "# ===================================\n"
        "# All values below are defaults. Modify as needed.\n"
        "# Changes take effect on the next iteration (no restart required).\n"
        "\n"
        "# Git push behavior (true = commits stay local)\n"
        "no_push: false\n"
        "\n"
        "# Early-complete mode (default: false).\n"
        "# When true, the loop converges instead of running forever:\n"
        "#   - reviews focus ONLY on functionality + critical UX blockers\n"
        "#     (no visual polish, performance tweaks, or new features)\n"
        "#   - review mode is forced to polish (no feature suggestions)\n"
        "#   - after `early_complete_idle_threshold` consecutive idle\n"
        "#     iterations, the daemon writes .autobuilder/COMPLETE.md\n"
        "#     and exits cleanly.\n"
        "early_complete: false\n"
        "early_complete_idle_threshold: 1\n"
        "\n"
        "# Review mode weight: probability of 'polish' vs 'feature' mode\n"
        "# polish = find bugs/gaps, feature = propose new capabilities\n"
        "# Range: 0.0 (always feature) to 1.0 (always polish)\n"
        "review_polish_weight: 0.9\n"
        "\n"
        "# Maximum tasks created per review iteration by mode\n"
        "review_max_tasks_polish: 5\n"
        "review_max_tasks_feature: 3\n"
        "\n"
        "# Built-in review strategies (comment out lines to disable)\n"
        "review_strategies:\n"
        f"{strategy_lines}\n"
        "\n"
        "# Per-strategy task limit overrides (uncomment to customize)\n"
        "# Overrides the mode-level max when the named strategy is active.\n"
        "# strategy_max_tasks:\n"
        "#   deep-dive: 2\n"
        "\n"
        "# Custom review strategies (uncomment and modify to add your own)\n"
        "# Each needs: name, focus. Optional: max_tasks.\n"
        "# custom_review_strategies:\n"
        "#   - name: security-audit\n"
        '#     focus: "Focus on SECURITY: XSS, CSRF, injection, auth bypass."\n'
        "#   - name: api-contract\n"
        '#     focus: "Verify every API endpoint matches its TypeScript interface."\n'
        "#     max_tasks: 3\n"
        "\n"
        "# Per-task fanout (default: null = all pending tasks in one prompt).\n"
        "# Under per-token billing this is the cheapest mode because the\n"
        "# iteration header (project info + reference material) is replayed\n"
        "# via chat-history on every chained call — sending it once per\n"
        "# phase keeps that replay short.\n"
        "# Set to `1` for one-task-per-prompt (strongest checkpoints, tightest\n"
        "# context window per call) — recommended for long unattended runs\n"
        "# where mid-batch crashes are catastrophic, but expect input-token\n"
        "# costs to grow ~quadratically with the number of tasks.\n"
        "# Any positive int = up to N tasks bundled per call.\n"
        "plan_batch_size: null\n"
        "execute_batch_size: null\n"
        "\n"
        "# Per-phase executor/model overrides (optional).\n"
        "# Phases not listed inherit the daemon's --executor / --model.\n"
        "# Valid executors: cursor, codex, claude, opencode.\n"
        "# When the executor differs between consecutive phases the chat\n"
        "# session cannot be reused, so the new executor receives a fresh\n"
        "# session with the full iteration header re-sent.\n"
        "# phase_executors:\n"
        "#   - phase: review\n"
        "#     executor: claude\n"
        "#     model: sonnet\n"
        "#   - phase: plan\n"
        "#     executor: cursor\n"
        "#     model: claude-opus-4-7-thinking-high\n"
        "#   - phase: execute\n"
        "#     executor: cursor\n"
        "#     model: claude-opus-4-7-thinking-high\n"
    )

    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(content, encoding="utf-8")
    return config_path


class ExecutorConfigError(ValueError):
    """Raised when the executor / model configuration is invalid.

    Carries every detected error in a single message so the user sees
    them all at once instead of fixing them one round-trip at a time.
    """


# Maps each valid executor to (env-var override, default binary on $PATH).
# Mirrors the constants in ``common.py`` — keep these in sync.
_EXECUTOR_BIN: Dict[str, Tuple[str, str]] = {
    "cursor": ("CURSOR_CMD", "cursor"),
    "codex": ("CODEX_CMD", "codex"),
    "claude": ("CLAUDE_CMD", "claude"),
    "opencode": ("OPENCODE_CMD", "opencode"),
}

_EXECUTOR_INSTALL_HINT: Dict[str, str] = {
    "cursor": "https://cursor.com/cli",
    "codex": "https://github.com/openai/codex",
    "claude": "https://docs.claude.com/en/docs/claude-code",
    "opencode": "https://opencode.ai/",
}

_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[\d;]*[A-Za-z]")

# Cache of (executor → model list) results, keyed by binary name so a
# CURSOR_CMD override doesn't poison the cache. Populated lazily by
# query_available_models().
_MODELS_CACHE: Dict[str, Optional[List[str]]] = {}


def _query_cursor_models(bin_name: str) -> Optional[List[str]]:
    """Run ``<bin_name> agent --list-models`` and parse the model IDs.

    Returns ``None`` if the CLI couldn't be invoked, returned non-zero,
    or produced nothing parseable. Strips ANSI escape codes and progress
    lines (``Loading models…``, ``Available models``, blanks). Each
    model line is ``<id> - <description>`` with optional trailing
    ``(default)`` / ``(current)`` markers we ignore.
    """
    try:
        result = subprocess.run(
            [bin_name, "agent", "--list-models"],
            capture_output=True, text=True, timeout=20,
            stdin=subprocess.DEVNULL,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError,
            PermissionError, OSError):
        return None
    if result.returncode != 0:
        return None

    models: List[str] = []
    for raw in result.stdout.splitlines():
        line = _ANSI_ESCAPE_RE.sub("", raw).strip()
        if not line:
            continue
        low = line.lower()
        if low.startswith(("loading", "available", "no models", "error")):
            continue
        if " - " not in line:
            continue
        model_id = line.split(" - ", 1)[0].strip()
        if model_id:
            models.append(model_id)
    return models if models else None


def _query_opencode_models(bin_name: str) -> Optional[List[str]]:
    """Run ``<bin_name> models`` and parse the ``provider/model`` IDs.

    OpenCode prints one model per line in ``provider/model`` form
    (e.g. ``anthropic/claude-sonnet-4-5-20250929``). Returns ``None``
    on any error so the caller falls back to "trust the executor".
    """
    try:
        result = subprocess.run(
            [bin_name, "models"],
            capture_output=True, text=True, timeout=20,
            stdin=subprocess.DEVNULL,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError,
            PermissionError, OSError):
        return None
    if result.returncode != 0:
        return None

    models: List[str] = []
    for raw in result.stdout.splitlines():
        line = _ANSI_ESCAPE_RE.sub("", raw).strip()
        if not line:
            continue
        # Skip headers / status lines. The "provider/model" identifier
        # always contains exactly one slash and no whitespace.
        if " " in line or "\t" in line:
            continue
        if "/" not in line:
            continue
        models.append(line)
    return models if models else None


def query_available_models(executor: str) -> Optional[List[str]]:
    """Return the list of available models for *executor*, or ``None``.

    ``None`` means the executor's CLI doesn't expose a model-listing
    command (``claude``, ``codex``) or the call failed — the caller
    should skip strict model validation and rely on the executor itself
    to reject bad models at first invocation.

    Currently supports live model queries for ``cursor`` (via
    ``cursor agent --list-models``) and ``opencode`` (via
    ``opencode models``). Other executors return ``None``.

    Results are memoised per-binary so repeated validation calls don't
    re-shell. Set ``$AUTOBUILDER_SKIP_MODEL_QUERY=1`` to short-circuit
    (useful in tests / offline runs).
    """
    if os.environ.get("AUTOBUILDER_SKIP_MODEL_QUERY"):
        return None
    if executor not in ("cursor", "opencode"):
        return None  # claude + codex have no list command

    env_var, default_bin = _EXECUTOR_BIN[executor]
    bin_name = os.environ.get(env_var, default_bin)
    cache_key = f"{executor}:{bin_name}"
    if cache_key in _MODELS_CACHE:
        return _MODELS_CACHE[cache_key]

    if executor == "cursor":
        models = _query_cursor_models(bin_name)
    else:  # opencode
        models = _query_opencode_models(bin_name)
    _MODELS_CACHE[cache_key] = models
    return models


def _suggest_near_matches(model: str, candidates: List[str], k: int = 5) -> List[str]:
    """Return up to *k* candidate models that look most similar to *model*."""
    needle = model.lower()
    # Score: substring match wins; otherwise difflib ratio.
    import difflib
    contains = [c for c in candidates if needle in c.lower() or c.lower() in needle]
    if contains:
        return contains[:k]
    return difflib.get_close_matches(model, candidates, n=k, cutoff=0.4)


def _validate_one_executor(
    executor: object,
    model: object,
    *,
    where: str,
) -> List[str]:
    """Return a list of error strings for a single (executor, model) pair.

    Empty list means the configuration is valid for that pair.
    """
    errors: List[str] = []

    if not isinstance(executor, str) or not executor.strip():
        errors.append(
            f"{where}: executor must be a non-empty string "
            f"(got {executor!r})."
        )
        return errors

    ex = executor.strip().lower()
    if ex not in _VALID_EXECUTORS:
        errors.append(
            f"{where}: unknown executor {executor!r}. "
            f"Valid executors: {sorted(_VALID_EXECUTORS)}."
        )
        return errors

    env_var, default_bin = _EXECUTOR_BIN[ex]
    bin_name = os.environ.get(env_var, default_bin)
    if shutil.which(bin_name) is None:
        hint = _EXECUTOR_INSTALL_HINT.get(ex, "")
        suffix = f"  Install: {hint}" if hint else ""
        errors.append(
            f"{where}: executor {ex!r} requires {bin_name!r} on $PATH "
            f"(or set ${env_var} to its absolute path).{suffix}"
        )
        # No point asking the missing binary for its model list.
        return errors

    if model is None:
        # Inherits the daemon default; nothing to check here.
        return errors

    if not isinstance(model, str):
        errors.append(
            f"{where}: model must be a string "
            f"(got {type(model).__name__})."
        )
        return errors
    if not model.strip():
        errors.append(f"{where}: model must not be empty / whitespace.")
        return errors

    available = query_available_models(ex)
    if available is None:
        # Either the executor has no list command (claude, codex) or the
        # query failed. Honest behaviour: don't second-guess the model
        # name — the executor will reject it at first invocation.
        return errors

    if model not in available:
        suggestions = _suggest_near_matches(model, available)
        hint = ""
        if suggestions:
            hint = f"\n    Did you mean: {', '.join(suggestions)}?"
        sample = ", ".join(available[:6])
        # The "see the full list" hint uses each executor's actual
        # model-listing subcommand.
        list_cmd = (
            f"{bin_name} agent --list-models" if ex == "cursor"
            else f"{bin_name} models"
        )
        more = (
            f" (… {len(available) - 6} more, run "
            f"`{list_cmd}` for the full list)"
            if len(available) > 6 else ""
        )
        errors.append(
            f"{where}: model {model!r} is not available for "
            f"{ex} on this account.{hint}\n"
            f"    Known models: {sample}{more}"
        )

    return errors


def validate_executor_config(
    default_executor: str,
    default_model: Optional[str],
    project_config: Dict[str, object],
) -> None:
    """Validate the daemon defaults plus every per-phase override.

    Raises :class:`ExecutorConfigError` if anything is invalid. Errors
    are collected and reported together so the user can fix them in one
    pass.

    For each ``(executor, model)`` pair we check:

    1. Executor name is one of ``cursor`` / ``codex`` / ``claude`` /
       ``opencode``.
    2. The executor's CLI binary is on ``$PATH`` (or ``$<EXECUTOR>_CMD``
       points at an absolute path that exists).
    3. Model, when specified, is queried from the executor's CLI:

       - ``cursor`` runs ``cursor agent --list-models`` and the model
         must appear in the parsed list. Near-match suggestions are
         emitted on miss.
       - ``opencode`` runs ``opencode models`` and the model must
         appear in ``provider/model`` form. Near-match suggestions are
         emitted on miss.
       - ``claude`` and ``codex`` don't expose a model-list command,
         so we don't second-guess the value — the executor itself will
         reject a bad model at first invocation.

    Per-phase overrides that omit ``executor`` or ``model`` inherit the
    daemon defaults — those resolved values are validated, not the raw
    override entries, so a bad daemon default shows up only once.
    """
    errors: List[str] = []
    errors.extend(_validate_one_executor(
        default_executor, default_model,
        where="daemon defaults (--executor / --model)",
    ))

    overrides = project_config.get("phase_executors", {})
    if isinstance(overrides, dict):
        for phase in sorted(overrides.keys()):
            entry = overrides[phase]
            if not isinstance(entry, dict):
                continue
            ex = entry.get("executor", default_executor)
            md = entry.get("model", default_model)
            errors.extend(_validate_one_executor(
                ex, md, where=f"phase_executors[{phase}]",
            ))

    if errors:
        message = (
            "Invalid executor/model configuration "
            f"({len(errors)} error{'s' if len(errors) != 1 else ''}):\n"
            + "\n".join(f"  - {e}" for e in errors)
            + "\n\nFix .autobuilder/config.yaml or your --executor / "
            "--model arguments, then re-launch."
        )
        raise ExecutorConfigError(message)


def resolve_phase_executor(
    phase: str,
    project_config: Dict[str, object],
    default_executor: str,
    default_model: Optional[str],
) -> Tuple[str, Optional[str]]:
    """Return ``(executor, model)`` for the given phase.

    Per-phase overrides come from ``phase_executors`` in
    ``.autobuilder/config.yaml``. Phases not listed inherit the daemon's
    CLI ``--executor`` / ``--model`` defaults. An override that supplies
    only one of ``executor``/``model`` falls back to the default for the
    other.
    """
    overrides = project_config.get("phase_executors", {})
    if not isinstance(overrides, dict):
        return default_executor, default_model
    entry = overrides.get(phase)
    if not isinstance(entry, dict):
        return default_executor, default_model
    executor = entry.get("executor", default_executor)
    model = entry.get("model", default_model)
    return str(executor), (model if model else default_model)


def resolve_review_strategies(
    project_config: Dict[str, object],
) -> List[Dict[str, object]]:
    """Build the effective strategy list from project config.

    Filters built-in strategies to those enabled in config, then
    merges in any custom strategies.  Returns all built-ins when
    no config restriction is set.
    """
    enabled_names = project_config.get("review_strategies")
    if enabled_names is not None and isinstance(enabled_names, list):
        name_set = set(enabled_names)
        strategies: List[Dict[str, object]] = [
            dict(s) for s in REVIEW_STRATEGIES if s["name"] in name_set
        ]
    else:
        strategies = [dict(s) for s in REVIEW_STRATEGIES]

    custom = project_config.get("custom_review_strategies", [])
    if isinstance(custom, list):
        for cs in custom:
            if isinstance(cs, dict) and "name" in cs and "focus" in cs:
                strategies.append(dict(cs))

    return strategies if strategies else [dict(s) for s in REVIEW_STRATEGIES]


# ---------------------------------------------------------------------------
# File readers
# ---------------------------------------------------------------------------


def read_file_text(path: Path) -> str:
    """Read a file as stripped UTF-8 text; return ``""`` on any error."""
    if not path.exists():
        return ""
    try:
        return path.read_text(encoding="utf-8").strip()
    except Exception:
        return ""


def consume_backlog(backlog_path: Path) -> str:
    """Atomically consume a backlog file (rename → read → delete)."""
    if not backlog_path.exists():
        return ""
    processing = backlog_path.with_name("backlog.processing.md")
    try:
        backlog_path.rename(processing)
    except OSError:
        return ""
    try:
        text = processing.read_text(encoding="utf-8").strip()
    except Exception:
        text = ""
    try:
        processing.unlink()
    except OSError:
        pass
    return text


def get_project_description(project_dir: Path) -> str:
    """Extract the first sentence from the ``## Vision`` section of scope.md."""
    scope_file = project_dir / ".autobuilder" / "scope.md"
    try:
        text = scope_file.read_text(encoding="utf-8")
    except Exception:
        return ""
    in_vision = False
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.lower().startswith("## vision"):
            in_vision = True
            continue
        if in_vision:
            if stripped.startswith("#"):
                break
            if stripped:
                sentence = stripped.split(". ")[0].rstrip(".")
                return sentence
    return ""


def parse_env_var_names(env_path: Path) -> List[str]:
    """Return a list of ``KEY (set in .env)`` strings from an env file."""
    if not env_path.exists():
        return []
    result: List[str] = []
    for line in env_path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" in line:
            key = line.split("=", 1)[0].strip()
            result.append(f"{key} (set in .env)")
    return result


# ---------------------------------------------------------------------------
# Skills sync
# ---------------------------------------------------------------------------


def sync_skills_to_project(project_dir: Path) -> bool:
    """Symlink ``skills/`` into the project's ``.cursor/skills/autobuilder``."""
    dst = project_dir / SKILLS_TARGET
    src = BUILDER_SKILLS_DIR.resolve()

    if not src.is_dir():
        return False

    dst.parent.mkdir(parents=True, exist_ok=True)

    if dst.is_symlink():
        if dst.resolve() == src:
            return True
        dst.unlink()
    elif dst.exists():
        shutil.rmtree(str(dst))

    dst.symlink_to(src)

    legacy = project_dir / SKILLS_TARGET_LEGACY
    if legacy.exists() and not legacy.is_symlink():
        shutil.rmtree(str(legacy))

    return True


def setup_project_agents_md(project_dir: Path) -> bool:
    """Symlink ``AGENTS.md`` from autobuilder into the project root."""
    src = (AUTOBUILDER_DIR / "AGENTS.md").resolve()
    if not src.exists():
        return False
    dst = project_dir / "AGENTS.md"
    if dst.is_symlink():
        if dst.resolve() == src:
            return True
        dst.unlink()
    elif dst.exists():
        dst.unlink()
    dst.symlink_to(src)
    return True


def ensure_gitignore_entries(project_dir: Path, entries: List[str]) -> None:
    """Append missing entries to the project's ``.gitignore``."""
    gitignore = project_dir / ".gitignore"
    existing = set()
    if gitignore.exists():
        existing = set(gitignore.read_text(encoding="utf-8").splitlines())
    missing = [e for e in entries if e not in existing]
    if missing:
        with gitignore.open("a", encoding="utf-8") as fh:
            if existing and not gitignore.read_text(encoding="utf-8").endswith("\n"):
                fh.write("\n")
            for entry in missing:
                fh.write(entry + "\n")


def _untrack_ignored_files(project_dir: Path, entries: List[str]) -> None:
    """Run ``git rm --cached`` for any gitignored paths still in the index.

    Handles literal paths and ``**/dirname/`` directory globs (resolves all
    matching directories on disk).  Skips pure wildcard entries like ``*.log``
    since git handles those via .gitignore at commit time.
    """
    git_dir = project_dir / ".git"
    if not git_dir.is_dir():
        return

    paths_to_untrack: List[str] = []
    for entry in entries:
        stripped = entry.rstrip("/")
        if stripped.startswith("**/"):
            dirname = stripped[3:]
            for match in project_dir.rglob(dirname):
                if match.is_dir():
                    rel = str(match.relative_to(project_dir))
                    paths_to_untrack.append(rel)
        elif "*" in stripped:
            continue
        else:
            paths_to_untrack.append(stripped)

    for path in paths_to_untrack:
        try:
            subprocess.run(
                ["git", "rm", "-r", "--cached", "--quiet", "--ignore-unmatch", path],
                cwd=str(project_dir),
                capture_output=True, timeout=10,
            )
        except Exception:
            pass


def cleanup_stale_mcp(project_dir: Path) -> None:
    """Remove leftover ``.cursor/mcp.json`` from prior runs."""
    mcp_path = project_dir / ".cursor" / "mcp.json"
    if mcp_path.exists():
        try:
            mcp_path.unlink()
        except Exception:
            pass


# ---------------------------------------------------------------------------
# Autobuilder .env — per-project credentials and config
# ---------------------------------------------------------------------------


def load_autobuilder_env(project_dir: Path) -> Dict[str, str]:
    """Load key=value pairs from ``.autobuilder/.env``.

    Returns a dict of all non-comment, non-empty lines parsed as KEY=VALUE.
    Values may be optionally quoted (single or double quotes are stripped).
    """
    env_path = project_dir / ".autobuilder" / ".env"
    if not env_path.exists():
        return {}
    result: Dict[str, str] = {}
    try:
        for line in env_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if "=" not in line:
                continue
            key, _, val = line.partition("=")
            key = key.strip()
            val = val.strip()
            if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
                val = val[1:-1]
            result[key] = val
    except Exception:
        pass
    return result


def load_test_credentials(project_dir: Path) -> Optional[Tuple[str, str]]:
    """Extract test credentials from ``.autobuilder/.env``.

    Looks for ``TEST_USERNAME`` and ``TEST_PASSWORD`` keys.
    Returns ``(username, password)`` or ``None`` if not found.
    """
    env = load_autobuilder_env(project_dir)
    username = env.get("TEST_USERNAME") or env.get("TEST_USER")
    password = env.get("TEST_PASSWORD") or env.get("TEST_PASS")
    if username and password:
        return (username, password)
    return None
