#!/usr/bin/env python3
"""Prepare and optionally assist TradingView Pine updates.

This tool is intentionally human-in-the-loop. It validates local Pine source,
copies the exact script text for paste, records an update manifest, and can open
TradingView in a normal visible browser session when Playwright is installed.
"""

from __future__ import annotations

import argparse
import hashlib
import importlib.metadata
import json
import re
import shutil
import subprocess
import sys
import textwrap
import webbrowser
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


REPO = Path(__file__).resolve().parents[1]
DEFAULT_OUT_DIR = REPO / ".tradingview_e2e"
TRADINGVIEW_CHART_URL = "https://www.tradingview.com/chart/"
DEFAULT_VIEWPORT = {"width": 1440, "height": 1000}
DEFAULT_REJECT_PATTERNS = [
    "Cannot compile",
    "Compilation error",
    "Error at",
    "Mismatched input",
    "Syntax error",
    "Undeclared identifier",
    "Could not find function or function reference",
]
LOGIN_PROMPT_PATTERNS = ["sign in", "log in", "sign up", "join for free"]


class PineParseError(ValueError):
    """Raised when a Pine script declaration cannot be parsed."""


@dataclass(frozen=True)
class PineScriptInfo:
    path: str
    kind: str
    title: str
    version: int | None
    sha256: str
    line_count: int
    char_count: int


@dataclass(frozen=True)
class CheckResult:
    command: list[str]
    returncode: int
    stdout: str
    stderr: str

    @property
    def ok(self) -> bool:
        return self.returncode == 0


@dataclass(frozen=True)
class PreparedUpdate:
    info: PineScriptInfo
    checks: list[CheckResult]
    source_copy: Path
    manifest: Path
    clipboard: bool


@dataclass(frozen=True)
class DoctorCheck:
    name: str
    ok: bool
    detail: str


def repo_relative(path: str | Path) -> str:
    path = Path(path)
    try:
        return str(path.resolve().relative_to(REPO))
    except ValueError:
        return str(path.resolve())


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def _extract_call(source: str, call_name: str) -> str | None:
    match = re.search(rf"\b{call_name}\s*\(", source)
    if not match:
        return None

    start = match.end() - 1
    depth = 0
    in_string = False
    escaped = False
    for idx in range(start, len(source)):
        ch = source[idx]
        if in_string:
            if escaped:
                escaped = False
            elif ch == "\\":
                escaped = True
            elif ch == '"':
                in_string = False
            continue
        if ch == '"':
            in_string = True
        elif ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
            if depth == 0:
                return source[match.start(): idx + 1]
    return None


def _extract_title(call: str) -> str:
    named = re.search(r'\btitle\s*=\s*"([^"]+)"', call, re.DOTALL)
    if named:
        return named.group(1)

    positional = re.search(r'\(\s*"([^"]+)"', call, re.DOTALL)
    if positional:
        return positional.group(1)

    raise PineParseError("Pine declaration has no string title")


def parse_pine(path: str | Path) -> PineScriptInfo:
    pine_path = Path(path)
    source = read_text(pine_path)
    version_match = re.search(r"^\s*//@version\s*=\s*(\d+)\s*$", source, re.MULTILINE)
    version = int(version_match.group(1)) if version_match else None

    for kind in ("strategy", "indicator"):
        call = _extract_call(source, kind)
        if call:
            title = _extract_title(call)
            break
    else:
        raise PineParseError("Expected a top-level strategy(...) or indicator(...) declaration")

    return PineScriptInfo(
        path=repo_relative(pine_path),
        kind=kind,
        title=title,
        version=version,
        sha256=hashlib.sha256(source.encode("utf-8")).hexdigest(),
        line_count=len(source.splitlines()),
        char_count=len(source),
    )


def run_command(command: list[str]) -> CheckResult:
    result = subprocess.run(
        command,
        cwd=REPO,
        capture_output=True,
        text=True,
        check=False,
    )
    return CheckResult(
        command=command,
        returncode=result.returncode,
        stdout=result.stdout,
        stderr=result.stderr,
    )


def run_preflight(pine_path: str | Path) -> list[CheckResult]:
    pine_path = Path(pine_path)
    checks = [
        run_command([sys.executable, "tools/check_pine_duplicates.py", repo_relative(pine_path)])
    ]

    if pine_path.resolve() == (REPO / "strategies/strategy_activation_scores.pine").resolve():
        checks.append(run_command([sys.executable, "tools/check_pine.py"]))

    return checks


def copy_to_clipboard(text: str) -> bool:
    if not shutil.which("pbcopy"):
        return False
    try:
        subprocess.run(["pbcopy"], input=text, text=True, check=True)
    except subprocess.CalledProcessError:
        return False
    return True


def manifest_path(out_dir: Path, info: PineScriptInfo) -> Path:
    safe_title = re.sub(r"[^A-Za-z0-9_.-]+", "_", info.title).strip("_") or "pine_script"
    return out_dir / f"{timestamp()}_{safe_title}.json"


def timestamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def slugify(value: str) -> str:
    return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") or "capture"


def write_manifest(
    out_dir: Path,
    info: PineScriptInfo,
    checks: list[CheckResult],
    clipboard: bool,
    browser_opened: bool,
    playwright_opened: bool,
    source_copy: Path,
) -> Path:
    out_dir.mkdir(parents=True, exist_ok=True)
    payload: dict[str, Any] = {
        "created_at": datetime.now(timezone.utc).isoformat(),
        "script": asdict(info),
        "source_copy": repo_relative(source_copy),
        "tradingview": {
            "chart_url": TRADINGVIEW_CHART_URL,
            "target_name": info.title,
            "human_review_required": True,
        },
        "local_checks": [
            {
                "command": check.command,
                "returncode": check.returncode,
                "ok": check.ok,
                "stdout_tail": check.stdout[-4000:],
                "stderr_tail": check.stderr[-4000:],
            }
            for check in checks
        ],
        "actions": {
            "copied_to_clipboard": clipboard,
            "opened_default_browser": browser_opened,
            "opened_playwright_browser": playwright_opened,
        },
        "verification_steps": [
            f"Open TradingView Pine Editor and select the existing {info.kind} named {info.title!r}.",
            "Paste the clipboard contents, save/update the script, and confirm TradingView shows no Pine compile errors.",
            "Visually inspect the chart/strategy tester for the expected script name and behavior.",
            "If the script compiles, mark the relevant local sentinel, for example: python3 tools/check_pine.py --mark-valid.",
            "Attach screenshots or exported trades/CSV to future parity checks when behavior changes.",
        ],
    }
    path = manifest_path(out_dir, info)
    path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return path


def open_playwright_browser(out_dir: Path, url: str) -> bool:
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        return False

    profile_dir = out_dir / "playwright-profile"
    profile_dir.mkdir(parents=True, exist_ok=True)
    with sync_playwright() as p:
        context = p.chromium.launch_persistent_context(
            str(profile_dir),
            headless=False,
            viewport=DEFAULT_VIEWPORT,
        )
        page = context.pages[0] if context.pages else context.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=60_000)
        input("TradingView is open. Press Enter here after you finish/save the update...")
        screenshot_dir = out_dir / "screenshots"
        screenshot_dir.mkdir(parents=True, exist_ok=True)
        screenshot_path = screenshot_dir / f"tradingview_{timestamp()}.png"
        page.screenshot(path=str(screenshot_path), full_page=True)
        print(f"Screenshot saved: {repo_relative(screenshot_path)}")
        context.close()
    return True


def write_source_copy(out_dir: Path, info: PineScriptInfo, source: str) -> Path:
    source_dir = out_dir / "source"
    source_dir.mkdir(parents=True, exist_ok=True)
    source_path = source_dir / f"{timestamp()}_{slugify(info.title)}.pine"
    source_path.write_text(source, encoding="utf-8")

    latest_path = source_dir / f"latest_{slugify(info.title)}.pine"
    latest_path.write_text(source, encoding="utf-8")
    return source_path


def stage_update(
    pine: str,
    out_dir: Path,
    no_clipboard: bool,
    browser_opened: bool = False,
    playwright_opened: bool = False,
) -> PreparedUpdate:
    pine_path = (REPO / pine).resolve() if not Path(pine).is_absolute() else Path(pine)
    info = parse_pine(pine_path)
    checks = run_preflight(pine_path)
    source = read_text(pine_path)

    clipboard = False
    if not no_clipboard:
        clipboard = copy_to_clipboard(source)

    source_copy = write_source_copy(out_dir, info, source)
    manifest = write_manifest(out_dir, info, checks, clipboard, browser_opened, playwright_opened, source_copy)
    return PreparedUpdate(
        info=info,
        checks=checks,
        source_copy=source_copy,
        manifest=manifest,
        clipboard=clipboard,
    )


def playwright_smoke(args: argparse.Namespace) -> int:
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        print("Python Playwright is not installed. See docs/tradingview_e2e_workflow.md.")
        return 1

    out_dir = resolve_out_dir(args.out_dir)
    screenshot_dir = out_dir / "screenshots"
    screenshot_dir.mkdir(parents=True, exist_ok=True)
    screenshot_path = screenshot_dir / f"smoke_{timestamp()}.png"

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page(viewport=DEFAULT_VIEWPORT)
        page.goto(
            "data:text/html,<title>tradingview-e2e-smoke</title>"
            "<body><h1>TradingView E2E smoke</h1></body>",
            wait_until="domcontentloaded",
        )
        title = page.title()
        page.screenshot(path=str(screenshot_path), full_page=True)
        browser.close()

    payload = {
        "ok": title == "tradingview-e2e-smoke",
        "title": title,
        "screenshot": repo_relative(screenshot_path),
    }
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0 if payload["ok"] else 1


def add_doctor_check(checks: list[DoctorCheck], name: str, ok: bool, detail: str) -> None:
    checks.append(DoctorCheck(name=name, ok=ok, detail=detail))


def latest_file(paths: list[Path]) -> Path | None:
    existing = [path for path in paths if path.exists() and path.is_file()]
    if not existing:
        return None
    return max(existing, key=lambda path: (path.stat().st_mtime, path.name))


def latest_glob(root: Path, pattern: str) -> Path | None:
    return latest_file(list(root.glob(pattern)))


def load_json_file(path: Path | None) -> dict[str, Any] | None:
    if not path:
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return None


def status(args: argparse.Namespace) -> int:
    out_dir = resolve_out_dir(args.out_dir)
    manifest = latest_glob(out_dir, "*_MLPScores.json") or latest_glob(out_dir, "*.json")
    capture = latest_glob(out_dir, "*_capture.json")
    manual_update = latest_glob(out_dir, "*_manual_update.json")
    screenshot = latest_glob(out_dir / "screenshots", "*.png")
    text = latest_glob(out_dir / "page_text", "*.txt")
    source = latest_glob(out_dir / "source", "*.pine")

    manifest_payload = load_json_file(manifest)
    capture_payload = load_json_file(capture)
    manual_payload = load_json_file(manual_update)
    latest_payload = manual_payload or capture_payload or manifest_payload
    title_source = latest_payload if (
        isinstance(latest_payload, dict) and isinstance(latest_payload.get("script"), dict)
    ) else manifest_payload

    payload = {
        "ok": out_dir.exists(),
        "out_dir": repo_relative(out_dir),
        "latest_manifest": repo_relative(manifest) if manifest else None,
        "latest_capture": repo_relative(capture) if capture else None,
        "latest_manual_update": repo_relative(manual_update) if manual_update else None,
        "latest_screenshot": repo_relative(screenshot) if screenshot else None,
        "latest_text": repo_relative(text) if text else None,
        "latest_source": repo_relative(source) if source else None,
        "latest_result_ok": latest_payload.get("ok") if isinstance(latest_payload, dict) else None,
        "latest_script_title": (
            title_source.get("script", {}).get("title")
            if isinstance(title_source, dict) and isinstance(title_source.get("script"), dict)
            else None
        ),
        "manual_update_verification": manual_payload.get("verification") if isinstance(manual_payload, dict) else None,
    }
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0 if payload["ok"] else 1


def doctor(args: argparse.Namespace) -> int:
    checks: list[DoctorCheck] = []
    pine_path = REPO / args.pine if not Path(args.pine).is_absolute() else Path(args.pine)

    add_doctor_check(checks, "repo", REPO.exists(), str(REPO))
    add_doctor_check(checks, "requirements-e2e", (REPO / "requirements-e2e.txt").exists(), "requirements-e2e.txt")
    add_doctor_check(checks, "gitignore-artifacts", ".tradingview_e2e/" in (REPO / ".gitignore").read_text(encoding="utf-8"), ".tradingview_e2e/")

    try:
        info = parse_pine(pine_path)
        add_doctor_check(checks, "pine-parse", True, f"{info.kind} {info.title!r}")
    except Exception as exc:
        info = None
        add_doctor_check(checks, "pine-parse", False, str(exc))

    if pine_path.exists():
        preflight = run_preflight(pine_path)
        failed = [check for check in preflight if not check.ok]
        add_doctor_check(
            checks,
            "pine-preflight",
            not failed,
            "ok" if not failed else "; ".join(f"{' '.join(check.command)} -> {check.returncode}" for check in failed),
        )
    else:
        add_doctor_check(checks, "pine-preflight", False, f"missing {repo_relative(pine_path)}")

    try:
        version = importlib.metadata.version("playwright")
        add_doctor_check(checks, "playwright-python", True, version)
    except importlib.metadata.PackageNotFoundError:
        add_doctor_check(checks, "playwright-python", False, "not installed")

    try:
        from playwright.sync_api import sync_playwright
    except ImportError as exc:
        add_doctor_check(checks, "playwright-import", False, str(exc))
        sync_playwright = None
    else:
        add_doctor_check(checks, "playwright-import", True, "ok")

    out_dir = resolve_out_dir(args.out_dir)
    browser_screenshot = None
    tradingview_screenshot = None
    tradingview_text = None
    if sync_playwright and (args.browser or args.tradingview):
        screenshot_dir = out_dir / "screenshots"
        text_dir = out_dir / "page_text"
        screenshot_dir.mkdir(parents=True, exist_ok=True)
        text_dir.mkdir(parents=True, exist_ok=True)
        try:
            with sync_playwright() as p:
                if args.browser:
                    browser_screenshot = screenshot_dir / f"doctor_browser_{timestamp()}.png"
                    browser = p.chromium.launch(headless=True)
                    page = browser.new_page(viewport=DEFAULT_VIEWPORT)
                    page.goto("data:text/html,<title>doctor</title><h1>doctor ok</h1>")
                    title = page.title()
                    page.screenshot(path=str(browser_screenshot), full_page=True)
                    browser.close()
                    add_doctor_check(checks, "browser-launch", title == "doctor", repo_relative(browser_screenshot))

                if args.tradingview:
                    stamp = timestamp()
                    tradingview_screenshot = screenshot_dir / f"doctor_tradingview_{stamp}.png"
                    tradingview_text = text_dir / f"doctor_tradingview_{stamp}.txt"
                    browser = p.chromium.launch(headless=True)
                    page = browser.new_page(viewport=DEFAULT_VIEWPORT)
                    page.goto(TRADINGVIEW_CHART_URL, wait_until="domcontentloaded", timeout=args.timeout_ms)
                    if args.wait_ms:
                        page.wait_for_timeout(args.wait_ms)
                    text = page.locator("body").inner_text(timeout=5000)
                    page.screenshot(path=str(tradingview_screenshot), full_page=True)
                    browser.close()
                    tradingview_text.write_text(text, encoding="utf-8")
                    rendered = "Indicators" in text and ("Watchlist" in text or "Pine Editor" in text)
                    add_doctor_check(
                        checks,
                        "tradingview-render",
                        rendered,
                        f"{repo_relative(tradingview_screenshot)}; {repo_relative(tradingview_text)}",
                    )
        except Exception as exc:
            add_doctor_check(checks, "browser-runtime", False, str(exc))

    payload = {
        "ok": all(check.ok for check in checks),
        "pine": asdict(info) if info else None,
        "checks": [asdict(check) for check in checks],
        "browser_screenshot": repo_relative(browser_screenshot) if browser_screenshot else None,
        "tradingview_screenshot": repo_relative(tradingview_screenshot) if tradingview_screenshot else None,
        "tradingview_text": repo_relative(tradingview_text) if tradingview_text else None,
    }
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0 if payload["ok"] else 1


def capture_url(args: argparse.Namespace) -> int:
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        print("Python Playwright is not installed. See docs/tradingview_e2e_workflow.md.")
        return 1

    out_dir = resolve_out_dir(args.out_dir)
    profile_dir = out_dir / "playwright-profile"
    screenshot_dir = out_dir / "screenshots"
    text_dir = out_dir / "page_text"
    screenshot_dir.mkdir(parents=True, exist_ok=True)
    text_dir.mkdir(parents=True, exist_ok=True)

    label = slugify(args.label or args.url)
    stamp = timestamp()
    screenshot_path = screenshot_dir / f"{stamp}_{label}.png"
    text_path = text_dir / f"{stamp}_{label}.txt"
    metadata_path = out_dir / f"{stamp}_{label}_capture.json"

    with sync_playwright() as p:
        context = p.chromium.launch_persistent_context(
            str(profile_dir),
            headless=not args.headed,
            viewport=DEFAULT_VIEWPORT,
        )
        page = context.pages[0] if context.pages else context.new_page()
        page.goto(args.url, wait_until=args.wait_until, timeout=args.timeout_ms)
        if args.wait_ms:
            page.wait_for_timeout(args.wait_ms)
        title = page.title()
        visible_text = page.locator("body").inner_text(timeout=5000) if args.save_text else ""
        page.screenshot(path=str(screenshot_path), full_page=True)
        context.close()

    if args.save_text:
        text_path.write_text(visible_text, encoding="utf-8")

    payload = {
        "ok": True,
        "url": args.url,
        "title": title,
        "screenshot": repo_relative(screenshot_path),
        "text": repo_relative(text_path) if args.save_text else None,
        "headed": args.headed,
    }
    metadata_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0


def profile_status(args: argparse.Namespace) -> int:
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        print("Python Playwright is not installed. See docs/tradingview_e2e_workflow.md.")
        return 1

    out_dir = resolve_out_dir(args.out_dir)
    profile_dir = out_dir / "playwright-profile"
    screenshot_dir = out_dir / "screenshots"
    text_dir = out_dir / "page_text"
    screenshot_dir.mkdir(parents=True, exist_ok=True)
    text_dir.mkdir(parents=True, exist_ok=True)

    stamp = timestamp()
    screenshot_path = screenshot_dir / f"profile_status_{stamp}.png"
    text_path = text_dir / f"profile_status_{stamp}.txt"
    metadata_path = out_dir / f"profile_status_{stamp}.json"

    with sync_playwright() as p:
        context = p.chromium.launch_persistent_context(
            str(profile_dir),
            headless=not args.headed,
            viewport=DEFAULT_VIEWPORT,
        )
        page = context.pages[0] if context.pages else context.new_page()
        page.goto(args.url, wait_until=args.wait_until, timeout=args.timeout_ms)
        if args.wait_for_user:
            input("Use the visible browser to sign in or inspect TradingView, then press Enter to capture status...")
        if args.wait_ms:
            page.wait_for_timeout(args.wait_ms)
        title = page.title()
        visible_text = page.locator("body").inner_text(timeout=5000)
        page.screenshot(path=str(screenshot_path), full_page=True)
        context.close()

    text_path.write_text(visible_text, encoding="utf-8")
    lower_text = visible_text.lower()
    login_prompts = [pattern for pattern in LOGIN_PROMPT_PATTERNS if pattern in lower_text]
    controls_seen = [item for item in ["Indicators", "Pine Editor", "Save", "Publish"] if item in visible_text]
    payload = {
        "ok": True,
        "url": args.url,
        "title": title,
        "profile_dir": repo_relative(profile_dir),
        "screenshot": repo_relative(screenshot_path),
        "text": repo_relative(text_path),
        "login_prompt_detected": bool(login_prompts),
        "login_prompts": login_prompts,
        "controls_seen": controls_seen,
        "profile_ready_hint": bool(controls_seen) and not login_prompts,
    }
    metadata_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0


def guided_update(args: argparse.Namespace) -> int:
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        print("Python Playwright is not installed. See docs/tradingview_e2e_workflow.md.")
        return 1

    out_dir = resolve_out_dir(args.out_dir)
    prepared = stage_update(args.pine, out_dir, args.no_clipboard)
    print_summary(prepared.info, prepared.checks, prepared.manifest, prepared.clipboard)
    print(f"  Source copy: {repo_relative(prepared.source_copy)}")

    if any(not check.ok for check in prepared.checks):
        return 1

    profile_dir = out_dir / "playwright-profile"
    screenshot_dir = out_dir / "screenshots"
    text_dir = out_dir / "page_text"
    screenshot_dir.mkdir(parents=True, exist_ok=True)
    text_dir.mkdir(parents=True, exist_ok=True)

    label = slugify(args.label or f"manual-update-{prepared.info.title}")
    stamp = timestamp()
    screenshot_path = screenshot_dir / f"{stamp}_{label}.png"
    text_path = text_dir / f"{stamp}_{label}.txt"
    metadata_path = out_dir / f"{stamp}_{label}_manual_update.json"
    expected = args.expect or [prepared.info.title]
    rejected = args.reject or DEFAULT_REJECT_PATTERNS

    print()
    print("A visible browser will open. In TradingView:")
    print(f"  1. Open Pine Editor and select the existing {prepared.info.kind}: {prepared.info.title}")
    print(f"  2. Paste from clipboard, or paste from: {repo_relative(prepared.source_copy)}")
    print("  3. Save/update the script and confirm the editor reports no compile errors.")
    print("Return here and press Enter after the save/compile step is complete.")

    with sync_playwright() as p:
        context = p.chromium.launch_persistent_context(
            str(profile_dir),
            headless=False,
            viewport=DEFAULT_VIEWPORT,
        )
        page = context.pages[0] if context.pages else context.new_page()
        page.goto(args.url, wait_until=args.wait_until, timeout=args.timeout_ms)
        input("Press Enter to capture verification evidence...")
        if args.wait_ms:
            page.wait_for_timeout(args.wait_ms)
        title = page.title()
        visible_text = page.locator("body").inner_text(timeout=5000)
        page.screenshot(path=str(screenshot_path), full_page=True)
        context.close()

    text_path.write_text(visible_text, encoding="utf-8")
    verification = verify_text_content(visible_text, expected, rejected)
    payload = {
        "ok": verification["ok"],
        "url": args.url,
        "page_title": title,
        "script": asdict(prepared.info),
        "manifest": repo_relative(prepared.manifest),
        "source_copy": repo_relative(prepared.source_copy),
        "screenshot": repo_relative(screenshot_path),
        "text": repo_relative(text_path),
        "verification": verification,
        "expected": expected,
        "rejected": rejected,
    }
    metadata_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0 if verification["ok"] else 1


def verify_text_content(text: str, expected: list[str], rejected: list[str]) -> dict[str, Any]:
    missing_expected = [item for item in expected if item and item not in text]
    found_rejected = [item for item in rejected if item and item in text]
    return {
        "ok": not missing_expected and not found_rejected,
        "missing_expected": missing_expected,
        "found_rejected": found_rejected,
    }


def verify_text(args: argparse.Namespace) -> int:
    text_path = Path(args.text)
    if not text_path.is_absolute():
        text_path = REPO / text_path
    text = text_path.read_text(encoding="utf-8")
    rejected = args.reject or DEFAULT_REJECT_PATTERNS
    result = verify_text_content(text, args.expect, rejected)
    payload = {
        **result,
        "text": repo_relative(text_path),
        "expected": args.expect,
        "rejected": rejected,
    }
    print(json.dumps(payload, indent=2, sort_keys=True))
    return 0 if result["ok"] else 1


def resolve_out_dir(value: str) -> Path:
    return (REPO / value).resolve() if not Path(value).is_absolute() else Path(value)


def print_summary(info: PineScriptInfo, checks: list[CheckResult], manifest: Path, clipboard: bool) -> None:
    failed = [check for check in checks if not check.ok]
    print(f"Prepared TradingView update for {info.kind} {info.title!r}")
    print(f"  Source:   {info.path}")
    print(f"  SHA-256:  {info.sha256}")
    print(f"  Manifest: {repo_relative(manifest)}")
    print(f"  Clipboard: {'updated' if clipboard else 'not available'}")
    print()
    if failed:
        print("Preflight failed:")
        for check in failed:
            print(f"  {' '.join(check.command)} -> {check.returncode}")
        print("Fix these before pasting into TradingView.")
    else:
        print("Preflight passed. Use the exact same TradingView script name:")
        print(f"  {info.title}")
        print()
        print("Next human-in-the-loop step:")
        print(textwrap.fill(
            "Open TradingView, select the existing script with that name in Pine Editor, "
            "paste from the clipboard, save it, and visually confirm there are no compile errors.",
            width=88,
        ))


def prepare(args: argparse.Namespace) -> int:
    out_dir = resolve_out_dir(args.out_dir)
    prepared = stage_update(args.pine, out_dir, args.no_clipboard, args.open, args.playwright_open)
    print_summary(prepared.info, prepared.checks, prepared.manifest, prepared.clipboard)
    print(f"  Source copy: {repo_relative(prepared.source_copy)}")

    if any(not check.ok for check in prepared.checks):
        return 1

    browser_opened = False
    if args.open:
        browser_opened = webbrowser.open(TRADINGVIEW_CHART_URL)

    playwright_opened = False
    if args.playwright_open:
        playwright_opened = open_playwright_browser(out_dir, TRADINGVIEW_CHART_URL)
        if not playwright_opened:
            print("Python Playwright is not installed. See docs/tradingview_e2e_workflow.md.")

    if args.open and not browser_opened:
        return 1
    if args.playwright_open and not playwright_opened:
        return 1
    return 0


def inspect(args: argparse.Namespace) -> int:
    info = parse_pine(args.pine)
    print(json.dumps(asdict(info), indent=2, sort_keys=True))
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    inspect_parser = subparsers.add_parser("inspect", help="Print parsed Pine metadata as JSON")
    inspect_parser.add_argument("pine", help="Path to a .pine script")
    inspect_parser.set_defaults(func=inspect)

    prepare_parser = subparsers.add_parser("prepare", help="Preflight and stage a TradingView update")
    prepare_parser.add_argument("pine", help="Path to a .pine strategy or indicator")
    prepare_parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR.relative_to(REPO)))
    prepare_parser.add_argument("--no-clipboard", action="store_true", help="Do not copy Pine source to pbcopy")
    prepare_parser.add_argument("--open", action="store_true", help="Open TradingView chart in the default browser")
    prepare_parser.add_argument(
        "--playwright-open",
        action="store_true",
        help="Open a visible persistent Playwright browser and save a screenshot after manual review",
    )
    prepare_parser.set_defaults(func=prepare)

    guided_parser = subparsers.add_parser(
        "manual-update",
        help="Stage Pine, open TradingView, wait for manual save, then capture and verify evidence",
    )
    guided_parser.add_argument("pine", help="Path to a .pine strategy or indicator")
    guided_parser.add_argument("--url", default=TRADINGVIEW_CHART_URL)
    guided_parser.add_argument("--label", default="")
    guided_parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR.relative_to(REPO)))
    guided_parser.add_argument("--no-clipboard", action="store_true", help="Do not copy Pine source to pbcopy")
    guided_parser.add_argument("--wait-ms", type=int, default=1000, help="Extra wait before capture")
    guided_parser.add_argument("--timeout-ms", type=int, default=60_000)
    guided_parser.add_argument(
        "--wait-until",
        choices=["commit", "domcontentloaded", "load", "networkidle"],
        default="domcontentloaded",
    )
    guided_parser.add_argument(
        "--expect",
        action="append",
        default=[],
        help="String expected in the captured text. Defaults to the Pine script title.",
    )
    guided_parser.add_argument(
        "--reject",
        action="append",
        default=[],
        help="String that must not appear. Defaults to common Pine compile-error phrases.",
    )
    guided_parser.set_defaults(func=guided_update)

    smoke_parser = subparsers.add_parser("smoke", help="Verify Playwright can launch and save a screenshot")
    smoke_parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR.relative_to(REPO)))
    smoke_parser.set_defaults(func=playwright_smoke)

    doctor_parser = subparsers.add_parser("doctor", help="Check TradingView E2E readiness")
    doctor_parser.add_argument("--pine", default="strategies/strategy_mlp_scores.pine")
    doctor_parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR.relative_to(REPO)))
    doctor_parser.add_argument("--browser", action="store_true", help="Launch a local headless browser smoke test")
    doctor_parser.add_argument("--tradingview", action="store_true", help="Capture TradingView chart render evidence")
    doctor_parser.add_argument("--wait-ms", type=int, default=3000)
    doctor_parser.add_argument("--timeout-ms", type=int, default=60_000)
    doctor_parser.set_defaults(func=doctor)

    status_parser = subparsers.add_parser("status", help="Summarize latest TradingView E2E artifacts")
    status_parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR.relative_to(REPO)))
    status_parser.set_defaults(func=status)

    capture_parser = subparsers.add_parser("capture", help="Navigate to a URL and save screenshot/text metadata")
    capture_parser.add_argument("--url", default=TRADINGVIEW_CHART_URL)
    capture_parser.add_argument("--label", default="")
    capture_parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR.relative_to(REPO)))
    capture_parser.add_argument("--headed", action="store_true", help="Show the browser instead of headless capture")
    capture_parser.add_argument("--save-text", action="store_true", help="Save visible body text next to the screenshot")
    capture_parser.add_argument("--wait-ms", type=int, default=0, help="Extra wait before capture")
    capture_parser.add_argument("--timeout-ms", type=int, default=60_000)
    capture_parser.add_argument(
        "--wait-until",
        choices=["commit", "domcontentloaded", "load", "networkidle"],
        default="domcontentloaded",
    )
    capture_parser.set_defaults(func=capture_url)

    profile_parser = subparsers.add_parser("profile-status", help="Capture persistent TradingView profile readiness evidence")
    profile_parser.add_argument("--url", default=TRADINGVIEW_CHART_URL)
    profile_parser.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR.relative_to(REPO)))
    profile_parser.add_argument("--headed", action="store_true", help="Show the persistent browser profile")
    profile_parser.add_argument("--wait-for-user", action="store_true", help="Wait for Enter before capturing status")
    profile_parser.add_argument("--wait-ms", type=int, default=3000)
    profile_parser.add_argument("--timeout-ms", type=int, default=60_000)
    profile_parser.add_argument(
        "--wait-until",
        choices=["commit", "domcontentloaded", "load", "networkidle"],
        default="domcontentloaded",
    )
    profile_parser.set_defaults(func=profile_status)

    verify_parser = subparsers.add_parser("verify-text", help="Check captured text for expected and rejected strings")
    verify_parser.add_argument("--text", required=True, help="Path to captured visible-text artifact")
    verify_parser.add_argument(
        "--expect",
        action="append",
        default=[],
        help="String that must appear in the captured text; repeat for multiple checks",
    )
    verify_parser.add_argument(
        "--reject",
        action="append",
        default=[],
        help="String that must not appear. Defaults to common Pine compile-error phrases.",
    )
    verify_parser.set_defaults(func=verify_text)

    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
