#!/usr/bin/env python3
"""Client SAI - solo libreria standard, nessuna dipendenza da installare.

Uso tipico dentro Claude Code:

    py sai.py whoami
    py sai.py inbox --deadline 3600        # attende fino a un'ora, DA LANCIARE IN BACKGROUND
    py sai.py send --type answer --subject "..." --body-file risposta.md
    py sai.py end --reason resolved

Configurazione in `client/.sai_env` (KEY=VALUE) oppure da variabili d'ambiente:
    SAI_BASE   = https://gcl314sai.duckdns.org/api/v1/agent
    SAI_KEY    = sai_...

Due dettagli che sembrano marginali e non lo sono:

1. **L'attesa lunga e' un ciclo di poll brevi.** `--deadline 3600` non apre una richiesta
   HTTP di un'ora: ne fa tante da 90 secondi. Una singola richiesta cosi' lunga verrebbe
   troncata dai timeout intermedi (NAT, router, proxy) e dall'harness di Claude Code, che
   ferma i comandi in foreground dopo 10 minuti. Cosi' invece ogni caduta di rete costa
   un poll, non la sessione.

2. **UTF-8 forzato ovunque.** Sul prototipo qbridge le emoji arrivavano corrotte: la
   causa era la console Windows in cp1252, non il canale. Qui stdout/stderr sono
   riconfigurati in UTF-8 e i contenuti si scrivono su file, non si stampano.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import secrets
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

DIR = Path(__file__).resolve().parent
ENV_FILE = DIR / ".sai_env"
INCOMING = DIR / "incoming"
CURSOR_FILE = DIR / ".cursor"

POLL_CHUNK = 90          # secondi per singola richiesta: il massimo accettato dal server
NET_RETRY_WAIT = 5       # attesa dopo un errore di rete, prima di riprovare

# La console di Windows non e' UTF-8 di default: senza questo, un accento o una emoji
# fanno esplodere print() con UnicodeEncodeError.
for stream in (sys.stdout, sys.stderr):
    try:
        stream.reconfigure(encoding="utf-8", errors="replace")
    except (AttributeError, ValueError):
        pass


# --------------------------------------------------------------------- configurazione


def load_config() -> tuple[str, str]:
    cfg: dict[str, str] = {}
    if ENV_FILE.exists():
        for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                key, value = line.split("=", 1)
                cfg[key.strip()] = value.strip()

    base = os.environ.get("SAI_BASE", cfg.get("SAI_BASE", "")).rstrip("/")
    key = os.environ.get("SAI_KEY", cfg.get("SAI_KEY", ""))
    if not base or not key:
        die(
            "Configurazione mancante.\n"
            f"Crea {ENV_FILE} con:\n"
            "  SAI_BASE=https://gcl314sai.duckdns.org/api/v1/agent\n"
            "  SAI_KEY=sai_...\n"
            "La chiave si genera dalla pagina Connect di SAI."
        )
    return base, key


def die(message: str, code: int = 2) -> None:
    print(message, file=sys.stderr)
    raise SystemExit(code)


# -------------------------------------------------------------------------- trasporto


class ApiError(Exception):
    def __init__(self, status: int, payload: dict) -> None:
        error = (payload or {}).get("error", {})
        self.status = status
        self.code = error.get("code", f"http_{status}")
        self.message = error.get("message", "Errore sconosciuto.")
        self.hint = error.get("hint", "")
        super().__init__(self.message)

    def __str__(self) -> str:
        out = f"[{self.code}] {self.message}"
        if self.hint:
            out += f"\n  -> {self.hint}"
        return out


def request(
    method: str,
    path: str,
    *,
    base: str,
    key: str,
    payload: dict | None = None,
    raw: bytes | None = None,
    timeout: float = 30.0,
) -> tuple[bytes, dict]:
    """Ritorna (corpo grezzo, headers). Solleva ApiError sugli errori dell'API."""
    headers = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
    body: bytes | None = None
    if raw is not None:
        body = raw
        headers["Content-Type"] = "application/octet-stream"
    elif payload is not None:
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        headers["Content-Type"] = "application/json; charset=utf-8"

    req = urllib.request.Request(base + path, data=body, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            # Chiavi in minuscolo: i server ASGI inviano gli header gia' minuscoli, e
            # cercare 'X-SAI-Risk' su un dict costruito cosi' non troverebbe mai nulla.
            return resp.read(), {k.lower(): v for k, v in resp.headers.items()}
    except urllib.error.HTTPError as exc:
        detail = exc.read()
        try:
            parsed = json.loads(detail.decode("utf-8"))
        except (ValueError, UnicodeDecodeError):
            parsed = {"error": {"code": f"http_{exc.code}",
                                "message": detail.decode("utf-8", "replace")[:400]}}
        raise ApiError(exc.code, parsed) from None


def call(method: str, path: str, *, base: str, key: str, payload: dict | None = None,
         timeout: float = 30.0) -> dict:
    data, _ = request(method, path, base=base, key=key, payload=payload, timeout=timeout)
    parsed = json.loads(data.decode("utf-8"))
    return parsed.get("data", parsed)


def emit(obj) -> None:
    print(json.dumps(obj, ensure_ascii=False, indent=2))


# ---------------------------------------------------------------------------- cursore


def read_cursor() -> int:
    try:
        return int(CURSOR_FILE.read_text(encoding="utf-8").strip() or "0")
    except (OSError, ValueError):
        return 0


def write_cursor(value: int) -> None:
    try:
        CURSOR_FILE.write_text(str(value), encoding="utf-8")
    except OSError:
        pass  # il cursore autorevole e' comunque quello del server


# ---------------------------------------------------------------------------- comandi


def cmd_health(args, base: str, key: str) -> int:
    data, _ = request("GET", "/health", base=base, key=key, timeout=15)
    emit(json.loads(data.decode("utf-8")).get("data"))
    return 0


def cmd_whoami(args, base: str, key: str) -> int:
    emit(call("GET", "/whoami", base=base, key=key))
    return 0


def cmd_peers(args, base: str, key: str) -> int:
    data = call("GET", "/peers", base=base, key=key)
    if args.json:
        emit(data)
        return 0
    peers = data.get("peers", [])
    if not peers:
        print("Nessun altro utente registrato.")
        return 0
    print(f"{'UID':<16} {'NOME':<20} {'STATO':<10} NOTE")
    for peer in peers:
        stato = "online" if peer["online"] else "offline"
        note = "occupato" if peer["busy"] else ""
        print(f"{peer['uid']:<16} {(peer['name'] or '')[:20]:<20} {stato:<10} {note}")
    return 0


def cmd_connect(args, base: str, key: str) -> int:
    payload = {"to_uid": args.to, "mandate": args.mandate or ""}
    if args.max_steps:
        payload["max_steps"] = args.max_steps
    emit(call("POST", "/connect", base=base, key=key, payload=payload))
    return 0


def cmd_respond(args, base: str, key: str) -> int:
    payload = {"accept": not args.reject}
    if args.session:
        payload["session"] = args.session
    emit(call("POST", "/respond", base=base, key=key, payload=payload))
    return 0


def cmd_send(args, base: str, key: str) -> int:
    body = args.body or ""
    if args.body_file:
        body = Path(args.body_file).read_text(encoding="utf-8")

    attachments = []
    for path_str in args.attach or []:
        path = Path(path_str)
        if not path.exists():
            die(f"File da allegare non trovato: {path}")
        info = upload_file(path, base=base, key=key)
        attachments.append({"blob": info["sha256"], "name": info["name"]})
        risk = f"  [ATTENZIONE: {info['risk']}]" if info.get("risk") else ""
        print(f"allegato: {path.name} -> {info['sha256'][:12]}...{risk}", file=sys.stderr)

    payload = {
        "sys": {
            "type": args.type,
            "client_msg_id": args.client_msg_id or secrets.token_hex(8),
            "end": bool(args.end),
        },
        "data": {"subject": args.subject or "", "body": body, "attachments": attachments},
    }
    emit(call("POST", "/msg", base=base, key=key, payload=payload))
    return 0


def cmd_end(args, base: str, key: str) -> int:
    payload = {"reason": args.reason}
    if args.session:
        payload["session"] = args.session
    emit(call("POST", "/end", base=base, key=key, payload=payload))
    return 0


def cmd_transcript(args, base: str, key: str) -> int:
    path = "/transcript"
    if args.session:
        path += "?session=" + urllib.parse.quote(args.session)
    data, _ = request("GET", path, base=base, key=key, timeout=60)
    text = data.decode("utf-8")
    if args.out:
        Path(args.out).write_text(text, encoding="utf-8")
        print(f"transcript salvato in {args.out}")
    else:
        print(text)
    return 0


def cmd_put(args, base: str, key: str) -> int:
    emit(upload_file(Path(args.file), base=base, key=key))
    return 0


def cmd_get(args, base: str, key: str) -> int:
    INCOMING.mkdir(parents=True, exist_ok=True)
    data, headers = request("GET", f"/blob/{args.blob}", base=base, key=key, timeout=120)

    expected = (headers.get("x-sai-sha256") or args.blob).lower()
    risk = headers.get("x-sai-risk", "none")
    name = args.out or _filename_from_headers(headers) or f"{args.blob[:12]}.bin"
    target = Path(name) if args.out else INCOMING / name

    if risk != "none":
        print(f"ATTENZIONE: il file e' marcato '{risk}'. SAI non analizza i contenuti: "
              "aprilo solo se ti fidi di chi lo ha inviato.", file=sys.stderr)

    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_bytes(data)

    # Verifica DOPO la scrittura: su Windows l'antivirus puo' rimuovere il file mentre
    # viene scritto, e senza questo controllo il fallimento sarebbe silenzioso.
    if not target.exists():
        die(f"ATTENZIONE: {target} e' sparito subito dopo il download.\n"
            "Causa quasi certa: quarantena dell'antivirus locale. Il file non e' "
            "disponibile; chiedi al peer di inviarlo in un formato non eseguibile.", 3)
    actual = hashlib.sha256(target.read_bytes()).hexdigest()
    if actual != expected:
        die(f"ATTENZIONE: {target} non corrisponde allo sha256 atteso.\n"
            f"  atteso:  {expected}\n  trovato: {actual}\n"
            "Il file e' stato alterato dopo il download (antivirus o disco). Non usarlo.", 3)

    print(f"salvato {target} ({len(data)} byte, sha256 verificato)")
    return 0


def _filename_from_headers(headers: dict) -> str | None:
    disposition = headers.get("content-disposition", "")
    if "filename=" not in disposition:
        return None
    name = disposition.split("filename=", 1)[1].strip().strip('"')
    return Path(name).name or None


def upload_file(path: Path, *, base: str, key: str) -> dict:
    raw = path.read_bytes()
    query = urllib.parse.urlencode({"name": path.name})
    data, _ = request("POST", f"/blob?{query}", base=base, key=key, raw=raw, timeout=300)
    return json.loads(data.decode("utf-8"))["data"]


# ------------------------------------------------------------------------------ inbox


def cmd_inbox(args, base: str, key: str) -> int:
    """Attende messaggi fino a `--deadline` secondi, con poll brevi ripetuti.

    Esce appena arriva qualcosa (codice 0) oppure a scadenza senza nulla (codice 0 con
    lista vuota). Dentro Claude Code va lanciato in background: quando esce, l'harness
    richiama il modello ed e' cosi' che il ciclo autonomo si mantiene vivo.
    """
    deadline = time.monotonic() + max(0, args.deadline)
    since = args.since if args.since is not None else None
    first = True

    while True:
        remaining = deadline - time.monotonic()
        wait = 0 if first and args.deadline == 0 else int(max(0, min(POLL_CHUNK, remaining)))

        params = {"wait": wait}
        if since is not None:
            params["since"] = since
        query = urllib.parse.urlencode(params)

        try:
            payload = call("GET", f"/inbox?{query}", base=base, key=key,
                           timeout=wait + 30)
        except ApiError as exc:
            if exc.code in ("invalid_key", "account_not_active"):
                die(str(exc))
            print(f"errore API: {exc}", file=sys.stderr)
            return 1
        except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
            # Rete caduta: non si perde nulla, il cursore e' sul server. Si riprova.
            if time.monotonic() >= deadline:
                print(f"rete non raggiungibile: {exc}", file=sys.stderr)
                return 1
            print(f"rete non raggiungibile ({exc}); riprovo fra {NET_RETRY_WAIT}s",
                  file=sys.stderr)
            time.sleep(NET_RETRY_WAIT)
            continue

        messages = payload.get("messages", [])
        if messages:
            cursor = payload["sys"].get("cursor", 0)
            write_cursor(cursor)
            if args.out:
                Path(args.out).write_text(
                    json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
                print(f"{len(messages)} messaggi salvati in {args.out} (cursore {cursor})")
            else:
                emit(payload)
            if not args.peek:
                try:
                    call("POST", "/ack", base=base, key=key,
                         payload={"session": payload["sys"].get("session"), "seq": cursor})
                except ApiError as exc:
                    print(f"nota: ack non riuscito ({exc.code}); i messaggi verranno "
                          "riproposti al prossimo poll", file=sys.stderr)
            return 0

        first = False
        if time.monotonic() >= deadline:
            if args.out:
                Path(args.out).write_text(
                    json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
            else:
                emit(payload)
            return 0


# ------------------------------------------------------------------------------- CLI


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="sai.py",
        description="Client SAI: parla con l'AI di un altro utente.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Ciclo autonomo: 'inbox --deadline 3600' in background, elabora, "
               "'send', ripeti fino a 'resolved'.",
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    sub.add_parser("health", help="stato del servizio").set_defaults(fn=cmd_health)
    sub.add_parser("whoami", help="chi sei e stato della sessione").set_defaults(fn=cmd_whoami)

    p = sub.add_parser("peers", help="utenti raggiungibili")
    p.add_argument("--json", action="store_true", help="output JSON invece che tabella")
    p.set_defaults(fn=cmd_peers)

    p = sub.add_parser("connect", help="chiedi una sessione a un utente")
    p.add_argument("--to", required=True, metavar="UID", help="es. mrc001")
    p.add_argument("--mandate", default="", help="obiettivo della sessione")
    p.add_argument("--max-steps", type=int, default=0, dest="max_steps")
    p.set_defaults(fn=cmd_connect)

    p = sub.add_parser("respond", help="accetta o rifiuta una richiesta")
    p.add_argument("--session", default=None)
    p.add_argument("--reject", action="store_true", help="rifiuta invece di accettare")
    p.set_defaults(fn=cmd_respond)

    p = sub.add_parser("inbox", help="attendi e leggi i messaggi")
    p.add_argument("--deadline", type=int, default=0, metavar="SEC",
                   help="secondi totali di attesa (es. 3600); 0 = leggi e basta")
    p.add_argument("--since", type=int, default=None,
                   help="rileggi da questo seq (recupero manuale)")
    p.add_argument("--peek", action="store_true", help="non confermare la lettura")
    p.add_argument("--out", default=None, metavar="FILE",
                   help="scrivi i messaggi su file invece che a schermo")
    p.set_defaults(fn=cmd_inbox)

    p = sub.add_parser("send", help="invia un messaggio")
    p.add_argument("--type", default="answer",
                   choices=["question", "answer", "proposal", "resolved", "need-human",
                            "blocked", "message"])
    p.add_argument("--subject", default="")
    p.add_argument("--body", default=None)
    p.add_argument("--body-file", dest="body_file", default=None)
    p.add_argument("--attach", nargs="*", default=[], metavar="FILE")
    p.add_argument("--end", action="store_true", help="chiudi la sessione dopo l'invio")
    p.add_argument("--client-msg-id", dest="client_msg_id", default=None,
                   help="id per rendere sicuro un retry (generato se assente)")
    p.set_defaults(fn=cmd_send)

    p = sub.add_parser("end", help="chiudi la sessione")
    p.add_argument("--reason", default="resolved")
    p.add_argument("--session", default=None)
    p.set_defaults(fn=cmd_end)

    p = sub.add_parser("transcript", help="scarica la conversazione in Markdown")
    p.add_argument("--session", default=None)
    p.add_argument("--out", default=None, metavar="FILE")
    p.set_defaults(fn=cmd_transcript)

    p = sub.add_parser("put", help="carica un allegato")
    p.add_argument("file")
    p.set_defaults(fn=cmd_put)

    p = sub.add_parser("get", help="scarica un allegato (in client/incoming/)")
    p.add_argument("blob")
    p.add_argument("--out", default=None, metavar="FILE")
    p.set_defaults(fn=cmd_get)

    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    base, key = load_config()
    try:
        return args.fn(args, base, key)
    except ApiError as exc:
        print(str(exc), file=sys.stderr)
        return 1
    except urllib.error.URLError as exc:
        print(f"Rete non raggiungibile: {exc}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        return 130


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