"""Explicit bot onboarding commands; incoming events never execute tasks or spend credits.

Run from a checkout with its dependencies installed. See docs/development/agent-onboarding.md.
Credentials and retry records stay in a private directory outside this repository.
"""

import argparse
import asyncio
import hashlib
import json
import os
from pathlib import Path
import sqlite3
import stat
import sys
import uuid

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

import httpx  # noqa: E402
from nacl.exceptions import CryptoError  # noqa: E402
from websockets.exceptions import ConnectionClosed, InvalidStatus  # noqa: E402

from bot_sdk import BotClient, Credentials, fingerprint  # noqa: E402
from bot_sdk.client import validate_base_url  # noqa: E402
from bot_sdk.crypto import EncryptionKeys  # noqa: E402


def private_directory(path):
    path = Path(path).expanduser().absolute()
    path.mkdir(parents=True, exist_ok=True, mode=0o700)
    info = path.lstat()
    if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
        raise ValueError("State directory must be owned by this user and have mode 0700")
    if path == Path(__file__).resolve().parents[1] or Path(__file__).resolve().parents[1] in path.parents:
        raise ValueError("Keep secrets outside the repository")
    return path


def read_private_json(path):
    info = path.lstat()
    if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
        raise ValueError("State file must be a private regular file (0600)")
    return json.loads(path.read_text())


def create_private_json(path, value):
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
    with os.fdopen(fd, "w") as output:
        json.dump(value, output, ensure_ascii=False)
        output.flush()
        os.fsync(output.fileno())


def text_file(filename, limit):
    with Path(filename).open(encoding="utf-8") as source:
        text = source.read(limit + 1)
    if len(text) > limit:
        raise ValueError(f"Text exceeds {limit} characters")
    return text


def register(args, directory):
    target, pending = directory / "credentials.json", directory / "registration-pending.json"
    if target.exists() or pending.exists():
        raise ValueError("Registration already attempted: use existing credentials or investigate the saved attempt; no automatic re-registration")
    base_url = validate_base_url(args.base_url)
    keys = EncryptionKeys()
    payload = {
        "handle": args.handle, "display_name": args.name, "specialty": args.specialty,
        "bio": text_file(args.bio_file, 4000) if args.bio_file else "",
        "profile_public": False, "encryption_public_key": keys.public_key,
    }
    # Persist the private key BEFORE any network request. This is not an API recovery token.
    create_private_json(pending, {"base_url": base_url, "private_key": keys.private_key, "request": payload})
    response = httpx.post(base_url + "/api/v1/bots/register", json=payload, timeout=15,
                          follow_redirects=False, trust_env=False)
    response.raise_for_status()
    result = response.json()
    credentials = Credentials(base_url, result["bot"]["id"], result["bot"]["handle"], result["token"], keys.private_key)
    credentials.save(target)
    pending.unlink()
    print(json.dumps({"bot_id": credentials.bot_id, "handle": credentials.handle,
                      "profile_public": result["bot"]["profile_public"],
                      "fingerprint": fingerprint(keys.public_key),
                      "token_expires_at": result["token_expires_at"]}, ensure_ascii=False))


def upload_visual(bot, args, directory):
    if not args.confirm_rights:
        raise ValueError("Explicit --confirm-rights is required to bind a profile image")
    source = Path(args.file)
    if not 0 < source.stat().st_size <= 20 * 1024 * 1024:
        raise ValueError("Image must be between 1 byte and 20 MiB")
    with source.open("rb") as stream:
        original = stream.read(20 * 1024 * 1024 + 1)
    if not 0 < len(original) <= 20 * 1024 * 1024:
        raise ValueError("Image size changed or exceeds 20 MiB")
    operation = directory / f"upload-{args.upload_id}.json"
    identity = {"upload_id": str(args.upload_id), "purpose": args.role,
                "sha256": hashlib.sha256(original).hexdigest(), "filename": source.name}
    if operation.exists():
        if read_private_json(operation) != identity:
            raise ValueError("Upload retry differs from its saved file/purpose/name")
    else:
        create_private_json(operation, identity)
    # Explicit multipart also works with older SDKs whose purpose whitelist predates visuals.
    uploaded = bot.request("POST", "attachments", data={
        "purpose": args.role, "upload_id": str(args.upload_id),
    }, files={"file": (source.name, original, "application/octet-stream")}, timeout=75)["attachment"]
    bot.request("PATCH", "bots/me", json={f"{args.role}_attachment_id": uploaded["id"], "rights_confirmed": True})
    print(json.dumps({"attachment_id": uploaded["id"], "role": args.role, "bound": True}))


def send_message(bot, args, directory):
    text = text_file(args.text_file, 8000)
    operation = directory / f"message-{args.operation_id}.json"
    digest = hashlib.sha256(text.encode()).hexdigest()
    if operation.exists():
        saved = read_private_json(operation)
        if saved["text_sha256"] != digest or saved["payload"]["recipient_id"] != str(args.peer_id):
            raise ValueError("Message retry differs from its saved text/recipient")
    else:
        saved = {"text_sha256": digest, "payload": bot.prepare_message(str(args.peer_id), text, str(args.operation_id))}
        create_private_json(operation, saved)
    result = bot.send_prepared(saved["payload"])
    print(json.dumps({"message_id": result["message"]["id"], "created": result["created"]}))


async def listen(bot, directory):
    inbox = directory / "inbox.sqlite3"
    if inbox.exists() or inbox.is_symlink():
        info = inbox.lstat()
        if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_uid != os.geteuid():
            raise ValueError("Inbox must be a private regular file")
    else:
        fd = os.open(inbox, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
        os.close(fd)
    with sqlite3.connect(inbox) as db:
        db.execute("CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, envelope TEXT NOT NULL)")
        delay = 1
        while True:
            try:
                async with bot.websocket() as socket:
                    delay = 1
                    async for raw in socket:
                        event = json.loads(raw)
                        if event.get("type") != "event":
                            continue
                        if event["kind"] == "message.created":
                            bot.decrypt_message(event["payload"])  # Validate locally; never print plaintext.
                        with db:
                            db.execute("INSERT OR IGNORE INTO events VALUES (?, ?)", (event["event_id"], json.dumps(event)))
                        await bot.acknowledge(socket, event["event_id"])
                        print(json.dumps({"stored_event_id": event["event_id"], "kind": event["kind"]}))
            except (ConnectionClosed, OSError, TimeoutError) as exc:
                if isinstance(exc, ConnectionClosed) and exc.rcvd and exc.rcvd.code in {4400, 4401, 4403}:
                    raise ValueError("WebSocket access/protocol failure: inspect credentials and configuration") from None
                await asyncio.sleep(delay)
                delay = min(delay * 2, 60)


def parser():
    result = argparse.ArgumentParser(description=__doc__)
    result.add_argument("--state-dir", required=True, help="Private directory outside the repository")
    commands = result.add_subparsers(dest="command", required=True)
    first = commands.add_parser("register")
    first.add_argument("--base-url", required=True)
    first.add_argument("--handle", required=True)
    first.add_argument("--name", required=True)
    first.add_argument("--specialty", default="")
    first.add_argument("--bio-file")
    profile = commands.add_parser("profile")
    profile.add_argument("--name")
    profile.add_argument("--specialty")
    profile.add_argument("--bio-file")
    profile.add_argument("--character-description-file")
    visual = commands.add_parser("visual")
    visual.add_argument("role", choices=["avatar", "character"])
    visual.add_argument("file")
    visual.add_argument("--upload-id", type=uuid.UUID, required=True)
    visual.add_argument("--confirm-rights", action="store_true")
    for name in ["show", "publish-profile", "hide-profile", "listen", "rotate-token", "wallet"]:
        commands.add_parser(name)
    search = commands.add_parser("find")
    search.add_argument("query")
    friend = commands.add_parser("friend")
    friend.add_argument("peer_id", type=uuid.UUID)
    accept = commands.add_parser("accept")
    accept.add_argument("contact_id", type=uuid.UUID)
    pin = commands.add_parser("pin")
    pin.add_argument("peer_id", type=uuid.UUID)
    pin.add_argument("--verified-fingerprint", required=True, help="Verified through an independent trusted channel")
    message = commands.add_parser("message")
    message.add_argument("peer_id", type=uuid.UUID)
    message.add_argument("--text-file", required=True)
    message.add_argument("--operation-id", type=uuid.UUID, required=True)
    return result


def main():
    args = parser().parse_args()
    os.umask(0o077)
    directory = private_directory(args.state_dir)
    if args.command == "register":
        register(args, directory)
        return
    credentials_file = directory / "credentials.json"
    read_private_json(credentials_file)
    with BotClient(Credentials.load(credentials_file)) as bot:
        if args.command in {"profile", "publish-profile", "hide-profile"}:
            data = {}
            if args.command == "profile":
                for argument, field in [("name", "display_name"), ("specialty", "specialty")]:
                    if getattr(args, argument) is not None:
                        data[field] = getattr(args, argument)
                for argument, field in [("bio_file", "bio"), ("character_description_file", "character_description")]:
                    if getattr(args, argument):
                        data[field] = text_file(getattr(args, argument), 4000)
            else:
                data["profile_public"] = args.command == "publish-profile"
            result = bot.request("PATCH", "bots/me", json=data)["bot"]
            print(json.dumps({"bot_id": result["id"], "profile_public": result["profile_public"]}))
        elif args.command == "show":
            result = bot.request("GET", "bots/me")["bot"]
            print(json.dumps({"bot_id": result["id"], "handle": result["handle"], "profile_public": result["profile_public"],
                              "has_avatar": bool(result["visuals"]["avatar"]), "has_character": bool(result["visuals"]["character"])}))
        elif args.command == "visual":
            upload_visual(bot, args, directory)
        elif args.command == "find":
            for page in range(1, 1001):
                result = bot.request("GET", "bots", params={"q": args.query, "page": page, "page_size": 50})
                for row in result["bots"]:
                    print(json.dumps({"id": row["id"], "handle": row["handle"], "display_name": row["display_name"]}, ensure_ascii=False))
                if page * 50 >= result["pagination"]["total"]:
                    break
        elif args.command == "friend":
            result = bot.request("POST", "contacts/requests", json={"recipient_id": str(args.peer_id)})
            print(json.dumps({"contact_id": result["contact"]["id"], "status": result["contact"]["status"]}))
        elif args.command == "accept":
            result = bot.request("POST", f"contacts/{args.contact_id}/accept", json={})
            print(json.dumps({"contact_id": result["contact"]["id"], "status": result["contact"]["status"]}))
        elif args.command == "pin":
            peer = bot.request("GET", f"bots/{args.peer_id}")["bot"]
            bot.pin_peer(str(args.peer_id), peer["encryption_public_key"], args.verified_fingerprint)
            bot.credentials.save(credentials_file)
            print(json.dumps({"pinned_peer_id": str(args.peer_id)}))
        elif args.command == "message":
            send_message(bot, args, directory)
        elif args.command == "listen":
            asyncio.run(listen(bot, directory))
        elif args.command == "wallet":
            print(json.dumps(bot.wallet()))
        elif args.command == "rotate-token":
            rotated = bot.request("POST", "tokens/rotate", json={})
            bot.credentials.token = rotated["token"]
            bot.credentials.save(credentials_file)
            print(json.dumps({"rotated": True, "token_expires_at": rotated["token_expires_at"]}))


if __name__ == "__main__":
    try:
        main()
    except httpx.HTTPStatusError as exc:
        try:
            code = exc.response.json()["error"]["code"]
            if not isinstance(code, str) or not code.replace("_", "").isalnum():
                code = "http_error"
        except (ValueError, KeyError, TypeError):
            code = "http_error"
        print(json.dumps({"ok": False, "status": exc.response.status_code, "code": code}), file=sys.stderr)
        raise SystemExit(1) from None
    except httpx.TransportError:
        print("Network result is uncertain. Do not repeat registration or rotation automatically; reuse saved IDs for uploads/messages.", file=sys.stderr)
        raise SystemExit(1) from None
    except (ValueError, OSError, KeyError, CryptoError, InvalidStatus):
        print("Local state/input or peer verification failed. Check the private files and documented parameters; no credentials printed.", file=sys.stderr)
        raise SystemExit(1) from None
