# tools/web_act.py — Webwright-style interactive web automation via Playwright scripts.
from __future__ import annotations

import json
import os
import re
import shutil
import time
import uuid
from datetime import datetime, timezone
from typing import Any

from langchain_core.tools import tool

from tools._logging import (
    _debug_log,
    _emit_input_status,
    should_stop_tool,
)
from tools.job_stream_schema import (
    close_job_stream,
    make_capabilities,
    open_job_stream,
    update_job_stream,
)
from tools.live_view_visuals import emit_live_view_preview, image_to_preview_base64
from tools.plugin_stream_controls import get_action_count
from tools.shell import _safe_subprocess_env
from utils.path_safety import safe_resolve_path

_SCOPE_SAFE_RE = re.compile(r"[^a-zA-Z0-9._-]+")
_LOCK_STALE_FACTOR = 2.0
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_WEB_ACT_PLUGIN_ID = "web_act"
_WEB_ACT_PLUGIN_NAME = "Web Script"


def _current_thread_id() -> str:
    try:
        from persistence import get_current_agent_thread_id
        return (get_current_agent_thread_id() or "default").strip() or "default"
    except Exception:
        return "default"


def sanitize_scope(raw: str) -> str:
    s = (raw or "").strip()
    if not s:
        return "default"
    s = _SCOPE_SAFE_RE.sub("_", s)
    return s[:64] or "default"


def _configured_workspace_dir() -> str:
    try:
        from core.config import get_workspace_path
        return os.path.abspath(os.path.expanduser(get_workspace_path()))
    except Exception:
        return os.path.abspath(os.path.expanduser("~/.mugi/workspace"))


def web_act_enabled() -> bool:
    try:
        from core.config import get_web_act_config
        if not get_web_act_config().get("enabled", True):
            return False
    except Exception:
        pass
    try:
        from utils.app_prefs_store import load_app_prefs_cached
        return bool(load_app_prefs_cached().get("allow_web_act_scripts", False))
    except Exception:
        return False


def _gate_message() -> str | None:
    if not web_act_enabled():
        return (
            "Web automation scripts are disabled. Enable “Allow web automation scripts” in "
            "Settings → Advanced (requires Playwright installed)."
        )
    return None


def _current_scope_key() -> str:
    try:
        from utils.kanban_dispatcher import current_claim_token

        claim = current_claim_token()
        if claim:
            return f"kanban_{claim[0]}"
    except Exception:
        pass
    return _current_thread_id()


def resolve_web_act_scope_key(scope: str | None = None) -> str:
    """Sanitized scope segment for web_runs/{key}/ (chat thread or kanban task)."""
    return sanitize_scope(scope or _current_scope_key())


def validate_web_act_result(
    run_dir: str,
    *,
    fresh_run_expected: bool = False,
) -> dict[str, Any]:
    """Parse result.json and artifacts under *run_dir* for tool output and kanban verify."""
    collected = _collect_artifacts(run_dir)
    result = collected.get("result")
    paths = list(collected.get("paths") or [])
    ok = False
    answer = ""
    detail = ""
    fresh_run_ok = False
    if isinstance(result, dict):
        ok = bool(result.get("ok"))
        answer = str(result.get("answer") or "").strip()
        if ok and answer:
            fresh_run_ok = True
        elif ok and not answer:
            detail = "result.json ok=true but answer is empty"
        elif not ok:
            detail = str(result.get("error") or "result.json ok=false").strip()[:500]
    elif fresh_run_expected:
        detail = "result.json missing or invalid"
    elif os.path.isfile(os.path.join(run_dir, "result.json")):
        detail = "result.json could not be parsed"
    return {
        "ok": ok and bool(answer),
        "answer": answer,
        "paths": paths,
        "fresh_run_ok": fresh_run_ok,
        "detail": detail,
        "run_dir": run_dir,
    }


def web_act_tool_output_indicates_fresh_success(content: str) -> bool:
    """True when ``run_web_script`` fresh_run output shows verified pass (non-empty answer)."""
    text = str(content or "")
    if not text or "exit_code: 0" not in text:
        return False
    fresh = False
    result_ok = False
    answer = ""
    for line in text.splitlines():
        ls = line.strip()
        if ls.startswith("fresh_run:"):
            fresh = ls.split(":", 1)[1].strip().lower() in ("true", "1", "yes")
        elif ls.startswith("result_ok:"):
            result_ok = ls.split(":", 1)[1].strip().lower() == "true"
        elif ls.startswith("answer:"):
            answer = ls.split(":", 1)[1].strip()
    return fresh and result_ok and bool(answer)


def resolve_web_act_dir(scope: str | None = None) -> str:
    """Absolute path to web_runs/{scope}/ under the agent workspace."""
    ws = _configured_workspace_dir()
    safe = resolve_web_act_scope_key(scope)
    rel = os.path.join("web_runs", safe)
    path, err = safe_resolve_path(ws, rel, allow_write=True, create_parents=True)
    if err or not path:
        raise ValueError(err or "Could not resolve web_act directory")
    root = os.path.abspath(ws)
    abs_path = os.path.abspath(path)
    if not abs_path.startswith(root + os.sep) and abs_path != root:
        raise ValueError("web_act directory escapes workspace")
    os.makedirs(abs_path, exist_ok=True)
    return abs_path


def _resolve_under_scope(relative_path: str, scope_dir: str) -> tuple[str | None, str | None]:
    rel = (relative_path or "").strip().replace("\\", "/").lstrip("/")
    if not rel or ".." in rel.split("/"):
        return None, "Invalid path: use a simple filename or subpath under web_runs (no ..)."
    for part in rel.split("/"):
        if part.startswith("."):
            return None, "Invalid path: hidden/control files cannot be written under web_runs."
    abs_path, err = safe_resolve_path(scope_dir, rel, allow_write=True, create_parents=True)
    if err or not abs_path:
        return None, err or "Path resolution failed"
    scope_real = os.path.realpath(scope_dir)
    target_real = os.path.realpath(abs_path)
    if not target_real.startswith(scope_real + os.sep) and target_real != scope_real:
        return None, "Path escapes web_act sandbox."
    return abs_path, None


def _rate_limit_path(scope_dir: str) -> str:
    return os.path.join(scope_dir, ".rate_window.json")


def _check_rate_limit(scope_dir: str, limit: int) -> str | None:
    """Consume one run slot from the hourly bucket (call only while holding the run lock)."""
    path = _rate_limit_path(scope_dir)
    hour = datetime.now(timezone.utc).strftime("%Y%m%d%H")
    count = 0
    if os.path.isfile(path):
        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            if isinstance(data, dict) and str(data.get("hour") or "") == hour:
                count = int(data.get("count") or 0)
        except Exception:
            count = 0
    if count >= limit:
        return f"Rate limit reached ({limit} script runs per hour for this conversation)."
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump({"hour": hour, "count": count + 1}, f)
    except Exception:
        pass
    return None


def _lock_path(scope_dir: str) -> str:
    return os.path.join(scope_dir, ".run.lock")


def _clear_stale_run_lock(scope_dir: str, stale_s: float) -> None:
    lock = _lock_path(scope_dir)
    if not os.path.isfile(lock):
        return
    try:
        with open(lock, "r", encoding="utf-8") as f:
            data = json.load(f)
        started = float(data.get("started_at") or 0)
        if time.time() - started < stale_s:
            return
    except Exception:
        pass
    try:
        os.unlink(lock)
    except OSError:
        pass


def _acquire_run_lock(scope_dir: str, timeout_s: float) -> tuple[bool, str]:
    os.makedirs(scope_dir, exist_ok=True)
    lock = _lock_path(scope_dir)
    stale_s = max(60.0, float(timeout_s) * _LOCK_STALE_FACTOR)
    _clear_stale_run_lock(scope_dir, stale_s)
    now = time.time()
    try:
        fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump({"started_at": now, "pid": os.getpid()}, f)
        return True, ""
    except FileExistsError:
        return False, "A web script is already running for this conversation. Wait for it to finish."
    except OSError as exc:
        return False, f"Could not acquire run lock: {exc}"


def _release_run_lock(scope_dir: str) -> None:
    try:
        os.unlink(_lock_path(scope_dir))
    except OSError:
        pass


def _next_fresh_run_dir(scope_dir: str) -> str:
    runs_root = os.path.join(scope_dir, "runs")
    os.makedirs(runs_root, exist_ok=True)
    n = 1
    while True:
        candidate = os.path.join(runs_root, f"run_{n}")
        if not os.path.exists(candidate):
            os.makedirs(candidate, exist_ok=True)
            return candidate
        n += 1


def _collect_artifacts(run_dir: str) -> dict[str, Any]:
    artifacts: list[str] = []
    result_payload: dict[str, Any] | None = None
    result_path = os.path.join(run_dir, "result.json")
    if os.path.isfile(result_path):
        artifacts.append(result_path)
        try:
            with open(result_path, "r", encoding="utf-8") as f:
                raw = json.load(f)
            if isinstance(raw, dict):
                result_payload = raw
        except Exception:
            result_payload = None
    shots_dir = os.path.join(run_dir, "screenshots")
    if os.path.isdir(shots_dir):
        for name in sorted(os.listdir(shots_dir)):
            if name.lower().endswith(".png"):
                artifacts.append(os.path.join(shots_dir, name))
    for name in ("stdout.log", "script.py"):
        p = os.path.join(run_dir, name)
        if os.path.isfile(p):
            artifacts.append(p)
    return {"paths": artifacts, "result": result_payload}


def _emit_screenshot_preview(run_dir: str) -> None:
    shots_dir = os.path.join(run_dir, "screenshots")
    candidate = os.path.join(shots_dir, "result.png")
    if not os.path.isfile(candidate) and os.path.isdir(shots_dir):
        pngs = sorted(
            p for p in os.listdir(shots_dir) if p.lower().endswith(".png")
        )
        if pngs:
            candidate = os.path.join(shots_dir, pngs[-1])
    if not os.path.isfile(candidate):
        return
    try:
        with open(candidate, "rb") as f:
            b64 = image_to_preview_base64(f.read())
        if b64:
            emit_live_view_preview(
                "Web script screenshot",
                tool_name="run_web_script",
                preview_image_base64=b64,
            )
    except Exception:
        pass


def _format_run_result(
    *,
    run_dir: str,
    script_abs: str,
    run: Any,
    artifacts: dict[str, Any],
    fresh_run: bool,
) -> str:
    lines = [
        f"exit_code: {run.exit_code}",
        f"run_dir: {run_dir}",
        f"script: {script_abs}",
        f"fresh_run: {fresh_run}",
        f"duration_ms: {run.duration_ms}",
    ]
    if run.timed_out:
        lines.append("timed_out: true")
    if run.cancelled:
        lines.append("cancelled: true")
    result = artifacts.get("result")
    validation_detail = ""
    if isinstance(result, dict) or fresh_run:
        validated = validate_web_act_result(run_dir, fresh_run_expected=bool(fresh_run))
        lines.append(f"result_ok: {validated.get('ok')}")
        answer = str(validated.get("answer") or "").strip()
        if isinstance(result, dict) and (answer or result.get("answer") is not None):
            lines.append(f"answer: {answer}")
        elif answer:
            lines.append(f"answer: {answer}")
        validation_detail = str(validated.get("detail") or "").strip()
        if validation_detail and not validated.get("ok"):
            lines.append(f"validation_detail: {validation_detail}")
        if fresh_run and not isinstance(result, dict):
            lines.append(
                "result_json: missing or invalid (fresh_run expects result.json in run_dir)"
            )
    paths = artifacts.get("paths") or []
    if paths:
        lines.append("artifacts:")
        for p in paths[:12]:
            lines.append(f"  - {p}")
    if run.stdout.strip():
        lines.append("\n--- stdout ---")
        lines.append(run.stdout.strip())
    if run.stderr.strip():
        lines.append("\n--- stderr ---")
        lines.append(run.stderr.strip())
    try:
        from utils.web_act_diagnostics import format_blockers_line, infer_web_act_blockers

        blockers = infer_web_act_blockers(
            stdout=str(run.stdout or ""),
            stderr=str(run.stderr or ""),
            exit_code=int(run.exit_code) if run.exit_code is not None else None,
            fresh_run=bool(fresh_run),
            validation_detail=validation_detail,
        )
        bl = format_blockers_line(blockers)
        if bl:
            lines.append(bl)
    except Exception:
        pass
    return "\n".join(lines)


def _suggested_kanban_handoff(
    *,
    run_dir: str,
    artifacts: dict[str, Any],
    fresh_run: bool,
    run: Any,
) -> str:
    if not fresh_run or run.exit_code != 0:
        return ""
    validated = validate_web_act_result(run_dir, fresh_run_expected=True)
    if not validated.get("fresh_run_ok"):
        return ""
    paths = artifacts.get("paths") or []
    answer = validated.get("answer") or ""
    snippet = {
        "summary": f"Web automation completed: {answer}" if answer else "Web automation fresh_run passed.",
        "metadata": {
            "answer": answer,
            "web_act_run_dir": run_dir,
            "artifact_paths": paths[:8],
            "fresh_run_ok": True,
        },
    }
    return (
        "\n\n--- suggested kanban-result metadata (copy into trailing fence) ---\n"
        + json.dumps(snippet, indent=2, ensure_ascii=False)
    )


@tool
def write_web_act_file(relative_path: str, content: str) -> str:
    """Write a file under the current conversation's web_runs sandbox only.

    Use this for Playwright automation scripts (explore.py, final.py). Paths must be
    relative (e.g. explore.py or runs/notes.txt) with no .. segments."""
    gate = _gate_message()
    if gate:
        return gate
    _emit_input_status("Writing web script…")
    try:
        scope_dir = resolve_web_act_dir()
        abs_path, err = _resolve_under_scope(relative_path, scope_dir)
        if err or not abs_path:
            return err or "Invalid path"
        cfg = __import__("core.config", fromlist=["get_web_act_config"]).get_web_act_config()
        max_bytes = int(cfg.get("max_script_bytes") or 256_000)
        data = content if isinstance(content, str) else str(content or "")
        if len(data.encode("utf-8")) > max_bytes:
            return f"Refused: content exceeds max_script_bytes ({max_bytes})."
        os.makedirs(os.path.dirname(abs_path), exist_ok=True)
        with open(abs_path, "w", encoding="utf-8") as f:
            f.write(data)
        return f"Wrote {len(data)} chars to {abs_path}"
    except Exception as exc:
        _debug_log(f"[write_web_act_file] {exc}", "ERROR")
        return f"Write failed: {exc}"


@tool
def run_web_script(script_path: str, fresh_run: bool = False) -> str:
    """Run a Playwright Python script from the web_runs sandbox for this conversation.

    Write scripts with write_web_act_file first. script_path is relative to this
    conversation's web_runs scope (e.g. explore.py), not a nested web_runs/… path.
    Scripts may import playwright.sync_api. Write result.json ({"ok": true, "answer": "..."})
    and optional screenshots/ under the run cwd. Set fresh_run=True to copy the script into
    runs/run_N/ and execute there (verification pass).

    Use for interactive web tasks (forms, sliders, pagination). For read-only pages use read_webpage.
    For login-walled pages use request_browser_page."""
    gate = _gate_message()
    if gate:
        return gate
    _emit_input_status("Running web script…")
    from core.config import get_web_act_config
    from utils.playwright_runner import playwright_available, playwright_browsers_ready, run_python_module

    cfg = get_web_act_config()
    if cfg.get("require_playwright"):
        if not playwright_available():
            return (
                "Playwright is not installed. Open Settings → Advanced and click Install Playwright, "
                "then retry."
            )
        browsers_ok, browsers_detail = playwright_browsers_ready()
        if not browsers_ok:
            return browsers_detail or (
                "Playwright browsers are not ready. Open Settings → Advanced and click "
                "Install Playwright, then retry."
            )
    rel_script = (script_path or "").strip()
    if not rel_script:
        return "No script_path provided."
    try:
        scope_dir = resolve_web_act_dir()
    except ValueError as exc:
        return str(exc)
    script_abs, err = _resolve_under_scope(rel_script, scope_dir)
    if err or not script_abs:
        return err or "Invalid script path"
    if not os.path.isfile(script_abs):
        return (
            f"Script not found: {script_abs}\n"
            f"Web-act scope directory: {scope_dir}\n"
            "Use a path relative to that directory (e.g. explore.py), not a nested web_runs/… path."
        )
    try:
        size = os.path.getsize(script_abs)
    except OSError:
        size = 0
    max_bytes = int(cfg.get("max_script_bytes") or 256_000)
    if size > max_bytes:
        return f"Refused: script exceeds max_script_bytes ({max_bytes})."
    timeout_s = float(cfg.get("max_script_seconds") or 120)
    ok_lock, lock_err = _acquire_run_lock(scope_dir, timeout_s)
    if not ok_lock:
        return lock_err
    rate_err = _check_rate_limit(
        scope_dir, int(cfg.get("max_runs_per_hour_per_scope") or 30)
    )
    if rate_err:
        _release_run_lock(scope_dir)
        return rate_err

    stream_id = f"web_act:{uuid.uuid4().hex[:12]}"
    try:
        open_job_stream(
            plugin_id=_WEB_ACT_PLUGIN_ID,
            plugin_name=_WEB_ACT_PLUGIN_NAME,
            title="Web automation script",
            job_type="run_web_script",
            stream_id=stream_id,
            controls=[
                {
                    "id": "stop_task",
                    "type": "button",
                    "label": "Stop Script",
                    "action_id": "stop_task",
                    "style": "destructive",
                }
            ],
            capabilities=make_capabilities(cancel=True, progress=True),
            metadata={"script": rel_script},
        )
        update_job_stream(
            plugin_id=_WEB_ACT_PLUGIN_ID,
            plugin_name=_WEB_ACT_PLUGIN_NAME,
            stream_id=stream_id,
            message="Starting script…",
            phase="start",
            progress_pct=5,
        )

        run_dir = scope_dir
        exec_script = script_abs
        if fresh_run:
            run_dir = _next_fresh_run_dir(scope_dir)
            exec_script = os.path.join(run_dir, "script.py")
            shutil.copy2(script_abs, exec_script)
            os.makedirs(os.path.join(run_dir, "screenshots"), exist_ok=True)

        allowed_hosts = [str(h).strip() for h in (cfg.get("allowed_hosts") or []) if str(h).strip()]
        try:
            from utils.kanban_dispatcher import current_claim_token

            claim = current_claim_token()
            if claim:
                import kanban_storage as ks

                task_row = ks.get_task(claim[0]) or {}
                tm = task_row.get("metadata") if isinstance(task_row.get("metadata"), dict) else {}
                task_hosts = tm.get("allowed_hosts")
                if isinstance(task_hosts, list):
                    allowed_hosts = list(
                        dict.fromkeys([*allowed_hosts, *[str(h).strip() for h in task_hosts if str(h).strip()]])
                    )
        except Exception:
            pass

        launch_json = "{}"
        try:
            from utils.playwright_runner import playwright_launch_kwargs

            launch_json = json.dumps(playwright_launch_kwargs())
        except Exception:
            pass

        env = _safe_subprocess_env(
            MUGI_WEB_ACT_ROOT=scope_dir,
            PYTHONUNBUFFERED="1",
            PYTHONPATH=_REPO_ROOT,
            MUGI_PLAYWRIGHT_LAUNCH_KWARGS=launch_json,
            MUGI_WEB_ACT_ALLOWED_HOSTS=json.dumps(allowed_hosts),
        )

        def _stop_check() -> bool:
            return should_stop_tool() or get_action_count(stream_id, "stop_task") > 0

        run = run_python_module(
            "utils.web_act_entry",
            [exec_script, run_dir],
            cwd=run_dir,
            timeout_s=timeout_s,
            env=env,
            max_output_chars=int(cfg.get("max_stdout_chars") or 8000),
            stop_check=_stop_check,
        )
        artifacts = _collect_artifacts(run_dir)
        _emit_screenshot_preview(run_dir)
        status = "complete" if run.exit_code == 0 else "error"
        update_job_stream(
            plugin_id=_WEB_ACT_PLUGIN_ID,
            plugin_name=_WEB_ACT_PLUGIN_NAME,
            stream_id=stream_id,
            message=f"Finished (exit {run.exit_code})",
            phase="done",
            progress_pct=100,
        )
        close_job_stream(
            plugin_id=_WEB_ACT_PLUGIN_ID,
            plugin_name=_WEB_ACT_PLUGIN_NAME,
            stream_id=stream_id,
            status=status,
            message=f"exit {run.exit_code}",
        )
        body = _format_run_result(
            run_dir=run_dir,
            script_abs=exec_script,
            run=run,
            artifacts=artifacts,
            fresh_run=bool(fresh_run),
        )
        body += _suggested_kanban_handoff(
            run_dir=run_dir,
            artifacts=artifacts,
            fresh_run=bool(fresh_run),
            run=run,
        )
        if fresh_run and run.exit_code == 0:
            validated = validate_web_act_result(run_dir, fresh_run_expected=True)
            if validated.get("fresh_run_ok") and bool(cfg.get("reflection_enabled", False)):
                try:
                    from utils.web_act_reflection import (
                        append_reflection_to_active_run,
                        reflect_web_act_run,
                    )

                    task_meta: dict[str, Any] = {}
                    try:
                        from utils.kanban_dispatcher import current_claim_token

                        claim = current_claim_token()
                        if claim:
                            import kanban_storage as ks

                            row = ks.get_task(claim[0]) or {}
                            task_meta = dict(row.get("metadata") or {})
                            task_meta["_task_id"] = claim[0]
                    except Exception:
                        pass
                    reflection = reflect_web_act_run(run_dir, task_metadata=task_meta)
                    summary = str(reflection.get("summary") or "").strip()
                    if summary:
                        body += f"\nreflection_summary: {summary[:800]}"
                        append_reflection_to_active_run(
                            str(task_meta.get("_task_id") or ""),
                            summary,
                        )
                except Exception as exc:
                    _debug_log(f"[web_act] reflection after fresh_run failed: {exc}", "DEBUG")
        return body
    except Exception as exc:
        try:
            close_job_stream(
                plugin_id=_WEB_ACT_PLUGIN_ID,
                plugin_name=_WEB_ACT_PLUGIN_NAME,
                stream_id=stream_id,
                status="error",
                message=str(exc),
            )
        except Exception:
            pass
        _debug_log(f"[run_web_script] {exc}", "ERROR")
        return f"Run failed: {exc}"
    finally:
        _release_run_lock(scope_dir)
