#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
from http.cookiejar import CookieJar
from html.parser import HTMLParser
import json
import re
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlparse
from urllib.request import HTTPCookieProcessor, Request, build_opener, urlopen


USER_AGENT = "darebuild-agent-client/1.0"


class BootstrapParser(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.settings_urls = []
        self.webhook_url = ""
        self.readme_url = ""
        self.smoke_command = ""
        self._capture = ""
        self._parts = []

    def handle_starttag(self, tag, attrs):
        attrs = dict(attrs)
        href = attrs.get("href") or ""
        if href and re.search(r"/account/[^/]+/settings/?(?:[#?].*)?$", href):
            self.settings_urls.append(href)
        if href and "/api/codex/v1/account/" in href and href.endswith("/README.md"):
            self.readme_url = href
        if "data-account-codex-url" in attrs:
            self._capture = "webhook"
            self._parts = []
        if "data-account-codex-smoke" in attrs:
            self._capture = "smoke"
            self._parts = []

    def handle_data(self, data):
        if self._capture:
            self._parts.append(data)

    def handle_endtag(self, tag):
        if not self._capture:
            return
        value = "".join(self._parts).strip()
        if self._capture == "webhook" and value:
            self.webhook_url = value
        if self._capture == "smoke" and value:
            self.smoke_command = value
        self._capture = ""
        self._parts = []


def die(message, code=1):
    print(message, file=sys.stderr)
    raise SystemExit(code)


def request_bytes(url, *, opener=None, timeout=20, accept="*/*"):
    opener = opener or build_opener()
    request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": accept})
    try:
        with opener.open(request, timeout=timeout) as response:
            return response.geturl(), response.status, response.read()
    except HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        die(f"{url} returned HTTP {exc.code}: {body[:500]}")
    except URLError as exc:
        die(f"{url} failed: {exc}")


def request_text(url, *, opener=None, timeout=20):
    final_url, status, body = request_bytes(url, opener=opener, timeout=timeout, accept="text/html,*/*")
    return final_url, status, body.decode("utf-8", errors="replace")


def request_json(url, *, payload=None, opener=None, timeout=20):
    opener = opener or build_opener()
    data = None if payload is None else json.dumps(payload).encode("utf-8")
    request = Request(
        url,
        data=data,
        method="GET" if payload is None else "POST",
        headers={
            "User-Agent": USER_AGENT,
            "Accept": "application/json",
            "Content-Type": "application/json",
        },
    )
    try:
        with opener.open(request, timeout=timeout) as response:
            body = response.read().decode("utf-8", errors="replace")
    except HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        try:
            detail = json.loads(body)
        except json.JSONDecodeError:
            detail = body[:500]
        die(f"{url} returned HTTP {exc.code}: {detail}")
    except URLError as exc:
        die(f"{url} failed: {exc}")
    try:
        return json.loads(body)
    except json.JSONDecodeError:
        die(f"{url} did not return JSON: {body[:500]}")


def request_json_or_none(url, *, opener=None, timeout=20):
    opener = opener or build_opener()
    request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
    try:
        with opener.open(request, timeout=timeout) as response:
            return json.loads(response.read().decode("utf-8", errors="replace"))
    except Exception:
        return None


def api_url(base_url, suffix):
    return urljoin(base_url.rstrip("/") + "/", suffix.lstrip("/"))


def parse_bootstrap_page(html, base_url):
    parser = BootstrapParser()
    parser.feed(html)
    return {
        "settings_urls": [urljoin(base_url, href) for href in parser.settings_urls],
        "webhook_url": urljoin(base_url, parser.webhook_url) if parser.webhook_url else "",
        "readme_url": urljoin(base_url, parser.readme_url) if parser.readme_url else "",
        "smoke_command": parser.smoke_command,
    }


def infer_settings_url(url):
    parsed = urlparse(url)
    match = re.search(r"/account/([^/]+)/", parsed.path)
    if not match:
        return ""
    return f"{parsed.scheme}://{parsed.netloc}/account/{match.group(1)}/settings/"


def payload_from_arg(raw, default=None):
    if raw:
        try:
            value = json.loads(raw)
        except json.JSONDecodeError as exc:
            die(f"Invalid JSON payload: {exc}")
        if not isinstance(value, dict):
            die("JSON payload must be an object.")
        return value
    return dict(default or {})


def print_json(payload):
    print(json.dumps(payload, indent=2, sort_keys=True))


def sha256_bytes(value):
    return hashlib.sha256(value).hexdigest()


def verify_artifacts(base_url, manifest, *, timeout=20):
    artifacts = manifest.get("artifacts") or {}
    results = {}
    for name in ("client", "installer"):
        artifact = artifacts.get(name) or {}
        url = artifact.get("url") or artifact.get("well_known_url")
        expected_hash = artifact.get("sha256")
        expected_bytes = artifact.get("bytes")
        if not url or not expected_hash:
            results[name] = {"ok": False, "message": "missing artifact URL or sha256"}
            continue
        final_url, _status, body = request_bytes(urljoin(base_url.rstrip("/") + "/", url), timeout=timeout)
        actual_hash = sha256_bytes(body)
        actual_bytes = len(body)
        results[name] = {
            "ok": actual_hash == expected_hash and (expected_bytes in (None, actual_bytes)),
            "url": final_url,
            "sha256": actual_hash,
            "expected_sha256": expected_hash,
            "bytes": actual_bytes,
            "expected_bytes": expected_bytes,
        }
    return {
        "ok": all(item.get("ok") for item in results.values()) and set(results) == {"client", "installer"},
        "artifacts": results,
    }


def compact_json(payload):
    return json.dumps(payload, separators=(",", ":"), sort_keys=True)


def build_command_catalog(base_url, manifest):
    base_url = base_url.rstrip("/")
    account_url = manifest.get("account_webhook", {}).get("url_template", "<account-webhook-url>")
    project_url = manifest.get("project_api", {}).get("base_url_template", "<project-api-url>")
    account_actions = []
    for action in manifest.get("account_webhook", {}).get("actions", []):
        payload = action.get("payload", {"action": action.get("name", "list")})
        account_actions.append(
            {
                "name": action.get("name"),
                "payload": payload,
                "argv_template": [
                    "python",
                    "darebuild_agent.py",
                    "account",
                    "--account-webhook-url",
                    account_url,
                    "--payload-json",
                    compact_json(payload),
                ],
                "requires": action.get("requires", []),
                "returns": action.get("returns", []),
            }
        )
    project_endpoints = []
    for endpoint in manifest.get("project_api", {}).get("endpoints", []):
        method = endpoint.get("method", "POST").upper()
        payload = endpoint.get("payload", {})
        if endpoint.get("content_type") == "multipart/form-data":
            argv = [
                "curl",
                "-sS",
                "-X",
                method,
                project_url.rstrip("/") + "/" + endpoint.get("path", "").lstrip("/"),
                "-F",
                "media=@hero.png",
            ]
        else:
            argv = [
                "python",
                "darebuild_agent.py",
                "project",
                "--project-api-url",
                project_url,
                "--method",
                method,
                "--endpoint",
                endpoint.get("path", ""),
            ]
        if method != "GET" and endpoint.get("content_type") != "multipart/form-data":
            argv.extend(["--payload-json", compact_json(payload)])
        project_endpoints.append(
            {
                "method": method,
                "path": endpoint.get("path", ""),
                "payload": payload,
                "argv_template": argv,
                "purpose": endpoint.get("purpose", ""),
                "notes": endpoint.get("notes", []),
            }
        )
    return {
        "ok": True,
        "base_url": base_url,
        "discovery": manifest.get("discovery", {}),
        "human_only_operations": manifest.get("security", {}).get("human_only_operations", []),
        "starter_keys": manifest.get("starter_keys", []),
        "client_commands": [
            {
                "name": "check",
                "purpose": "Verify public discovery and downloadable artifact hashes.",
                "argv": ["python", "darebuild_agent.py", "check", "--base-url", base_url],
            },
            {
                "name": "commands",
                "purpose": "Print this command catalog from the live public manifest.",
                "argv": ["python", "darebuild_agent.py", "commands", "--base-url", base_url, "--format", "json"],
            },
            {
                "name": "bootstrap",
                "purpose": "Use the login link DareBuild emailed to the user to discover the private account webhook.",
                "argv_template": [
                    "python",
                    "darebuild_agent.py",
                    "bootstrap",
                    "--magic-link",
                    "https://darebuild.com/magic/...",
                    "--run-smoke",
                    "--editor-email",
                    "editor@example.com",
                ],
            },
            {
                "name": "smoke",
                "purpose": "Create and verify the five-project acceptance workflow from a copied account webhook.",
                "argv_template": [
                    "python",
                    "darebuild_agent.py",
                    "smoke",
                    "--account-webhook-url",
                    account_url,
                    "--editor-email",
                    "editor@example.com",
                ],
            },
        ],
        "account_actions": account_actions,
        "project_endpoints": project_endpoints,
    }


def print_command_catalog_text(catalog):
    print("DareBuild agent command catalog")
    print(f"Base URL: {catalog['base_url']}")
    print("")
    print("Start here:")
    for command in catalog["client_commands"]:
        argv = command.get("argv") or command.get("argv_template") or []
        print(f"- {command['name']}: {' '.join(argv)}")
    print("")
    print("Account actions:")
    for action in catalog["account_actions"]:
        print(f"- {action['name']}: payload {compact_json(action['payload'])}")
    print("")
    print("Project endpoints:")
    for endpoint in catalog["project_endpoints"]:
        print(f"- {endpoint['method']} {endpoint['path']}")
    print("")
    print("Human-only operations:")
    for item in catalog["human_only_operations"]:
        print(f"- {item}")


def command_check(args):
    base_url = args.base_url.rstrip("/")
    manifest = request_json(f"{base_url}/.well-known/darebuild-agent.json", timeout=args.request_timeout)
    payload = request_json(f"{base_url}/.well-known/darebuild-agent-check.json", timeout=args.request_timeout)
    if args.no_artifact_verify:
        payload["artifact_verification"] = {"ok": None, "status": "skipped"}
    else:
        payload["artifact_verification"] = verify_artifacts(base_url, manifest, timeout=args.request_timeout)
        if not payload["artifact_verification"].get("ok"):
            payload["ok"] = False
    print_json(payload)
    if not payload.get("ok"):
        raise SystemExit(2)


def command_commands(args):
    base_url = args.base_url.rstrip("/")
    manifest = request_json(f"{base_url}/.well-known/darebuild-agent.json", timeout=args.request_timeout)
    catalog = build_command_catalog(base_url, manifest)
    if args.format == "json":
        print_json(catalog)
    else:
        print_command_catalog_text(catalog)


def discover_webhook_from_magic_link(magic_link, *, timeout=20):
    opener = build_opener(HTTPCookieProcessor(CookieJar()))
    final_url, _status, body = request_text(magic_link, opener=opener, timeout=timeout)
    session_discovery_url = urljoin(final_url, "/api/codex/v1/account/current/")
    session_payload = request_json_or_none(session_discovery_url, opener=opener, timeout=timeout) or {}
    if session_payload.get("webhook_url"):
        return opener, final_url, {
            "settings_urls": [],
            "webhook_url": session_payload["webhook_url"],
            "readme_url": session_payload.get("readme_url", ""),
            "smoke_command": session_payload.get("smoke_command", ""),
        }
    page = parse_bootstrap_page(body, final_url)
    if page["webhook_url"]:
        return opener, final_url, page
    settings_url = page["settings_urls"][0] if page["settings_urls"] else infer_settings_url(final_url)
    if not settings_url:
        die("Magic link was accepted, but no Team settings URL was found.")
    settings_final_url, _status, settings_body = request_text(settings_url, opener=opener, timeout=timeout)
    settings_page = parse_bootstrap_page(settings_body, settings_final_url)
    if not settings_page["webhook_url"]:
        die("Team settings did not expose an Account Codex webhook. Confirm this user can manage the team.")
    return opener, settings_final_url, settings_page


def command_bootstrap(args):
    opener, final_url, page = discover_webhook_from_magic_link(args.magic_link, timeout=args.request_timeout)
    verification = None
    if not args.no_verify:
        verification = request_json(page["webhook_url"], payload={"action": "list"}, opener=opener, timeout=args.request_timeout)
    report = {
        "ok": True,
        "final_url": final_url,
        "account_webhook_url": page["webhook_url"],
        "account_readme_url": page["readme_url"],
        "smoke_command": page["smoke_command"],
        "account": (verification or {}).get("account", {}),
        "project_count": len((verification or {}).get("projects", [])),
    }
    if args.run_smoke:
        report["smoke"] = run_smoke(
            page["webhook_url"],
            editor_email=args.editor_email,
            name_prefix=args.name_prefix,
            public_timeout=args.public_timeout,
            request_timeout=args.request_timeout,
            skip_public=args.skip_public,
        )
    print_json(report)


def command_account(args):
    payload = payload_from_arg(args.payload_json, {"action": args.action})
    print_json(request_json(args.account_webhook_url, payload=payload, timeout=args.request_timeout))


def command_project(args):
    url = api_url(args.project_api_url, args.endpoint)
    if args.method == "GET":
        print_json(request_json(url, timeout=args.request_timeout))
        return
    payload = payload_from_arg(args.payload_json)
    print_json(request_json(url, payload=payload, timeout=args.request_timeout))


def get_public_url(url, *, timeout):
    request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"})
    try:
        with urlopen(request, timeout=timeout) as response:
            return response.status, response.read().decode("utf-8", errors="replace")
    except HTTPError as exc:
        return exc.code, exc.read().decode("utf-8", errors="replace")
    except URLError as exc:
        return 0, str(exc)


def account_call(account_webhook_url, payload, *, timeout):
    result = request_json(account_webhook_url, payload=payload, timeout=timeout)
    if not result.get("ok", True):
        die(f"Account action failed: {result}")
    return result


def project_get(project, suffix, *, timeout):
    result = request_json(api_url(project["codex_api_url"], suffix), timeout=timeout)
    if not result.get("ok", True):
        die(f"Project GET failed: {result}")
    return result


def project_post(project, suffix, payload, *, timeout):
    result = request_json(api_url(project["codex_api_url"], suffix), payload=payload, timeout=timeout)
    if not result.get("ok", True):
        die(f"Project POST failed: {result}")
    return result


def create_project(account_webhook_url, name, starter_key, *, timeout):
    result = account_call(
        account_webhook_url,
        {"action": "create", "name": name, "starter_key": starter_key},
        timeout=timeout,
    )
    project = result.get("project") or {}
    if not project.get("codex_api_url"):
        die(f"Project creation did not return codex_api_url: {result}")
    return project


def poll_public(project, expected, *, timeout, request_timeout, skip_public=False):
    if skip_public:
        return {"ok": None, "status": "skipped", "message": "public polling skipped"}
    deadline = time.monotonic() + max(1, timeout)
    expected_lower = [item.lower() for item in expected]
    blockers = ["backend database unavailable", "backend access is locked"]
    last_status = 0
    last_body = ""
    while time.monotonic() <= deadline:
        last_status, last_body = get_public_url(project["public_url"], timeout=request_timeout)
        body_lower = last_body.lower()
        if 200 <= last_status < 400 and all(item in body_lower for item in expected_lower):
            return {"ok": True, "status": "passed", "http_status": last_status}
        if any(blocker in body_lower for blocker in blockers):
            return {
                "ok": None,
                "status": "blocked_backend",
                "http_status": last_status,
                "message": "backend unlock is required for this runtime check",
            }
        time.sleep(2)
    return {"ok": False, "status": "failed", "http_status": last_status, "message": last_body[:300]}


def verify_project(project, expected, *, public_timeout, request_timeout, skip_public=False, extra=None):
    manifest = project_get(project, "manifest/", timeout=request_timeout)
    result = {
        "id": project["id"],
        "name": project["name"],
        "public_url": project["public_url"],
        "codex_api_url": project["codex_api_url"],
        "backend_access_enabled": manifest.get("project", {}).get("backend_access_enabled"),
        "public_check": poll_public(
            project,
            expected,
            timeout=public_timeout,
            request_timeout=request_timeout,
            skip_public=skip_public,
        ),
    }
    if extra:
        result.update(extra)
    return result


def run_smoke(account_webhook_url, *, editor_email="", name_prefix="Agent smoke", public_timeout=60, request_timeout=20, skip_public=False):
    list_payload = account_call(account_webhook_url, {"action": "list"}, timeout=request_timeout)
    starters = {starter.get("key") for starter in list_payload.get("starter_templates", [])}
    required = {"project-auth-password", "todo-list", "background-timestamp-task", "blank"}
    missing = sorted(required - starters)
    if missing:
        die("Account webhook is missing starter templates: " + ", ".join(missing))

    stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
    prefix = f"{name_prefix} {stamp}"
    report = {"ok": True, "account": list_payload.get("account", {}), "started_at": stamp, "projects": [], "warnings": []}

    login_project = create_project(account_webhook_url, f"{prefix} 1 login", "project-auth-password", timeout=request_timeout)
    report["projects"].append(
        verify_project(
            login_project,
            ["Project login", "Sign up", "Log in", "Verify email"],
            public_timeout=public_timeout,
            request_timeout=request_timeout,
            skip_public=skip_public,
        )
    )

    todo_project = create_project(account_webhook_url, f"{prefix} 2 todo", "todo-list", timeout=request_timeout)
    report["projects"].append(
        verify_project(
            todo_project,
            ["To-do list"],
            public_timeout=public_timeout,
            request_timeout=request_timeout,
            skip_public=skip_public,
        )
    )

    background_project = create_project(
        account_webhook_url,
        f"{prefix} 3 background",
        "background-timestamp-task",
        timeout=request_timeout,
    )
    background_manifest = project_get(background_project, "manifest/", timeout=request_timeout)
    update_task = next((task for task in background_manifest.get("tasks", []) if task.get("name") == "Update timestamp"), None)
    background_extra = {"task": update_task}
    if update_task and background_manifest.get("project", {}).get("backend_access_enabled"):
        background_extra["task_run"] = project_post(background_project, f"tasks/{update_task['id']}/run/", {}, timeout=request_timeout)
    elif update_task:
        background_extra["task_run"] = {"ok": None, "status": "blocked_backend", "message": "backend unlock is required before tasks can run"}
        report["warnings"].append("Background task run was skipped because backend access is locked.")
    else:
        report["warnings"].append("Background project did not expose task 'Update timestamp'.")
    report["projects"].append(
        verify_project(
            background_project,
            ["Background task timestamp"],
            public_timeout=public_timeout,
            request_timeout=request_timeout,
            skip_public=skip_public,
            extra=background_extra,
        )
    )

    locked_project = create_project(account_webhook_url, f"{prefix} 4 locked", "blank", timeout=request_timeout)
    locked_protection = project_post(
        locked_project,
        "files/protection/",
        {"action": "lock", "paths": ["public/homepage.py", "shared/__init__.py"]},
        timeout=request_timeout,
    )
    locked_push = project_post(
        locked_project,
        "changes/",
        {
            "files": {"public/homepage.html": "<h1>Locked file smoke</h1><p>Only homepage HTML changed.</p>"},
            "publish": True,
            "message": "Agent smoke locked-file homepage update",
        },
        timeout=request_timeout,
    )
    report["projects"].append(
        verify_project(
            locked_project,
            ["Locked file smoke"],
            public_timeout=public_timeout,
            request_timeout=request_timeout,
            skip_public=skip_public,
            extra={"protection": locked_protection, "push": locked_push},
        )
    )

    scoped_project = create_project(account_webhook_url, f"{prefix} 5 scoped", "blank", timeout=request_timeout)
    scoped_push = project_post(
        scoped_project,
        "changes/",
        {
            "files": {
                "public/homepage.html": '<h1>Scoped editor smoke</h1><link rel="stylesheet" href="/static/homepage.css"><script src="/static/homepage.js"></script>',
                "public/homepage.css": "body { font-family: system-ui, sans-serif; color: #182034; }",
                "public/homepage.js": "window.darebuildScopedSmoke = true;",
            },
            "publish": True,
            "message": "Agent smoke scoped editor files",
        },
        timeout=request_timeout,
    )
    scoped_protection = project_post(
        scoped_project,
        "files/protection/",
        {"action": "lock_and_hide", "paths": ["public/homepage.py", "shared/__init__.py"]},
        timeout=request_timeout,
    )
    scoped_extra = {"push": scoped_push, "protection": scoped_protection}
    if editor_email:
        scoped_extra["project_collaborator"] = account_call(
            account_webhook_url,
            {
                "action": "invite_project_user",
                "project_id": scoped_project["id"],
                "email": editor_email,
                "role": "editor",
                "allowed_paths": ["public/homepage.css", "public/homepage.js"],
            },
            timeout=request_timeout,
        )
    else:
        report["warnings"].append("Use --editor-email to verify project-scoped editor invite creation.")
    report["projects"].append(
        verify_project(
            scoped_project,
            ["Scoped editor smoke"],
            public_timeout=public_timeout,
            request_timeout=request_timeout,
            skip_public=skip_public,
            extra=scoped_extra,
        )
    )

    failed = [project for project in report["projects"] if project["public_check"].get("ok") is False]
    if failed:
        report["ok"] = False
    return report


def command_smoke(args):
    report = run_smoke(
        args.account_webhook_url,
        editor_email=args.editor_email,
        name_prefix=args.name_prefix,
        public_timeout=args.public_timeout,
        request_timeout=args.request_timeout,
        skip_public=args.skip_public,
    )
    print_json(report)
    if not report.get("ok"):
        raise SystemExit(2)


def build_parser():
    parser = argparse.ArgumentParser(description="DareBuild standalone agent client.")
    parser.add_argument("--request-timeout", type=int, default=20)
    subparsers = parser.add_subparsers(dest="command", required=True)

    check = subparsers.add_parser("check", help="Verify public DareBuild agent discovery.")
    check.add_argument("--base-url", default="https://darebuild.com")
    check.add_argument("--no-artifact-verify", action="store_true")
    check.set_defaults(func=command_check)

    commands = subparsers.add_parser("commands", help="Print agent command catalog from the public manifest.")
    commands.add_argument("--base-url", default="https://darebuild.com")
    commands.add_argument("--format", choices=["text", "json"], default="text")
    commands.set_defaults(func=command_commands)

    bootstrap = subparsers.add_parser(
        "bootstrap", help="Use the login link DareBuild emailed to the user to discover the account webhook."
    )
    bootstrap.add_argument("--magic-link", required=True)
    bootstrap.add_argument("--run-smoke", action="store_true")
    bootstrap.add_argument("--editor-email", default="")
    bootstrap.add_argument("--name-prefix", default="Agent smoke")
    bootstrap.add_argument("--public-timeout", type=int, default=60)
    bootstrap.add_argument("--skip-public", action="store_true")
    bootstrap.add_argument("--no-verify", action="store_true")
    bootstrap.set_defaults(func=command_bootstrap)

    account = subparsers.add_parser("account", help="POST an account webhook action.")
    account.add_argument("--account-webhook-url", required=True)
    account.add_argument("--action", default="list")
    account.add_argument("--payload-json", default="")
    account.set_defaults(func=command_account)

    project = subparsers.add_parser("project", help="Call a project Codex API endpoint.")
    project.add_argument("--project-api-url", required=True)
    project.add_argument("--method", choices=["GET", "POST"], default="POST")
    project.add_argument("--endpoint", required=True)
    project.add_argument("--payload-json", default="{}")
    project.set_defaults(func=command_project)

    smoke = subparsers.add_parser("smoke", help="Create and verify the five-project smoke workflow.")
    smoke.add_argument("--account-webhook-url", required=True)
    smoke.add_argument("--editor-email", default="")
    smoke.add_argument("--name-prefix", default="Agent smoke")
    smoke.add_argument("--public-timeout", type=int, default=60)
    smoke.add_argument("--skip-public", action="store_true")
    smoke.set_defaults(func=command_smoke)
    return parser


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)
    args.func(args)


if __name__ == "__main__":
    main()
