#!/usr/bin/env python3
"""Atlas Cyber Protect local portal — living security UI + guardian agent (no Factory, no prices)."""
from __future__ import annotations

import hashlib
import hmac
import json
import os
import re
import secrets
import socket
import sqlite3
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse

ROOT = Path(__file__).resolve().parent
DATA = Path("/var/lib/atlas-cyber-protect")
DB = DATA / "portal.sqlite"
PORTAL_HTML = ROOT / "portal.html"
ICONS_DIR = ROOT / "device-icons"
STATE = DATA / "state.json"
AGENT_RELEASES = DATA / "agent-releases"
AGENT_MANIFEST = AGENT_RELEASES / "manifest.json"
ENDPOINT_LEARN = DATA / "endpoint-learning.jsonl"
LOCK_FLAG = DATA / "subscription.locked"
ENV_FILE = Path("/etc/atlas-cyber-protect/acp.env")
CUSTOMER_JSON = DATA / "customer.json"
HOST = os.environ.get("ACP_PORTAL_HOST", "0.0.0.0")
PORT = int(os.environ.get("ACP_PORTAL_PORT", "8787"))
SESSIONS: dict[str, dict] = {}
SESS_TTL = 14 * 24 * 3600
SESSIONS_PATH = DATA / "sessions.json"
SESSIONS_LOCK = threading.Lock()


def _sessions_load() -> None:
    """Restore saved sessions (remember-me survives box restarts / power loss)."""
    try:
        if SESSIONS_PATH.is_file():
            data = json.loads(SESSIONS_PATH.read_text(encoding="utf-8"))
            now = time.time()
            for tok, sess in (data or {}).items():
                if isinstance(sess, dict) and float(sess.get("exp") or 0) > now:
                    SESSIONS[tok] = sess
    except Exception as exc:
        print(f"sessions load skipped: {type(exc).__name__}: {exc}", flush=True)


def _sessions_save() -> None:
    """Persist valid sessions so logins are remembered after a reboot."""
    try:
        DATA.mkdir(parents=True, exist_ok=True)
        now = time.time()
        with SESSIONS_LOCK:
            keep = {t: s for t, s in SESSIONS.items() if float(s.get("exp") or 0) > now}
            tmp = SESSIONS_PATH.with_suffix(".json.tmp")
            tmp.write_text(json.dumps(keep), encoding="utf-8")
            tmp.replace(SESSIONS_PATH)
    except Exception as exc:
        print(f"sessions save skipped: {type(exc).__name__}: {exc}", flush=True)


def _customer_info() -> dict:
    env = _load_env()
    out = {
        "business_name": env.get("BUSINESS_NAME") or "",
        "site_name": env.get("SITE_NAME") or "",
        "notify_email": env.get("NOTIFY_EMAIL") or "",
        "company_id": env.get("COMPANY_ID") or "",
    }
    try:
        if CUSTOMER_JSON.is_file():
            raw = json.loads(CUSTOMER_JSON.read_text(encoding="utf-8"))
            if isinstance(raw, dict):
                out["business_name"] = out["business_name"] or str(raw.get("business_name") or "")
                out["site_name"] = out["site_name"] or str(raw.get("site_name") or "")
                out["notify_email"] = out["notify_email"] or str(raw.get("notify_email") or "")
                out["company_id"] = out["company_id"] or str(raw.get("company_id") or "")
    except Exception:
        pass
    try:
        st = _load_state()
        cust = st.get("customer") if isinstance(st.get("customer"), dict) else {}
        if cust:
            out["business_name"] = out["business_name"] or str(cust.get("business_name") or "")
            out["site_name"] = out["site_name"] or str(cust.get("site_name") or "")
            out["notify_email"] = out["notify_email"] or str(cust.get("notify_email") or "")
            out["company_id"] = out["company_id"] or str(cust.get("company_id") or "")
    except Exception:
        pass
    done = Path("/etc/atlas-cyber-protect/customer-setup.done")
    try:
        if done.is_file() and not out["business_name"]:
            out["business_name"] = done.read_text(encoding="utf-8").strip()[:120]
    except Exception:
        pass
    return out


def _atlas_base() -> str:
    try:
        from atlas_reach import atlas_base as _reach

        return _reach()
    except Exception:
        return (
            _load_env().get("ATLAS_URL")
            or os.environ.get("ATLAS_URL")
            or "https://atlas-server.taile9cc75.ts.net"
        ).rstrip("/")


def _sync_atlas_connect_endpoint(agent_id: str, row: dict) -> None:
    """Register this PC with Funnel Atlas Connect so managers can click Connect."""
    try:
        cust = _customer_info()
        company_id = str(cust.get("company_id") or "").strip()
        if not company_id or company_id == "pending":
            return
        atlas = _atlas_base()
        inv = row.get("inventory") if isinstance(row.get("inventory"), dict) else {}
        hostname = (
            str(row.get("name") or "")
            or str(inv.get("hostname") or "")
            or str(row.get("hostname") or "")
            or agent_id
        )[:120]
        payload = {
            "agent_id": agent_id[:80],
            "company_id": company_id[:80],
            "site_id": str(cust.get("site_id") or "")[:80],
            "hostname": hostname,
            "ownership": "company_managed",
            "assigned_person_id": "",
            "rustdesk_id": str(inv.get("atlas_connect_id") or inv.get("rustdesk_id") or "")[:80],
        }
        import ssl
        import urllib.request

        data = json.dumps(payload).encode()
        req = urllib.request.Request(
            f"{atlas}/api/cyber-protect/remote/endpoints",
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        with urllib.request.urlopen(req, timeout=20, context=ctx) as resp:
            resp.read()
    except Exception as exc:
        print(f"atlas-connect sync: {exc}", flush=True)


def _agents() -> dict:
    try:
        import companion_store as cs

        return cs.load_agents()
    except Exception:
        st = _load_state()
        raw = st.get("companion_agents") or {}
        return raw if isinstance(raw, dict) else {}


def _get_agent(agent_id: str) -> dict | None:
    """Look up a single paired agent by id."""
    if not agent_id:
        return None
    try:
        import companion_store as cs

        agents = cs.load_agents()
        a = agents.get(agent_id)
        if a:
            return a
    except Exception:
        pass
    st = _load_state()
    raw = st.get("companion_agents") or {}
    if isinstance(raw, dict):
        return raw.get(agent_id)
    return None


def _save_agents(agents: dict) -> None:
    try:
        import companion_store as cs

        cs.save_agents(agents)
    except Exception:
        st = _load_state()
        st["companion_agents"] = agents
        _save_state(st)


def _load_env() -> dict[str, str]:
    out: dict[str, str] = {}
    if ENV_FILE.is_file():
        for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, v = line.split("=", 1)
            out[k.strip()] = v.strip().strip('"').strip("'")
    return out


def _enroll_token() -> str:
    return (_load_env().get("ENROLL_TOKEN") or os.environ.get("ENROLL_TOKEN") or "").strip()


def _lan_pending() -> dict:
    path = DATA / "lan-pending.json"
    if not path.is_file():
        return {"updated": 0, "people": []}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        if isinstance(data, dict):
            return data
    except Exception:
        pass
    return {"updated": 0, "people": []}


def _queue_tool_on_pcs(kind: str, url: str = "", agent_id: str = "") -> int:
    agents = dict(_agents())
    cmd = {
        "id": f"{kind}-{int(time.time())}",
        "type": kind,
        "queued_at": time.time(),
        "url": url,
    }
    n = 0
    for aid, row in agents.items():
        if not isinstance(row, dict):
            continue
        if agent_id and aid != agent_id:
            continue
        cmds = list(row.get("pending_commands") or [])
        cmds.append(cmd)
        row["pending_commands"] = cmds[-8:]
        agents[aid] = row
        n += 1
    if n:
        _save_agents(agents)
    return n


def _atlas_lan_approve(email: str, role: str = "employee", person_id: str = "") -> dict:
    atlas = _atlas_base()
    token = _enroll_token()
    payload = {
        "enroll_token": token,
        "email": email,
        "person_id": person_id,
        "role": role,
    }
    import ssl
    import urllib.request

    data = json.dumps(payload).encode()
    req = urllib.request.Request(
        f"{atlas}/api/cyber-protect/auth/lan-approve",
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
        return json.loads(resp.read().decode("utf-8", errors="replace") or "{}")


def _chat_archive_index() -> dict:
    """Box-local chat archive (pulled from Atlas). Manager/owner-only via portal login."""
    base = DATA / "chat-archive"
    out: dict = {"ok": True, "threads": [], "people": [], "messages": [], "calls": [], "updated_at": 0}
    try:
        snap = base / "snapshot.json"
        if snap.is_file():
            d = json.loads(snap.read_text(encoding="utf-8"))
            out["threads"] = d.get("threads") or []
            out["people"] = d.get("people") or []
            out["calls"] = d.get("calls") or []
            out["thread_members"] = d.get("thread_members") or {}
            out["company_id"] = d.get("company_id") or ""
            out["site"] = d.get("site") or {}
            out["updated_at"] = d.get("updated_at") or 0
        msgs_file = base / "messages.jsonl"
        if msgs_file.is_file():
            rows: list[dict] = []
            for line in msgs_file.read_text(encoding="utf-8", errors="replace").splitlines():
                line = line.strip()
                if not line:
                    continue
                try:
                    m = json.loads(line)
                except Exception:
                    continue
                if m.get("media_path"):
                    ext = str(m.get("media_path") or "").rsplit(".", 1)[-1] or "bin"
                    m["media_src"] = f"/api/companion/chat-archive/media/{m.get('id')}.{ext}"
                rows.append(m)
            out["messages"] = rows[-2000:]
        for c in out.get("calls") or []:
            for rec in c.get("recordings") or []:
                fname = str(rec.get("url") or "").rsplit("/", 1)[-1]
                if fname:
                    rec["local_src"] = f"/api/companion/chat-archive/media/{fname}"
    except Exception as e:
        out["error"] = str(e)[:200]
    return out


def _companion_public(a: dict) -> dict:
    """Rich PC agent card for portal (AV + software, no secrets)."""
    inv = a.get("inventory") if isinstance(a.get("inventory"), dict) else {}
    av = inv.get("antivirus") if isinstance(inv.get("antivirus"), dict) else {}
    primary = av.get("primary") if isinstance(av.get("primary"), dict) else {}
    defender = av.get("defender") if isinstance(av.get("defender"), dict) else inv.get("defender") or {}
    software = inv.get("software") if isinstance(inv.get("software"), dict) else {}
    programs = software.get("programs") if isinstance(software.get("programs"), list) else []
    last_scan = inv.get("last_scan") if isinstance(inv.get("last_scan"), dict) else a.get("last_scan") or {}
    man = _load_agent_manifest()
    remote_ver = str(man.get("version") or "")
    local_ver = str(inv.get("agent_version") or "")[:32]
    update_available = False
    try:

        def parts(v: str):
            out = []
            for p in (v or "0").split("."):
                out.append(int("".join(c for c in p if c.isdigit()) or "0"))
            return out

        update_available = bool(remote_ver) and parts(remote_ver) > parts(local_ver or "0")
    except Exception:
        update_available = False
    cust = _customer_info()
    age = time.time() - float(a.get("last_seen") or 0)
    online = age < 180
    return {
        "agent_id": a.get("agent_id"),
        "name": a.get("name"),
        "last_seen": a.get("last_seen"),
        "online": online,
        "remote_addr": a.get("remote_addr") or inv.get("lan_ip") or "",
        "lan_ip": inv.get("lan_ip") or a.get("remote_addr") or "",
        "os": str(inv.get("os") or "")[:80],
        "hostname": str(inv.get("hostname") or "")[:80],
        "agent_version": local_ver,
        "latest_version": remote_ver,
        "update_available": update_available,
        "customer": cust,
        "safety": a.get("safety") or {},
        "primary_av": str(primary.get("name") or inv.get("primary_av") or "")[:120],
        "av_engine": str(primary.get("engine") or inv.get("av_engine") or "none")[:40],
        "av_can_scan": bool(primary.get("can_scan")),
        "defender_realtime": bool(defender.get("RealTimeProtectionEnabled")),
        "defender_enabled": bool(defender.get("AntivirusEnabled")),
        "software_count": int(software.get("count") or len(programs) or 0),
        "programs": [
            {
                "name": str(p.get("name") or "")[:120],
                "version": str(p.get("version") or "")[:40],
                "publisher": str(p.get("publisher") or "")[:80],
            }
            for p in programs[:40]
            if isinstance(p, dict)
        ],
        "last_scan": {
            "ok": last_scan.get("ok"),
            "engine": last_scan.get("engine"),
            "name": last_scan.get("name"),
            "note": str(last_scan.get("note") or "")[:240],
            "at": last_scan.get("at"),
            "threats_seen": list(last_scan.get("threats_seen") or [])[:12],
        }
        if last_scan
        else {},
        "pending_commands": len(a.get("pending_commands") or []),
        "paired_at": a.get("paired_at"),
    }


def _learn_endpoint(agent_row: dict, inv: dict) -> None:
    """Append endpoint facts so Atlas/guardian can learn what PCs are doing."""
    try:
        DATA.mkdir(parents=True, exist_ok=True)
        primary = {}
        av = inv.get("antivirus") if isinstance(inv.get("antivirus"), dict) else {}
        if isinstance(av.get("primary"), dict):
            primary = av.get("primary") or {}
        software = inv.get("software") if isinstance(inv.get("software"), dict) else {}
        lesson = {
            "ts": time.time(),
            "agent_id": agent_row.get("agent_id"),
            "name": agent_row.get("name"),
            "hostname": inv.get("hostname"),
            "os": inv.get("os"),
            "av_engine": primary.get("engine") or inv.get("av_engine"),
            "av_name": primary.get("name") or inv.get("primary_av"),
            "software_count": (software.get("count") if isinstance(software, dict) else None),
            "agent_version": inv.get("agent_version"),
            "lesson": (
                f"PC {(agent_row.get('name') or inv.get('hostname') or 'unknown')} uses "
                f"{primary.get('name') or 'unknown AV'} ({primary.get('engine') or 'none'}); "
                f"software inventory {(software.get('count') if isinstance(software, dict) else 0)} programs. "
                "Box watches LAN; agent watches endpoint via installed AV."
            ),
        }
        with ENDPOINT_LEARN.open("a", encoding="utf-8") as fh:
            fh.write(json.dumps(lesson, ensure_ascii=False) + "\n")
        # Keep a rolling summary on state for guardian
        st = _load_state()
        lessons = list(st.get("endpoint_lessons") or [])
        lessons.insert(0, lesson)
        st["endpoint_lessons"] = lessons[:80]
        # Light bump to site learning when endpoints report
        pct = float(st.get("learning_pct") or 0)
        if pct < 100:
            st["learning_pct"] = min(100.0, pct + 0.15)
        _save_state(st)
    except Exception as exc:
        print(f"portal: endpoint learn skip: {exc}", flush=True)


def _db() -> sqlite3.Connection:
    DATA.mkdir(parents=True, exist_ok=True)
    con = sqlite3.connect(str(DB), timeout=30)
    con.row_factory = sqlite3.Row
    con.executescript(
        """
        CREATE TABLE IF NOT EXISTS users (
          id TEXT PRIMARY KEY,
          email TEXT NOT NULL UNIQUE,
          name TEXT NOT NULL DEFAULT '',
          pass_hash TEXT NOT NULL,
          pass_salt TEXT NOT NULL,
          created_at REAL NOT NULL
        );
        CREATE TABLE IF NOT EXISTS decisions (
          finding_key TEXT PRIMARY KEY,
          decision TEXT NOT NULL,
          detail TEXT NOT NULL DEFAULT '',
          updated_at REAL NOT NULL
        );
        """
    )
    con.commit()
    return con


def _hash_pw(password: str, salt: str) -> str:
    return hashlib.pbkdf2_hmac(
        "sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000
    ).hex()


def _user_count() -> int:
    with _db() as con:
        return int(con.execute("SELECT COUNT(*) FROM users").fetchone()[0])


def _get_user(email: str):
    with _db() as con:
        return con.execute(
            "SELECT * FROM users WHERE email=?", (email.strip().casefold(),)
        ).fetchone()


def _atlas_register_manager(email: str, password: str, name: str) -> dict:
    """After first portal account: also create Atlas collab manager + employee file under business folder."""
    import json
    import urllib.request

    cust = _customer_info()
    business = (cust.get("business_name") or "").strip()
    payload = {
        "email": email,
        "password": password,
        "name": name,
        "role": "manager",
        "business_name": business,
        "source": "portal_first_register",
        "hostname": socket.gethostname(),
    }
    funnel = (os.environ.get("ATLAS_FUNNEL") or _atlas_base()).rstrip("/")
    try:
        req = urllib.request.Request(
            f"{funnel}/api/cyber-protect/auth/setup-account",
            data=json.dumps(payload).encode("utf-8"),
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        ctx = None
        try:
            import ssl

            ctx = ssl._create_unverified_context()
        except Exception:
            pass
        with urllib.request.urlopen(req, context=ctx, timeout=25) as resp:
            return json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
    except Exception as exc:
        return {"ok": False, "error": str(exc)[:200]}


def _create_user(email: str, password: str, name: str) -> dict:
    email = email.strip().casefold()
    if "@" not in email or len(password) < 8:
        raise ValueError("Valid email and password (8+ chars) required")
    if _get_user(email):
        raise ValueError("Account already exists — log in")
    salt = secrets.token_hex(16)
    uid = "u_" + secrets.token_hex(8)
    with _db() as con:
        if int(con.execute("SELECT COUNT(*) FROM users").fetchone()[0]) > 0:
            raise ValueError("This Protect box already has an account — log in")
        con.execute(
            "INSERT INTO users(id,email,name,pass_hash,pass_salt,created_at) VALUES (?,?,?,?,?,?)",
            (uid, email, name.strip()[:80], _hash_pw(password, salt), salt, time.time()),
        )
        con.commit()
    user = {"id": uid, "email": email, "name": name.strip()[:80]}
    # First portal account = first manager — sync to Atlas company folder
    try:
        atlas = _atlas_register_manager(email, password, name.strip()[:80])
        user["atlas_collab"] = atlas
    except Exception as exc:
        user["atlas_collab"] = {"ok": False, "error": str(exc)[:120]}
    return user


class LoginError(Exception):
    """User-facing login failure (wrong password vs Atlas unreachable)."""

    def __init__(self, message: str, *, status: int = 401):
        super().__init__(message)
        self.status = status


def _try_owner_login(email: str, password: str) -> dict | None:
    """Master FN.nology owner can open any Protect box via Atlas credentials."""
    email = (email or "").strip().casefold()
    owner_email = "fn.nology@gmail.com"
    try:
        from share_policy import FACTORY_OWNER_EMAIL

        owner_email = FACTORY_OWNER_EMAIL.strip().casefold() or owner_email
    except Exception:
        pass
    if email != owner_email:
        return None
    atlas = _atlas_base()
    kit = (_load_env().get("FIELD_KIT_KEY") or os.environ.get("FIELD_KIT_KEY") or "").strip()
    payload = {
        "email": email,
        "password": password,
        "field_kit_key": kit,
    }
    try:
        import ssl
        import urllib.error
        import urllib.request

        data = json.dumps(payload).encode()
        req = urllib.request.Request(
            f"{atlas}/api/cyber-protect/owner-login",
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        with urllib.request.urlopen(req, timeout=45, context=ctx) as resp:
            out = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
        if out.get("ok") and out.get("master"):
            return {
                "id": "owner_master",
                "email": out.get("email") or email,
                "name": out.get("name") or "FN.nology owner",
                "master": True,
            }
        return None
    except urllib.error.HTTPError as exc:
        detail = ""
        try:
            detail = (exc.read() or b"").decode("utf-8", errors="replace")[:200]
        except Exception:
            detail = ""
        print(f"owner-login HTTP {exc.code}: {detail}", flush=True)
        # Wrong password / not owner → fall through to local accounts
        if exc.code in (401, 403):
            return None
        raise LoginError(
            "Atlas could not verify the owner login right now. Try again in a moment.",
            status=503,
        ) from exc
    except Exception as exc:
        print(f"owner-login failed: {exc}", flush=True)
        raise LoginError(
            "Cannot reach Atlas to verify FN.nology owner login. "
            "Use the Protect HTTPS link (…ts.net) and check internet on this box.",
            status=503,
        ) from exc


def _check_login(email: str, password: str):
    # Master owner first — works on every Protect box
    owner = _try_owner_login(email, password)
    if owner:
        return owner
    row = _get_user(email)
    if not row:
        return None
    if not hmac.compare_digest(row["pass_hash"], _hash_pw(password, row["pass_salt"])):
        return None
    # NOTE: we deliberately do NOT push this password to the Atlas collab
    # account. An earlier auto-sync overwrote a user's funnel password on every
    # box login, which caused "sign in rejected" in the agent app (the owner
    # reported the box changing their password without consent). Passwords are
    # only ever changed by the user (agent app / forgot-password) or by an
    # explicit manager/owner reset. The funnel collab account stays the source
    # of truth.
    return dict(row)


def _verify_collab_login(email: str, password: str) -> dict | None:
    """Verify an Atlas collab manager login against the funnel. Returns the
    collab person + token when the account exists and is approved."""
    try:
        import ssl
        import urllib.request

        payload = json.dumps({"email": email, "password": password}).encode()
        req = urllib.request.Request(
            f"{_atlas_base().rstrip('/')}/api/cyber-protect/auth/login",
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        with urllib.request.urlopen(req, timeout=25, context=ctx) as resp:
            out = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
        if not out.get("ok") or not out.get("token"):
            return None
        person = out.get("person") or {}
        return {
            "token": out.get("token"),
            "person": person,
            "access": out.get("access") or {},
            "role": str(person.get("role") or "employee"),
        }
    except Exception as exc:
        print(f"collab verify failed: {exc}", flush=True)
        return None


def _new_session(user: dict) -> str:
    tok = secrets.token_urlsafe(32)
    SESSIONS[tok] = {
        "user_id": user["id"],
        "email": user["email"],
        "name": user.get("name") or "",
        "exp": time.time() + SESS_TTL,
    }
    _sessions_save()
    return tok


def _session(handler: BaseHTTPRequestHandler):
    cookie = handler.headers.get("Cookie") or ""
    for part in cookie.split(";"):
        part = part.strip()
        if part.startswith("acp_sess="):
            tok = part.split("=", 1)[1].strip()
            sess = SESSIONS.get(tok)
            if sess and float(sess.get("exp") or 0) > time.time():
                return tok, sess
    return None, None


def _load_state() -> dict:
    if STATE.is_file():
        try:
            return json.loads(STATE.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {}


def _link_guard_hits() -> list:
    """Hits live in a separate file so agent heartbeats cannot wipe them."""
    try:
        import link_guard as lg

        return list(lg.load_hits(50))
    except Exception:
        return list((_load_state().get("link_guard_hits") or []))


def _remote_install_configured() -> bool:
    try:
        import remote_install as ri

        c = ri.load_creds()
        return bool(c.get("username") and c.get("password"))
    except Exception:
        return False


def _public_invites() -> list:
    try:
        import remote_install as ri

        rows = []
        for r in ri.load_invites()[:30]:
            rows.append(
                {
                    "token": r.get("token"),
                    "ip": r.get("ip"),
                    "mac": r.get("mac"),
                    "hostname": r.get("hostname"),
                    "status": r.get("status"),
                    "created_at": r.get("created_at"),
                    "delivery": r.get("delivery"),
                    "delivery_detail": r.get("delivery_detail"),
                    "install_url": f"/install/{r.get('token')}",
                }
            )
        return rows
    except Exception:
        return []


def _lan_ip_guess() -> str:
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("1.1.1.1", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "192.168.111.141"


# --------------------------------------------------------------------------
# Atlas Remote relay — frames from agents, input queue for managers.
# The agent posts its latest screen frame here; the manager's live-view
# fetches it. Manager input events are queued per agent and drained by the
# agent's RemoteLoop. All in-memory (frames are transient by nature).
# --------------------------------------------------------------------------
REMOTE_FRAMES: dict[str, dict] = {}   # agent_id -> {"png": bytes, "w": int, "h": int, "rw": int, "rh": int, "ts": float}
REMOTE_INPUTS: dict[str, list] = {}   # agent_id -> [input events]
REMOTE_LOCK = threading.Lock()
REMOTE_TTL = 60  # drop stale frames after 60s


def _remote_auth(self, agent_id: str) -> bool:
    """Agent auth: the posted agent token must match the paired agent row."""
    import hmac

    tok = str(self.headers.get("X-Agent-Token") or "").strip()
    if not tok or not agent_id:
        return False
    try:
        row = _get_agent(agent_id)
        expected = str((row or {}).get("agent_token") or "")
        return bool(expected) and hmac.compare_digest(tok, expected)
    except Exception:
        return False


def _remote_cleanup() -> None:
    now = time.time()
    for aid in [a for a, f in REMOTE_FRAMES.items() if now - float(f.get("ts") or 0) > REMOTE_TTL]:
        REMOTE_FRAMES.pop(aid, None)


def _load_agent_manifest() -> dict:
    AGENT_RELEASES.mkdir(parents=True, exist_ok=True)
    if AGENT_MANIFEST.is_file():
        try:
            return json.loads(AGENT_MANIFEST.read_text(encoding="utf-8"))
        except Exception:
            pass
    # Default empty channel — publish a build to enable auto-update
    return {
        "ok": True,
        "product": "atlas-cyber-protect-agent",
        "version": "0.2.0",
        "channel": "stable",
        "sha256": "",
        "url": "",
        "changelog": "Initial observe-only agent channel. Drop a signed EXE + manifest to push features.",
        "mandatory": False,
        "safety": {
            "mode": "observe_report_only",
            "never_delete_files": True,
            "never_block_network": True,
        },
    }


def _agent_update_available(client_version: str) -> dict:
    man = _load_agent_manifest()
    remote = str(man.get("version") or "")
    if not man.get("sha256") or not man.get("url"):
        return {"available": False, "version": remote}
    try:
        def parts(v: str):
            out = []
            for p in (v or "0").split("."):
                out.append(int("".join(c for c in p if c.isdigit()) or "0"))
            return out

        available = parts(remote) > parts(client_version or "0")
    except Exception:
        available = False
    fname = str(man.get("filename") or "")
    url = str(man.get("url") or "")
    if fname and not url:
        url = f"/api/companion/agent/download/{fname}"
    return {
        "available": bool(available),
        "version": remote,
        "changelog": man.get("changelog") or "",
        "channel": man.get("channel") or "stable",
        "filename": fname,
        "url": url,
        "sha256": man.get("sha256") or "",
        "silent": True,
    }


def _save_state(st: dict) -> None:
    DATA.mkdir(parents=True, exist_ok=True)
    STATE.write_text(json.dumps(st), encoding="utf-8")


def _decided_keys() -> dict[str, str]:
    out: dict[str, str] = {}
    with _db() as con:
        for row in con.execute("SELECT finding_key, decision FROM decisions"):
            out[str(row["finding_key"])] = str(row["decision"])
    st = _load_state()
    for k, v in (st.get("decisions") or {}).items():
        if isinstance(v, dict):
            out.setdefault(k, str(v.get("decision") or ""))
        else:
            out.setdefault(k, str(v))
    return out


def _status_payload(sess: dict | None) -> dict:
    st = _load_state()
    try:
        import ui_guardian

        ui_guardian.live_feed(st)
        _save_state(st)
    except Exception:
        pass
    locked = LOCK_FLAG.is_file() or bool(st.get("subscription_locked"))
    pct = float(st.get("learning_pct") or 0)
    devices = st.get("last_devices") or []
    decided = _decided_keys()
    findings = [
        f
        for f in (st.get("open_findings") or [])
        if (f.get("key") or "") not in decided
    ]
    jobs = st.get("jobs") or [
        {"id": "learn", "label": "Learning normal traffic", "pct": pct},
        {"id": "scan", "label": "Checking devices on your network", "pct": min(100, pct + 5)},
    ]
    if findings:
        jobs = list(jobs) + [
            {"id": "inspect", "label": "Inspecting issues for your safety", "pct": 55}
        ]
    needs = []
    for f in findings:
        key = f.get("key") or f.get("id") or f.get("plain_tip", "")[:40]
        needs.append(
            {
                "key": key,
                "title": f.get("title") or f.get("plain_tip") or "Something needs a look",
                "detail": f.get("plain_tip") or f.get("tech_detail") or "",
                "state": f.get("customer_state") or "checked",
                "category": f.get("category") or "security",
                "severity": f.get("severity") or ("high" if f.get("category") == "threat" else "low"),
            }
        )
    pulse = st.get("guardian_pulse") or {
        "score": 50,
        "label": "Starting",
        "tone": "sky",
        "devices": len(devices) if isinstance(devices, list) else 0,
        "reachable": 0,
        "open_issues": len(needs),
        "learning_pct": pct,
        "internet_ok": bool(st.get("internet_ok", True)),
    }
    headline = "Network protected"
    sub = st.get("guardian_pulse", {}).get("label") or ""
    if locked:
        headline = "Subscription paused"
        sub = "Contact FN.nology to restore Atlas Cyber Protect."
    elif needs:
        headline = "Network protected"
        sub = "Issues found — being inspected for future fixes for your safety"
    elif not locked:
        headline = "Network protected"
        sub = sub or "Guardian is live on this Protect box"

    authed = bool(sess)
    collab = None
    if sess and sess.get("collab"):
        collab = {
            "role": sess.get("collab_role") or "manager",
            "token": sess.get("collab_token") or "",
            "person_id": sess.get("collab_person_id") or "",
        }
    return {
        "ok": True,
        "locked": locked,
        "authenticated": authed,
        "needs_register": _user_count() == 0,
        "user": {"email": sess.get("email"), "name": sess.get("name")} if sess else None,
        "collab": collab,
        "headline": headline,
        "subline": sub,
        "learning_pct": pct,
        "learning_state": st.get("learning_state") or "learning",
        "device_count": len(devices) if isinstance(devices, list) else int(st.get("device_count") or 0),
        "devices": (devices if isinstance(devices, list) else []) if authed else [],
        "scan_meta": (st.get("scan_meta") or {}) if authed else {},
        "jobs": jobs if authed else [],
        "needs_decision": needs if (authed and not locked) else [],
        "activity": (st.get("activity") or []) if authed else [],
        "internet_ok": bool(st.get("internet_ok", True)),
        "pulse": pulse if authed else {},
        "shields": (st.get("guardian_shields") or []) if authed else [],
        "guardian_feed": (st.get("guardian_feed") or [])[:20] if authed else [],
        "network_health": (st.get("network_health") or {}) if authed else {},
        "services_summary": (st.get("services_summary") or []) if authed else [],
        "device_events": (st.get("device_events") or [])[:30] if authed else [],
        "capabilities": (st.get("capabilities") or []) if authed else [],
        "threat_summary": (st.get("threat_summary") or {}) if authed else {},
        "companion_agents": (
            [_companion_public(a) for a in list(_agents().values())]
            if authed
            else []
        ),
        "endpoint_lessons": (st.get("endpoint_lessons") or [])[:12] if authed else [],
        "link_guard_hits": (_link_guard_hits()[:12] if authed else []),
        "pc_agent_download": "https://atlas-server.taile9cc75.ts.net/releases/public/windows/AtlasCyberProtectAgent-Setup-latest.exe",
        "agent_latest_version": str((_load_agent_manifest() or {}).get("version") or ""),
        "customer": _customer_info() if authed else {},
        "install_invites": (_public_invites() if authed else []),
        "remote_install_configured": _remote_install_configured() if authed else False,
        "gateway_secure": (_gateway_secure_summary() if authed else {}),
        "portal_version": str(st.get("portal_version") or "—"),
        "portal_update": st.get("portal_update_info") or {},
        "server_time": time.time(),
    }


def _gateway_secure_summary() -> dict:
    try:
        import infra_secure as infra

        st = infra.public_status()
        inv = infra.inventory_public()
        st["inventory"] = {
            "summary": inv.get("summary") or {},
            "count": len(inv.get("items") or []),
            "last_scan_at": inv.get("checked_at") or "",
            "needs_scan": bool(inv.get("needs_scan")),
        }
        # Preview tips from last full scan cache if present
        tips = []
        for it in (inv.get("items") or [])[:4]:
            for t in (it.get("tips") or [])[:1]:
                tips.append(t)
        st["tips_preview"] = tips[:6]
        return st
    except Exception as exc:
        return {"ok": False, "configured": False, "error": str(exc)[:160]}


class Handler(BaseHTTPRequestHandler):
    server_version = "AtlasCyberProtect/2.0"

    def log_message(self, fmt: str, *args) -> None:
        print(f"portal: {fmt % args}", flush=True)

    def _request_https(self) -> bool:
        xf = (self.headers.get("X-Forwarded-Proto") or self.headers.get("x-forwarded-proto") or "").lower()
        if xf == "https":
            return True
        host = (self.headers.get("Host") or "").split(":")[0].lower()
        return host.endswith(".ts.net")

    def _json(self, code: int, payload: dict, *, set_cookie: str | None = None) -> None:
        body = json.dumps(payload).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(body.__len__()))
        self.send_header("Cache-Control", "no-store")
        if set_cookie is not None:
            flags = f"Path=/; HttpOnly; SameSite=Lax; Max-Age={SESS_TTL}"
            if self._request_https():
                flags += "; Secure"
            if set_cookie == "":
                self.send_header("Set-Cookie", f"acp_sess=; {flags}")
            else:
                self.send_header("Set-Cookie", f"acp_sess={set_cookie}; {flags}")
        self.end_headers()
        self.wfile.write(body)

    def _html(self, path: Path) -> None:
        data = path.read_bytes() if path.is_file() else b"<h1>Portal missing</h1>"
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(data)

    def _read_json(self) -> dict:
        n = int(self.headers.get("Content-Length") or 0)
        raw = self.rfile.read(n) if n else b"{}"
        try:
            return json.loads(raw.decode() or "{}")
        except Exception:
            return {}

    def _read_raw(self) -> bytes:
        n = int(self.headers.get("Content-Length") or 0)
        return self.rfile.read(n) if n else b""

    def do_GET(self) -> None:  # noqa: N802
        path = urlparse(self.path).path
        if path in {"/", "/index.html", "/login", "/app"}:
            self._html(PORTAL_HTML)
            return
        if path in {"/atlas-cyber-protect-guide.json", "/api/guide"}:
            guide = ROOT / "atlas-cyber-protect-guide.json"
            if not guide.is_file():
                self.send_error(404)
                return
            data = guide.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.send_header("Cache-Control", "no-cache")
            self.end_headers()
            self.wfile.write(data)
            return
        if path.startswith("/device-icons/") and path.count("/") == 2:
            name = path.rsplit("/", 1)[-1]
            if re.fullmatch(r"[a-z0-9_-]+\.svg", name or ""):
                icon = ICONS_DIR / name
                if icon.is_file():
                    data = icon.read_bytes()
                    self.send_response(200)
                    self.send_header("Content-Type", "image/svg+xml")
                    self.send_header("Content-Length", str(len(data)))
                    self.send_header("Cache-Control", "public, max-age=86400")
                    self.end_headers()
                    self.wfile.write(data)
                    return
            self.send_error(404)
            return
        if path == "/api/companion/discover":
            # Public LAN discovery for Protect Agent / AV companion apps
            st = _load_state()
            sm = st.get("scan_meta") or {}
            lan = sm.get("self_ip") or ""
            if not lan:
                try:
                    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                    s.connect(("1.1.1.1", 80))
                    lan = s.getsockname()[0]
                    s.close()
                except Exception:
                    lan = ""
            cust = _customer_info()
            # Public funnel front so paired PC agents can reach this box from
            # anywhere without Tailscale VPN (https://acp-<host>.<tailnet>.ts.net)
            try:
                ts_host = _load_env().get("TS_HOSTNAME") or f"acp-{socket.gethostname().split('.')[0]}"
                funnel_url = f"https://{ts_host}.taile9cc75.ts.net"
            except Exception:
                funnel_url = ""
            self._json(
                200,
                {
                    "ok": True,
                    "product": "atlas-cyber-protect",
                    "role": "lan-sidebox",
                    "hostname": socket.gethostname(),
                    "lan_ip": lan,
                    "portal_port": PORT,
                    "funnel_url": funnel_url,
                    "business_name": cust.get("business_name") or "",
                    "site_name": cust.get("site_name") or "",
                    "company_id": (cust.get("company_id") or os.environ.get("COMPANY_ID") or "").strip(),
                    "device_count": len(st.get("last_devices") or []),
                    "threat_summary": st.get("threat_summary") or {},
                    "agent_policy": {
                        "mode": "observe_report_only",
                        "never_delete_files": True,
                        "never_block_network": True,
                        "never_modify_firewall": True,
                        "may_request_av_scan": True,
                        "link_guard": True,
                        "warn_dangerous_links": True,
                    },
                    "pair": "/api/companion/pair",
                    "heartbeat": "/api/companion/heartbeat",
                    "update": "/api/companion/agent/update",
                    "link_check": "/api/companion/link-check",
                },
            )
            return
        if path == "/api/companion/agent/update":
            man = _load_agent_manifest()
            man = dict(man)
            man["ok"] = True
            # Always expose relative download URL if file name present
            fname = str(man.get("filename") or "")
            if fname and not man.get("url"):
                man["url"] = f"/api/companion/agent/download/{fname}"
            elif man.get("url") and not str(man.get("url")).startswith("/"):
                pass
            elif fname:
                man["url"] = f"/api/companion/agent/download/{fname}"
            self._json(200, man)
            return
        if path.startswith("/api/companion/agent/download/"):
            name = path.rsplit("/", 1)[-1]
            if not re.fullmatch(r"AtlasCyberProtectAgent-[0-9A-Za-z.\-]+\.exe", name or ""):
                self.send_error(404)
                return
            fpath = AGENT_RELEASES / name
            if not fpath.is_file():
                self.send_error(404)
                return
            data = fpath.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "application/octet-stream")
            self.send_header("Content-Length", str(len(data)))
            self.send_header("Content-Disposition", f'attachment; filename="{name}"')
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(data)
            return
        if path.startswith("/api/companion/remote/frame/"):
            # Manager live-view: latest screen frame for an agent (session).
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            agent_id = path.rsplit("/", 1)[-1]
            with REMOTE_LOCK:
                _remote_cleanup()
                frame = REMOTE_FRAMES.get(agent_id) or {}
            if not frame.get("png"):
                self._json(404, {"ok": False, "error": "no frame yet"})
                return
            self.send_response(200)
            self.send_header("Content-Type", "image/png")
            self.send_header("Cache-Control", "no-store")
            self.send_header("X-Frame-Size", f"{frame.get('w')}x{frame.get('h')}")
            self.send_header("X-Remote-Size", f"{frame.get('rw')}x{frame.get('rh')}")
            self.send_header("Content-Length", str(len(frame["png"])))
            self.end_headers()
            self.wfile.write(frame["png"])
            return
        if path.startswith("/api/companion/remote/input/"):
            # Agent drains its queued input events (agent token auth).
            agent_id = path.rsplit("/", 1)[-1]
            if not _remote_auth(self, agent_id):
                self._json(401, {"ok": False, "error": "agent auth required"})
                return
            with REMOTE_LOCK:
                q = REMOTE_INPUTS.pop(agent_id, None) or []
            self._json(200, q)
            return
        if path == "/api/companion/agents":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            agents = list(_agents().values())
            agents.sort(key=lambda a: float(a.get("last_seen") or 0), reverse=True)
            self._json(200, {"ok": True, "agents": [_companion_public(a) for a in agents], "count": len(agents)})
            return
        if path == "/api/companion/chat-archive":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            self._json(200, _chat_archive_index())
            return
        if path.startswith("/api/companion/chat-archive/media/"):
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            fname = path.rsplit("/", 1)[-1]
            if not re.fullmatch(r"[A-Za-z0-9_.-]+", fname or ""):
                self.send_error(404)
                return
            fpath = DATA / "chat-archive" / "media" / fname
            if not fpath.is_file():
                fpath = DATA / "chat-archive" / "recordings" / fname
            if not fpath.is_file():
                self.send_error(404)
                return
            low = fname.lower()
            ctype = (
                "image/png"
                if low.endswith(".png")
                else "image/jpeg"
                if low.endswith((".jpg", ".jpeg"))
                else "audio/webm"
                if low.endswith(".webm")
                else "application/octet-stream"
            )
            data = fpath.read_bytes()
            self.send_response(200)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(data)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(data)
            return
        # Install invite pages (public — opened on the target PC)
        if path.startswith("/install/") or path.startswith("/api/companion/invite/"):
            parts = [p for p in path.split("/") if p]
            token = ""
            action = ""
            if path.startswith("/install/") and len(parts) >= 2:
                token = parts[1]
                action = "page"
            elif len(parts) >= 4 and parts[0] == "api":
                token = parts[3]
                action = parts[4] if len(parts) > 4 else "meta"
            if not re.fullmatch(r"[A-Za-z0-9_\-]{8,80}", token or ""):
                self.send_error(404)
                return
            try:
                import remote_install as ri
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)})
                return
            inv = ri.get_invite(token)
            if not inv:
                self.send_error(404)
                return
            base = f"http://{_lan_ip_guess()}:{PORT}"
            meta = ri.installer_meta()
            if action in {"page", ""}:
                html = f"""<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Install Atlas Cyber Protect</title>
<style>body{{font-family:Georgia,serif;background:#0b1220;color:#e8f4ff;margin:0;padding:2rem;line-height:1.5}}
a.btn,button{{display:inline-block;background:#3dff9a;color:#04140c;font-weight:700;border:0;border-radius:10px;padding:12px 18px;text-decoration:none;cursor:pointer;font-size:1rem}}
.muted{{color:#8aa0b8}}.card{{max-width:520px;margin:0 auto;padding:1.5rem;border:1px solid #1e3a5f;background:#0f1a2e;border-radius:14px}}
h1{{color:#7CFFB2;font-size:1.4rem}}</style></head><body><div class=card>
<h1>Install PC Agent</h1>
<p>Protect invites <strong>{inv.get('hostname') or inv.get('ip') or 'this PC'}</strong> to install Atlas Cyber Protect Agent {meta.get('version') or ''}.</p>
<p class=muted>Warn-only: antivirus + email/link warnings. Never deletes files or blocks internet.</p>
<p><a class=btn href="/api/companion/invite/{token}/download">Download &amp; install</a></p>
<p class=muted>Or open the desktop prompt if it already appeared.</p>
</div>
<script>fetch('/api/companion/invite/{token}/accept',{{method:'POST'}}).catch(()=>{{}})</script>
</body></html>"""
                data = html.encode()
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.send_header("Content-Length", str(len(data)))
                self.end_headers()
                self.wfile.write(data)
                return
            if action == "prompt.hta":
                hta = ri.prompt_hta(token, base, {**meta, "hostname": inv.get("hostname") or inv.get("ip")})
                data = hta.encode("utf-8", errors="replace")
                self.send_response(200)
                self.send_header("Content-Type", "application/hta")
                self.send_header("Content-Disposition", f'attachment; filename="AtlasProtectInvite.hta"')
                self.send_header("Content-Length", str(len(data)))
                self.end_headers()
                self.wfile.write(data)
                return
            if action == "download":
                fpath = Path(meta.get("path") or "")
                if not fpath.is_file():
                    self.send_error(404)
                    return
                ri.update_invite(token, status="downloading")
                data = fpath.read_bytes()
                name = meta.get("filename") or fpath.name
                self.send_response(200)
                self.send_header("Content-Type", "application/octet-stream")
                self.send_header("Content-Length", str(len(data)))
                self.send_header("Content-Disposition", f'attachment; filename="{name}"')
                self.end_headers()
                self.wfile.write(data)
                return
            if action == "meta":
                self._json(200, {"ok": True, "invite": inv, "installer": meta})
                return
            self.send_error(404)
            return
        if path == "/api/status":
            _, sess = _session(self)
            self._json(200, _status_payload(sess))
            return
        if path == "/api/me":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            self._json(200, {"ok": True, "user": sess})
            return
        if path == "/api/people/lan-pending":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            self._json(200, {"ok": True, **_lan_pending()})
            return
        if path == "/api/portal/version":
            # Public: which room UI version this box runs + Atlas-managed status
            st = _load_state()
            self._json(
                200,
                {
                    "ok": True,
                    "version": str(st.get("portal_version") or "—"),
                    "updates_from_atlas": True,
                    "update": st.get("portal_update_info") or {},
                },
            )
            return
        if path == "/api/gateway/creds":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import gateway_secure as gs

                self._json(200, gs.public_status())
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)[:200]})
            return
        if path == "/api/gateway/read":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import gateway_secure as gs

                self._json(200, gs.read_gateway(force=True))
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)[:200]})
            return
        if path == "/api/infra/inventory":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import infra_secure as infra

                self._json(200, infra.inventory_public())
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)[:200]})
            return
        if path == "/api/infra/creds":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import infra_secure as infra

                self._json(200, infra.public_status())
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)[:200]})
            return
        self.send_error(404)

    def do_POST(self) -> None:  # noqa: N802
        path = urlparse(self.path).path
        if path.startswith("/api/companion/remote/frame/"):
            # Agent posts its latest screen frame (raw PNG body, agent auth).
            agent_id = path.rsplit("/", 1)[-1]
            if not _remote_auth(self, agent_id):
                self._json(401, {"ok": False, "error": "agent auth required"})
                return
            png = self._read_raw()
            if not png:
                self._json(400, {"ok": False, "error": "empty frame"})
                return
            size = str(self.headers.get("X-Frame-Size") or "0x0")
            w, _, h = size.partition("x")
            try:
                w, h = int(w), int(h)
            except Exception:
                w, h = 0, 0
            rsize = str(self.headers.get("X-Remote-Size") or "0x0")
            rw, _, rh = rsize.partition("x")
            try:
                rw, rh = int(rw), int(rh)
            except Exception:
                rw, rh = w, h
            with REMOTE_LOCK:
                REMOTE_FRAMES[agent_id] = {"png": png, "w": w, "h": h, "rw": rw, "rh": rh, "ts": time.time()}
            self._json(200, {"ok": True})
            return
        if path.startswith("/api/companion/remote/input/"):
            # Manager posts input events for an agent (session auth).
            agent_id = path.rsplit("/", 1)[-1]
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            evs = body_json(self)
            if not isinstance(evs, list):
                evs = [evs]
            with REMOTE_LOCK:
                q = REMOTE_INPUTS.setdefault(agent_id, [])
                q.extend(evs)
                REMOTE_INPUTS[agent_id] = q[-200:]
            self._json(200, {"ok": True, "queued": len(evs)})
            return
        body = self._read_json()
        if path == "/api/companion/report-issue":
            """Agent fix-report → relay to the Atlas Ideas Office (kind=fix)."""
            text = str(body.get("text") or "").strip()
            if len(text) < 3:
                self._json(400, {"ok": False, "error": "Describe the problem (3+ characters)"})
                return
            location = str(body.get("location") or "")[:300]
            url = str(body.get("url") or "")[:500]
            host = socket.gethostname()
            full = f"Agent fix report ({host}): {text}"
            import urllib.request

            funnel = (os.environ.get("ATLAS_FUNNEL") or _atlas_base()).rstrip("/")
            payload = {
                "text": full,
                "room": "atlas-protect",
                "url": url,
                "location": location,
            }
            try:
                req = urllib.request.Request(
                    f"{funnel}/api/cyber-protect/auth/report-issue",
                    data=json.dumps(payload).encode("utf-8"),
                    headers={"Content-Type": "application/json"},
                    method="POST",
                )
                ctx = None
                try:
                    import ssl

                    ctx = ssl._create_unverified_context()
                except Exception:
                    pass
                with urllib.request.urlopen(req, context=ctx, timeout=25) as resp:
                    out = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
                self._json(200, out)
            except Exception as exc:
                self._json(502, {"ok": False, "error": f"Ideas Office unreachable: {str(exc)[:160]}"})
            return
        if path == "/api/companion/link-check":
            # Public to paired agents (token optional but preferred); warn-only
            try:
                import link_guard as lg
            except Exception as exc:
                self._json(500, {"ok": False, "error": f"link_guard missing: {exc}"})
                return
            text = str(body.get("text") or "")
            urls = body.get("urls") if isinstance(body.get("urls"), list) else []
            source = str(body.get("source") or "agent")[:40]
            agent_id = str(body.get("agent_id") or "")[:80]
            if text.strip():
                out = lg.check_text(text, source=source)
            elif urls:
                out = lg.check_urls([str(u) for u in urls], source=source)
            else:
                self._json(400, {"ok": False, "error": "text or urls required"})
                return
            out["agent_id"] = agent_id
            # Surface warnings in dedicated hits file + activity (agent must not wipe hits)
            if int(out.get("danger_count") or 0) or int(out.get("warn_count") or 0):
                hosts = [
                    r.get("host")
                    for r in (out.get("results") or [])
                    if isinstance(r, dict) and r.get("warn")
                ][:6]
                hit = {
                    "ts": time.time(),
                    "source": source,
                    "agent_id": agent_id,
                    "danger": out.get("danger_count"),
                    "warn": out.get("warn_count"),
                    "hosts": hosts,
                }
                try:
                    lg.record_hit(hit)
                except Exception:
                    pass
                st = _load_state()
                act = list(st.get("activity") or [])
                act.insert(
                    0,
                    {
                        "ts": time.time(),
                        "plain": (
                            f"Link guard ({source}"
                            + (f"/{agent_id}" if agent_id else "")
                            + f"): {out.get('summary')} "
                            + (f"Hosts: {', '.join(h for h in hosts if h)}" if hosts else "")
                        )[:400],
                    },
                )
                st["activity"] = act[:40]
                _save_state(st)
            self._json(200, out)
            return
        if path == "/api/companion/pair":
            agent_id = str(body.get("agent_id") or "").strip()
            if not agent_id or len(agent_id) > 80:
                self._json(400, {"ok": False, "error": "agent_id required"})
                return
            # Reject agents that claim dangerous modes
            safety = body.get("safety") if isinstance(body.get("safety"), dict) else {}
            if safety.get("never_delete_files") is False or safety.get("mode") not in {
                "observe_report_only",
                None,
                "",
            }:
                # Only accept observe-only companions
                if safety and safety.get("mode") and safety.get("mode") != "observe_report_only":
                    self._json(403, {"ok": False, "error": "only observe_report_only agents allowed"})
                    return
            token = secrets.token_urlsafe(24)
            agents = dict(_agents())
            inv = body.get("inventory") if isinstance(body.get("inventory"), dict) else {}
            agents[agent_id] = {
                "agent_id": agent_id,
                "agent_token": token,
                "name": str(body.get("name") or inv.get("hostname") or agent_id)[:80],
                "inventory": inv,
                "safety": safety or {
                    "mode": "observe_report_only",
                    "never_delete_files": True,
                    "never_block_network": True,
                },
                "paired_at": time.time(),
                "last_seen": time.time(),
                "remote_addr": self.client_address[0] if self.client_address else "",
            }
            _save_agents(agents)
            st = _load_state()
            act = list(st.get("activity") or [])
            act.insert(
                0,
                {
                    "ts": time.time(),
                    "plain": f"Windows Protect Agent paired: {agents[agent_id]['name']} (observe-only).",
                },
            )
            st["activity"] = act[:40]
            st["companion_agents"] = agents
            _save_state(st)
            self._json(
                200,
                {
                    "ok": True,
                    "agent_id": agent_id,
                    "agent_token": token,
                    "policy": "observe_report_only",
                    "message": "Paired. Agent must never delete files or block PC network.",
                },
            )
            return
        if path == "/api/companion/heartbeat":
            agent_id = str(body.get("agent_id") or "").strip()
            token = str(body.get("agent_token") or "").strip()
            agents = dict(_agents())
            row = agents.get(agent_id) or {}
            if not row or not hmac.compare_digest(str(row.get("agent_token") or ""), token):
                self._json(403, {"ok": False, "error": "invalid agent credentials"})
                return
            inv = body.get("inventory") if isinstance(body.get("inventory"), dict) else {}
            row["inventory"] = inv or row.get("inventory") or {}
            row["last_seen"] = time.time()
            row["remote_addr"] = self.client_address[0] if self.client_address else row.get("remote_addr")
            if isinstance(body.get("safety"), dict):
                row["safety"] = body.get("safety")
            if isinstance(body.get("last_scan"), dict):
                row["last_scan"] = body.get("last_scan")
                row["inventory"]["last_scan"] = body.get("last_scan")
            # Consume pending commands for this agent (scan / refresh / update)
            pending = list(row.get("pending_commands") or [])
            send_cmds = pending[:3]
            row["pending_commands"] = pending[3:]
            st = _load_state()
            if isinstance(body.get("command_results"), list) and body.get("command_results"):
                row["last_command_results"] = body.get("command_results")[:8]
                act = list(st.get("activity") or [])
                for cr in body.get("command_results")[:3]:
                    if not isinstance(cr, dict):
                        continue
                    res = cr.get("result") if isinstance(cr.get("result"), dict) else {}
                    act.insert(
                        0,
                        {
                            "ts": time.time(),
                            "plain": (
                                f"PC {row.get('name')}: "
                                f"{res.get('name') or cr.get('type') or 'command'} — "
                                f"{str(res.get('note') or ('ok' if res.get('ok') else 'done'))[:160]}"
                            ),
                        },
                    )
                st["activity"] = act[:40]
            agents[agent_id] = row
            _save_agents(agents)
            st["companion_agents"] = agents
            _save_state(st)
            if inv:
                _learn_endpoint(row, inv)
            # Keep Atlas Connect PC list in sync (Funnel) so Connect finds this office PC
            try:
                _sync_atlas_connect_endpoint(agent_id, row)
            except Exception:
                pass
            self._json(
                200,
                {
                    "ok": True,
                    "agent_count": len(agents),
                    "policy": "observe_report_only",
                    "commands": send_cmds,
                    "update": _agent_update_available(
                        str(body.get("agent_version") or (inv.get("agent_version") if isinstance(inv, dict) else "") or "")
                    ),
                    "customer": _customer_info(),
                },
            )
            return
        if path.startswith("/api/companion/agents/") and (
            path.endswith("/scan") or path.endswith("/update")
        ):
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            parts = [p for p in path.split("/") if p]
            if len(parts) < 5:
                self._json(400, {"ok": False, "error": "agent id required"})
                return
            agent_id = parts[3]
            action = parts[4]
            agents = dict(_agents())
            row = agents.get(agent_id)
            if not row:
                self._json(404, {"ok": False, "error": "agent not found"})
                return
            cmds = list(row.get("pending_commands") or [])
            if action == "scan":
                cmds.append({"id": f"scan-{secrets.token_hex(4)}", "type": "quick_scan", "queued_at": time.time()})
                plain = f"Queued AV scan for PC agent {row.get('name') or agent_id}."
            else:
                cmds.append({"id": f"upd-{secrets.token_hex(4)}", "type": "force_update", "queued_at": time.time()})
                plain = f"Queued silent update for PC agent {row.get('name') or agent_id}."
            row["pending_commands"] = cmds[-8:]
            agents[agent_id] = row
            _save_agents(agents)
            st = _load_state()
            act = list(st.get("activity") or [])
            act.insert(0, {"ts": time.time(), "plain": plain})
            st["activity"] = act[:40]
            st["companion_agents"] = agents
            _save_state(st)
            self._json(200, {"ok": True, "agent_id": agent_id, "queued": True, "action": action, "pending": len(row["pending_commands"])})
            return
        if path == "/api/companion/invite":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import remote_install as ri
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)})
                return
            devices = body.get("devices") if isinstance(body.get("devices"), list) else []
            if not devices:
                self._json(400, {"ok": False, "error": "select at least one device"})
                return
            created = ri.create_invites(devices, created_by=str((sess or {}).get("email") or ""))
            base = f"http://{_lan_ip_guess()}:{PORT}"
            results = []
            for inv in created:
                delivery = ri.try_deliver_popup(inv, base)
                results.append(
                    {
                        "token": inv.get("token"),
                        "ip": inv.get("ip"),
                        "hostname": inv.get("hostname"),
                        "install_url": f"{base}/install/{inv.get('token')}",
                        "prompt_url": f"{base}/api/companion/invite/{inv.get('token')}/prompt.hta",
                        **delivery,
                    }
                )
            st = _load_state()
            act = list(st.get("activity") or [])
            act.insert(
                0,
                {
                    "ts": time.time(),
                    "plain": f"Install invite sent to {len(results)} PC(s). Users click Install on the popup or open the invite link.",
                },
            )
            st["activity"] = act[:40]
            _save_state(st)
            self._json(200, {"ok": True, "invites": results, "count": len(results)})
            return
        if path == "/api/portal/update":
            # In-room button: ask the box agent to pull the newest Atlas portal UI now.
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            st = _load_state()
            st["portal_update_requested"] = True
            _save_state(st)
            self._json(200, {"ok": True, "queued": True, "note": "Checking Atlas for a newer room UI…"})
            return

        if path == "/api/companion/remote-creds":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import remote_install as ri
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)})
                return
            if body.get("clear"):
                ri.clear_creds()
                self._json(200, {"ok": True, "configured": False})
                return
            user = str(body.get("username") or "").strip()
            password = str(body.get("password") or "")
            domain = str(body.get("domain") or "").strip()
            if not user or not password:
                self._json(400, {"ok": False, "error": "username and password required"})
                return
            ri.save_creds(user, password, domain)
            self._json(200, {"ok": True, "configured": True, "username": user, "domain": domain})
            return

        # —— Main gateway (router) login: info / read-only only ——
        if path == "/api/gateway/creds":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import gateway_secure as gs
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)})
                return
            if body.get("clear"):
                self._json(200, gs.clear_creds())
                return
            try:
                out = gs.save_creds(
                    username=str(body.get("username") or ""),
                    password=str(body.get("password") or ""),
                    gateway_ip=str(body.get("gateway_ip") or ""),
                    notes=str(body.get("notes") or ""),
                    keep_password=bool(body.get("keep_password")),
                )
                self._json(200, out)
            except ValueError as exc:
                self._json(400, {"ok": False, "error": str(exc)})
            return

        if path == "/api/gateway/read":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import gateway_secure as gs

                self._json(200, gs.read_gateway(force=bool(body.get("force", True))))
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)[:200]})
            return

        if path == "/api/gateway/mutate":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import gateway_secure as gs

                denied = gs.refuse_mutate(str(sess.get("email") or ""))
                self._json(403, denied)
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)[:200]})
            return

        if path == "/api/infra/creds":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import infra_secure as infra
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)})
                return
            if body.get("clear_all"):
                self._json(200, infra.clear_all_logins())
                return
            kind = str(body.get("kind") or "default").strip().lower()
            try:
                if kind == "device":
                    out = infra.save_device_login(
                        ip=str(body.get("ip") or ""),
                        username=str(body.get("username") or ""),
                        password=str(body.get("password") or ""),
                        notes=str(body.get("notes") or ""),
                        role=str(body.get("role") or ""),
                        keep_password=bool(body.get("keep_password")),
                        clear=bool(body.get("clear")),
                    )
                else:
                    out = infra.save_default_login(
                        username=str(body.get("username") or ""),
                        password=str(body.get("password") or ""),
                        notes=str(body.get("notes") or ""),
                        keep_password=bool(body.get("keep_password")),
                    )
                self._json(200, out)
            except ValueError as exc:
                self._json(400, {"ok": False, "error": str(exc)})
            return

        if path == "/api/infra/scan":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            try:
                import infra_secure as infra

                self._json(200, infra.scan_infrastructure(force=bool(body.get("force", True))))
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)[:200]})
            return

        if path.startswith("/api/companion/invite/") and path.endswith("/accept"):
            parts = [p for p in path.split("/") if p]
            token = parts[3] if len(parts) >= 5 else ""
            try:
                import remote_install as ri

                ri.update_invite(token, status="accepted", accepted_at=time.time())
            except Exception:
                pass
            self._json(200, {"ok": True})
            return
        if path == "/api/register":
            if LOCK_FLAG.is_file():
                self._json(403, {"ok": False, "error": "Subscription paused"})
                return
            try:
                user = _create_user(
                    str(body.get("email") or ""),
                    str(body.get("password") or ""),
                    str(body.get("name") or ""),
                )
            except ValueError as exc:
                self._json(400, {"ok": False, "error": str(exc)})
                return
            tok = _new_session(user)
            self._json(200, {"ok": True, "user": user}, set_cookie=tok)
            return
        if path == "/api/login":
            email = str(body.get("email") or "")
            password = str(body.get("password") or "")
            try:
                user = _check_login(email, password)
            except LoginError as exc:
                self._json(exc.status, {"ok": False, "error": str(exc)})
                return
            if not user:
                # Not a box-local account / not the master owner — try the Atlas
                # collab login (managers register in the app with the same email
                # + password; that account is not a box user). Managers get in,
                # employees are denied (users must not log into Atlas Protect).
                collab = _verify_collab_login(email, password)
                if not collab:
                    self._json(
                        401,
                        {
                            "ok": False,
                            "error": "Wrong email or password. Master owner must use fn.nology@gmail.com + Atlas password.",
                        },
                    )
                    return
                if str(collab.get("role") or "") != "manager":
                    self._json(
                        403,
                        {
                            "ok": False,
                            "error": "Only managers can open Atlas Protect — users use the installed agent app (chat + protection) and cannot log into Atlas Protect.",
                        },
                    )
                    return
                person = collab.get("person") or {}
                tok = _new_session(
                    {
                        "id": "collab_" + str(person.get("id") or email),
                        "email": email,
                        "name": str(person.get("name") or email),
                        "master": False,
                        "collab": True,
                        "collab_token": collab.get("token"),
                        "collab_role": "manager",
                        "collab_person_id": str(person.get("id") or ""),
                    }
                )
                self._json(
                    200,
                    {
                        "ok": True,
                        "user": {
                            "id": "collab_" + str(person.get("id") or email),
                            "email": email,
                            "name": str(person.get("name") or email),
                            "master": False,
                        },
                        "collab": {
                            "token": collab.get("token"),
                            "role": "manager",
                            "person": person,
                        },
                    },
                    set_cookie=tok,
                )
                return
            # Local customer accounts blocked when paused; master owner may still enter
            if LOCK_FLAG.is_file() and not user.get("master"):
                self._json(403, {"ok": False, "error": "Subscription paused"})
                return
            tok = _new_session(
                {
                    "id": user["id"],
                    "email": user["email"],
                    "name": user.get("name") or "",
                    "master": bool(user.get("master")),
                }
            )
            # The agent app signs in through this box as a fallback — return a
            # collab token too when the same credentials exist on Atlas (the
            # password was just re-synced above, so this succeeds for the box
            # owner account that was synced at registration).
            collab = _verify_collab_login(email, password)
            out = {
                "ok": True,
                "user": {
                    "id": user["id"],
                    "email": user["email"],
                    "name": user.get("name") or "",
                    "master": bool(user.get("master")),
                },
            }
            if collab and collab.get("token"):
                out["collab"] = {
                    "token": collab.get("token"),
                    "role": str(collab.get("role") or "employee"),
                    "person": collab.get("person") or {},
                }
            self._json(200, out, set_cookie=tok)
            return
        if path == "/api/cyber-protect/auth/login":
            # Same endpoint shape as the Atlas funnel, served by the box too —
            # the installed agent app tries the funnel first and falls back to
            # the box, so sign-in works even when only the box is reachable.
            email = str(body.get("email") or "")
            password = str(body.get("password") or "")
            if not email or not password:
                self._json(400, {"ok": False, "error": "email and password required"})
                return
            try:
                user = _check_login(email, password)
            except LoginError as exc:
                self._json(exc.status, {"ok": False, "error": str(exc)})
                return
            if not user:
                collab = _verify_collab_login(email, password)
                if not collab:
                    self._json(401, {"ok": False, "error": "invalid email or password"})
                    return
                person = collab.get("person") or {}
                self._json(
                    200,
                    {
                        "ok": True,
                        "token": collab.get("token"),
                        "person": person,
                        "access": collab.get("access") or {},
                    },
                )
                return
            collab = _verify_collab_login(email, password)
            if collab and collab.get("token"):
                self._json(
                    200,
                    {
                        "ok": True,
                        "token": collab.get("token"),
                        "person": collab.get("person") or {},
                        "access": collab.get("access") or {},
                    },
                )
                return
            self._json(401, {"ok": False, "error": "invalid email or password"})
            return
        if path == "/api/collab-token":
            # "Open Atlas Protect" from the agent app passes the collab token it
            # already got at sign-in — swap it for a box session so the manager
            # lands inside the portal without typing anything again.
            collab_token = str(body.get("token") or "").strip()
            role = str(body.get("role") or "").strip()
            if not collab_token:
                self._json(400, {"ok": False, "error": "collab token required"})
                return
            if role and role != "manager":
                self._json(403, {"ok": False, "error": "Only managers can open Atlas Protect — users cannot log into Atlas Protect."})
                return
            try:
                import ssl
                import urllib.request

                req = urllib.request.Request(
                    f"{_atlas_base().rstrip('/')}/api/cyber-protect/people/me",
                    headers={"Authorization": "Bearer " + collab_token, "Content-Type": "application/json"},
                    method="GET",
                )
                ctx = ssl.create_default_context()
                ctx.check_hostname = False
                ctx.verify_mode = ssl.CERT_NONE
                with urllib.request.urlopen(req, timeout=25, context=ctx) as resp:
                    me = json.loads(resp.read().decode("utf-8", errors="replace") or "{}")
            except urllib.error.HTTPError as exc:
                self._json(exc.code, {"ok": False, "error": "collab token invalid or expired — sign in again in the agent app"})
                return
            except Exception as exc:
                self._json(503, {"ok": False, "error": f"Atlas unreachable: {str(exc)[:120]}"})
                return
            person = (me or {}).get("person") or {}
            if str(person.get("role") or "") != "manager":
                self._json(403, {"ok": False, "error": "Only managers can open Atlas Protect — users cannot log into Atlas Protect."})
                return
            tok = _new_session(
                {
                    "id": "collab_" + str(person.get("id") or ""),
                    "email": str(person.get("email") or ""),
                    "name": str(person.get("name") or ""),
                    "master": False,
                    "collab": True,
                    "collab_token": collab_token,
                    "collab_role": "manager",
                    "collab_person_id": str(person.get("id") or ""),
                }
            )
            self._json(
                200,
                {
                    "ok": True,
                    "user": {
                        "id": "collab_" + str(person.get("id") or ""),
                        "email": str(person.get("email") or ""),
                        "name": str(person.get("name") or ""),
                        "master": False,
                    },
                    "collab": {"token": collab_token, "role": "manager", "person": person},
                },
                set_cookie=tok,
            )
            return
        if path == "/api/people/lan-approve":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            email = str(body.get("email") or "").strip().casefold()
            person_id = str(body.get("person_id") or "").strip()
            role = str(body.get("role") or "employee").strip()
            if not email and not person_id:
                self._json(400, {"ok": False, "error": "email required"})
                return
            try:
                out = _atlas_lan_approve(email, role=role, person_id=person_id)
            except Exception as exc:
                self._json(502, {"ok": False, "error": f"Atlas approve failed: {exc}"[:200]})
                return
            self._json(200, out if isinstance(out, dict) else {"ok": True, "result": out})
            return
        if path == "/api/tools/open":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            action = str(body.get("action") or body.get("kind") or "").strip().lower()
            kind = {
                "browser": "open_browser",
                "open_browser": "open_browser",
                "winbox": "open_winbox",
                "open_winbox": "open_winbox",
            }.get(action)
            if not kind:
                self._json(400, {"ok": False, "error": "action must be browser or winbox"})
                return
            url = str(body.get("url") or ("https://www.speedtest.net/" if kind == "open_browser" else ""))
            agent_id = str(body.get("agent_id") or "").strip()
            queued = _queue_tool_on_pcs(kind, url=url, agent_id=agent_id)
            local = {}
            try:
                from agent import _launch_local_tool

                local = _launch_local_tool(kind, {"url": url})
            except Exception as exc:
                local = {"ok": False, "error": str(exc)[:120]}
            self._json(
                200,
                {
                    "ok": True if queued or local.get("ok") else False,
                    "kind": kind,
                    "queued_pcs": queued,
                    "local": local,
                    "hint": "Opens on the office Windows Protect agent. Use Atlas Connect if you need to see the screen from overseas.",
                },
            )
            return
        if path == "/api/logout":
            tok, _ = _session(self)
            if tok:
                SESSIONS.pop(tok, None)
                _sessions_save()
            self._json(200, {"ok": True}, set_cookie="")
            return
        if path == "/api/decide":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            if LOCK_FLAG.is_file():
                self._json(403, {"ok": False, "error": "Subscription paused"})
                return
            key = str(body.get("key") or "").strip()
            decision = str(body.get("decision") or "").strip().lower()
            if not key or decision not in {"fine", "neutralize"}:
                self._json(400, {"ok": False, "error": "key and decision required"})
                return
            with _db() as con:
                con.execute(
                    "INSERT INTO decisions(finding_key,decision,detail,updated_at) VALUES (?,?,?,?) "
                    "ON CONFLICT(finding_key) DO UPDATE SET decision=excluded.decision, updated_at=excluded.updated_at",
                    (key, decision, str(body.get("detail") or "")[:500], time.time()),
                )
                con.commit()
            q = DATA / "decision_queue.jsonl"
            with q.open("a", encoding="utf-8") as fh:
                fh.write(
                    json.dumps(
                        {
                            "key": key,
                            "decision": decision,
                            "email": sess.get("email"),
                            "ts": time.time(),
                        }
                    )
                    + "\n"
                )
            st = _load_state()
            st.setdefault("decisions", {})
            st["decisions"][key] = {
                "decision": decision,
                "ts": time.time(),
                "email": sess.get("email"),
            }
            # Remove from open findings immediately so the button feels instant
            st["open_findings"] = [
                f for f in (st.get("open_findings") or []) if (f.get("key") or "") != key
            ]
            act = list(st.get("activity") or [])
            act.insert(
                0,
                {
                    "ts": time.time(),
                    "plain": (
                        "You marked an issue as fine — guardian noted it."
                        if decision == "fine"
                        else "Neutralize requested — Atlas will contain that issue when possible. Your network stays protected."
                    ),
                },
            )
            st["activity"] = act[:40]
            feed = list(st.get("guardian_feed") or [])
            feed.insert(
                0,
                {
                    "ts": time.time(),
                    "text": (
                        f"Understood — marked fine ({key})."
                        if decision == "fine"
                        else f"Neutralize queued for {key}. I never silent-block without your choice."
                    ),
                    "kind": "guardian",
                },
            )
            st["guardian_feed"] = feed[:60]
            _save_state(st)
            self._json(200, {"ok": True, "decision": decision, "key": key})
            return
        if path == "/api/agent/ask":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            if LOCK_FLAG.is_file():
                self._json(403, {"ok": False, "error": "Subscription paused"})
                return
            try:
                import ui_guardian

                result = ui_guardian.ask(
                    str(body.get("message") or body.get("q") or ""),
                    enroll_token=_enroll_token(),
                )
                self._json(200, result)
            except Exception as exc:
                self._json(500, {"ok": False, "error": str(exc)})
            return
        if path == "/api/agent/ack-all":
            _, sess = _session(self)
            if not sess:
                self._json(401, {"ok": False, "error": "login required"})
                return
            st = _load_state()
            count = 0
            with _db() as con:
                for f in list(st.get("open_findings") or []):
                    key = str(f.get("key") or "").strip()
                    if not key:
                        continue
                    con.execute(
                        "INSERT INTO decisions(finding_key,decision,detail,updated_at) VALUES (?,?,?,?) "
                        "ON CONFLICT(finding_key) DO UPDATE SET decision=excluded.decision, updated_at=excluded.updated_at",
                        (key, "fine", "ack-all", time.time()),
                    )
                    st.setdefault("decisions", {})[key] = {
                        "decision": "fine",
                        "ts": time.time(),
                    }
                    count += 1
                con.commit()
            st["open_findings"] = []
            _save_state(st)
            self._json(200, {"ok": True, "acked": count})
            return
        self.send_error(404)


def body_json(handler) -> dict | list:
    """Parse the POST body of a handler as JSON (dict or list)."""
    n = int(handler.headers.get("Content-Length") or 0)
    raw = handler.rfile.read(n) if n else b"{}"
    try:
        return json.loads(raw.decode() or "{}")
    except Exception:
        return {}


def main() -> None:
    _sessions_load()
    httpd = ThreadingHTTPServer((HOST, PORT), Handler)
    print(f"Atlas Cyber Protect portal on http://{HOST}:{PORT}", flush=True)
    httpd.serve_forever()


if __name__ == "__main__":
    main()
