# nova_cli/local/healer/runner.py

import os
import subprocess
from typing import Dict, List, Optional
import py_compile
import tempfile


from nova_cli.nova_core.ai.api_client import BridgeyeAPIClient
from nova_cli.local.file_manager.commands import handle_ai_commands
from nova_cli.local.ui import ui
import core.prompts as prompts


def _syntax_check(path: str) -> tuple[bool, str]:
    try:
        py_compile.compile(path, doraise=True)
        return True, ""
    except Exception as e:
        return False, str(e)


def _read_text(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def _write_text(path: str, content: str) -> None:
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)


def _strip_utf8_bom(path: str) -> bool:
    try:
        with open(path, "rb") as f:
            raw = f.read()
        if raw.startswith(b"\xef\xbb\xbf"):
            with open(path, "wb") as f:
                f.write(raw[3:])
            return True
    except Exception:
        return False
    return False


def _ensure_target_file_in_context(command_args: List[str], cwd: str, ctx: Dict[str, str]) -> None:
    """
    Ensure the executed target (.py) file is present in the context payload,
    using BOTH rel path and basename keys for better matching server-side.
    """
    if not command_args:
        return

    target = command_args[-1]
    if not isinstance(target, str) or not target.lower().endswith(".py"):
        return

    abs_path = target
    if not os.path.isabs(abs_path):
        abs_path = os.path.abspath(os.path.join(cwd, target))

    if not os.path.exists(abs_path):
        return

    # Read as text; if file begins with a BOM, Python may include \ufeff in the string.
    try:
        with open(abs_path, "r", encoding="utf-8") as f:
            content = f.read()
    except Exception:
        return

    # Strip BOM char from the in-memory context too (important for SEARCH matching)
    if content.startswith("\ufeff"):
        content = content.lstrip("\ufeff")

    try:
        rel = os.path.relpath(abs_path, cwd).replace("\\", "/")
    except Exception:
        rel = abs_path.replace("\\", "/")

    ctx[rel] = content
    ctx[os.path.basename(abs_path)] = content


def _run_once(command_args: List[str], cwd: str) -> tuple[int, str, str]:
    p = subprocess.run(
        command_args,
        cwd=cwd,
        capture_output=True,
        text=True,
        shell=False,
    )
    return p.returncode, p.stdout or "", p.stderr or ""


def run_with_healing(
    *,
    command_args: List[str],
    cwd: str,
    model: str,
    provider: str,
    context: Optional[Dict[str, str]] = None,
    extra_context: Optional[Dict[str, str]] = None,
    repo_map: Optional[str] = None,
    max_attempts: int = 3,
) -> str:
    """
    Runs the command locally.
    On failure: calls NOVA_API /healer/run to get a patch block ([EDIT]/[SHELL]).
    Applies the patch locally via handle_ai_commands(), then retries.
    Returns "SUCCESS_SIGNAL" on success.
    Raises on final failure.
    """

    merged_context: Dict[str, str] = {}
    if context:
        merged_context.update(context)
    if extra_context:
        merged_context.update(extra_context)

    if repo_map is None:
        try:
            repo_map = prompts.get_repo_map_cached(cwd)
        except Exception:
            repo_map = None

    api = BridgeyeAPIClient()
    last_err = ""

    # Best-effort: infer python target file path
    target_abs: Optional[str] = None
    if command_args and isinstance(command_args[-1], str) and command_args[-1].lower().endswith(".py"):
        t = command_args[-1]
        target_abs = t if os.path.isabs(t) else os.path.abspath(os.path.join(cwd, t))
        target_abs = target_abs.replace("\\", "/")

    # Bounded history for prompt stability
    healing_history: List[str] = []
    MAX_HISTORY_ITEMS = 6
    MAX_HISTORY_CHARS = 2000

    def _push_history(item: str) -> None:
        if not item:
            return
        healing_history.append(item[:MAX_HISTORY_CHARS])
        if len(healing_history) > MAX_HISTORY_ITEMS:
            del healing_history[:-MAX_HISTORY_ITEMS]

    def _norm_path(p: str) -> str:
        return (p or "").replace("\\", "/")

    def _resolve_modified_paths(modified: List[str]) -> List[str]:
        """
        Convert returned modified file identifiers into real on-disk paths when possible.
        Returns absolute, normalized paths for files that exist.
        """
        out: List[str] = []
        for f in modified or []:
            if not f or f == "SYSTEM_ENVIRONMENT" or not isinstance(f, str):
                continue

            f_norm = _norm_path(f)

            candidates = []
            if os.path.isabs(f_norm):
                candidates.append(f_norm)
            else:
                candidates.append(_norm_path(os.path.abspath(os.path.join(cwd, f_norm))))

            # Also try basename in cwd if AI returned weird relative fragments
            candidates.append(_norm_path(os.path.abspath(os.path.join(cwd, os.path.basename(f_norm)))))

            found = None
            for c in candidates:
                if os.path.exists(c) and os.path.isfile(c):
                    found = c
                    break

            if found and found not in out:
                out.append(found)

        return out

    def _snapshot_files(paths: List[str]) -> Dict[str, str]:
        snap: Dict[str, str] = {}
        for p in paths:
            try:
                snap[p] = _read_text(p)
            except Exception:
                pass
        return snap

    def _revert_files(snapshot: Dict[str, str]) -> None:
        for p, content in snapshot.items():
            try:
                _write_text(p, content)
            except Exception:
                pass

    for attempt in range(1, max_attempts + 1):
        ui.print(f"[dim]>> Run attempt {attempt}/{max_attempts}: {' '.join(command_args)}[/dim]")

        exit_code, stdout, stderr = _run_once(command_args, cwd)
        last_err = stderr or stdout or f"exit_code={exit_code}"

        if exit_code == 0:
            if stdout.strip():
                ui.console.print(stdout, markup=False)
            return "SUCCESS_SIGNAL"

        err_text = (stderr or "") + "\n" + (stdout or "")

        # Local hotfix: strip UTF-8 BOM bytes that can break Python
        if (
            ("U+FEFF" in err_text or "invalid non-printable character" in err_text)
            and target_abs
            and os.path.exists(target_abs)
        ):
            if _strip_utf8_bom(target_abs):
                ui.print("[yellow]>> Detected UTF-8 BOM (U+FEFF). Removed BOM and retrying...[/yellow]")
                prompts.clear_file_tree_cache()
                _ensure_target_file_in_context(command_args, cwd, merged_context)
                continue

        # Always refresh target file into context before API call
        _ensure_target_file_in_context(command_args, cwd, merged_context)

        clean_stdout = (stdout or "").replace("\r\n", "\n").replace("\r", "\n")
        clean_stderr = (stderr or "").replace("\r\n", "\n").replace("\r", "\n")

        patch_block = api.run_with_healing(
            command=command_args,
            cwd=cwd,
            model=model,
            provider=provider,
            exit_code=exit_code,
            stdout=clean_stdout,
            stderr=clean_stderr,
            context=merged_context,
            repo_map=repo_map,
            healing_history=healing_history,
        )

        if not patch_block or not patch_block.strip():
            raise RuntimeError(f"Healer returned empty patch. Last error:\n{last_err}")

        ui.print("[cyan]>> Healer suggested patch. Applying...[/cyan]")

        # Apply patch
        modified = handle_ai_commands(patch_block, cwd=cwd)

        # Record what we tried (ONCE)
        _push_history(patch_block)

        # If nothing applied, add a corrective hint and retry
        if not modified:
            _push_history(
                "EDIT_NOT_APPLIED: CLI could not apply patch. "
                "CAUSE: SEARCH not found or invalid SEARCH/REPLACE structure. "
                "NEXT: Copy SEARCH lines verbatim from FILE_CONTEXT, include exact indentation."
            )
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # Resolve real modified file paths and snapshot for possible revert
        modified_paths = _resolve_modified_paths(modified)
        snapshot = _snapshot_files(modified_paths)

        # Syntax check: if any modified .py file fails compile, revert and retry
        syntax_failed = False
        syntax_err = ""
        for p in modified_paths:
            if p.lower().endswith(".py"):
                ok, err = _syntax_check(p)
                if not ok:
                    syntax_failed = True
                    syntax_err = f"{os.path.basename(p)}: {err}"
                    break

        if syntax_failed:
            ui.print("[red]>> Edit Rejected: Resulting code has Syntax Error.[/red]")
            ui.print(f"[red]>> {syntax_err}[/red]")
            _revert_files(snapshot)
            _push_history(f"SYNTAX_ERROR_AFTER_PATCH: {syntax_err}")
            _ensure_target_file_in_context(command_args, cwd, merged_context)
            continue

        # After file ops, clear repo-map cache + refresh context for modified files
        prompts.clear_file_tree_cache()

        for p in modified_paths:
            try:
                with open(p, "r", encoding="utf-8") as fp:
                    content = fp.read()
                if content.startswith("\ufeff"):
                    content = content.lstrip("\ufeff")

                merged_context[os.path.basename(p)] = content
                merged_context[_norm_path(p)] = content
                try:
                    rel = os.path.relpath(p, cwd).replace("\\", "/")
                    merged_context[rel] = content
                except Exception:
                    pass
            except Exception:
                pass

        # Always refresh target file too (even if healer edited other files)
        _ensure_target_file_in_context(command_args, cwd, merged_context)

    raise RuntimeError(f"Command failed after {max_attempts} attempts.\nLast error:\n{last_err}")

