"""Remote PC Agent install invites + Windows popup delivery.

When the customer selects devices on the Protect portal, we create an invite.
If Windows admin credentials are configured, Protect copies a small prompt to the
PC and schedules it for the logged-on user (interactive popup → Install).
Without credentials, the invite link can be opened on that PC manually.
"""
from __future__ import annotations

import json
import os
import re
import secrets
import subprocess
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote

DATA = Path("/var/lib/atlas-cyber-protect")
INVITES = DATA / "install-invites.json"
CREDS = DATA / "remote-install-creds.json"
AGENT_RELEASES = DATA / "agent-releases"
MANIFEST = AGENT_RELEASES / "manifest.json"


def _load_json(path: Path, default):
    try:
        if path.is_file():
            return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        pass
    return default


def _save_json(path: Path, obj) -> None:
    DATA.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")


def load_invites() -> list[dict[str, Any]]:
    raw = _load_json(INVITES, [])
    return raw if isinstance(raw, list) else []


def save_invites(rows: list[dict[str, Any]]) -> None:
    _save_json(INVITES, rows[:200])


def load_creds() -> dict[str, str]:
    raw = _load_json(CREDS, {})
    if not isinstance(raw, dict):
        return {}
    return {
        "username": str(raw.get("username") or "").strip(),
        "password": str(raw.get("password") or ""),
        "domain": str(raw.get("domain") or "").strip(),
    }


def save_creds(username: str, password: str, domain: str = "") -> None:
    _save_json(
        CREDS,
        {
            "username": (username or "").strip()[:120],
            "password": password or "",
            "domain": (domain or "").strip()[:80],
            "updated_at": time.time(),
        },
    )
    try:
        os.chmod(CREDS, 0o600)
    except Exception:
        pass


def clear_creds() -> None:
    try:
        if CREDS.is_file():
            CREDS.unlink()
    except Exception:
        pass


def installer_meta() -> dict[str, Any]:
    """Local agent release info: the one-file EXE plus the full Setup installer
    (if mirrored), so the box can push both to C:\\Users\\Public on office PCs."""
    man = _load_json(MANIFEST, {})
    if not isinstance(man, dict):
        man = {}
    fname = str(man.get("filename") or "")
    ver = str(man.get("version") or "0.3.2")
    local = AGENT_RELEASES / fname if fname else None
    if not local or not local.is_file():
        # fall back to any latest exe (never a Setup — Setup is the installer)
        cands = sorted(AGENT_RELEASES.glob("AtlasCyberProtectAgent-[0-9]*.exe"), reverse=True)
        if cands:
            local = cands[0]
            fname = local.name
    # Full installer from the mirrored manifest (installer.filename)
    inst = man.get("installer") if isinstance(man.get("installer"), dict) else {}
    setup_fname = str(inst.get("filename") or "")
    setup_local = AGENT_RELEASES / setup_fname if setup_fname else None
    if not setup_local or not setup_local.is_file():
        cands = sorted(AGENT_RELEASES.glob("AtlasCyberProtectAgent-Setup-*.exe"), reverse=True)
        setup_local = cands[0] if cands else None
        setup_fname = setup_local.name if setup_local else ""
    return {
        "version": ver,
        "filename": fname,
        "path": str(local) if local and local.is_file() else "",
        "sha256": man.get("sha256") or "",
        "size": man.get("size") or (local.stat().st_size if local and local.is_file() else 0),
        "setup_filename": setup_fname,
        "setup_path": str(setup_local) if setup_local and setup_local.is_file() else "",
        "setup_sha256": inst.get("sha256") or "",
    }


def create_invites(devices: list[dict[str, Any]], *, created_by: str = "") -> list[dict[str, Any]]:
    rows = load_invites()
    out = []
    for d in devices:
        ip = str(d.get("ip") or "").strip()
        mac = str(d.get("mac") or "").strip().lower()
        if not ip and not mac:
            continue
        token = secrets.token_urlsafe(18)
        row = {
            "token": token,
            "ip": ip,
            "mac": mac,
            "hostname": str(d.get("hostname") or d.get("display_name") or "")[:120],
            "device_type": str(d.get("device_type") or d.get("role_guess") or "")[:40],
            "status": "pending",
            "created_at": time.time(),
            "created_by": (created_by or "")[:80],
            "delivery": "",
            "delivery_detail": "",
            "accepted_at": None,
            "installed_at": None,
        }
        rows.insert(0, row)
        out.append(row)
    save_invites(rows)
    return out


def get_invite(token: str) -> dict[str, Any] | None:
    tok = (token or "").strip()
    for row in load_invites():
        if row.get("token") == tok:
            return row
    return None


def update_invite(token: str, **fields) -> dict[str, Any] | None:
    rows = load_invites()
    for row in rows:
        if row.get("token") == token:
            row.update(fields)
            save_invites(rows)
            return row
    return None


def prompt_hta(token: str, portal_base: str, meta: dict[str, Any]) -> str:
    """HTA shown on the remote PC — user clicks Install."""
    install_url = f"{portal_base.rstrip('/')}/api/companion/invite/{quote(token)}/download"
    accept_url = f"{portal_base.rstrip('/')}/api/companion/invite/{quote(token)}/accept"
    host = meta.get("hostname") or "this PC"
    ver = meta.get("version") or ""
    # Keep HTA simple and self-contained
    return f"""<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>Atlas Cyber Protect</title>
<HTA:APPLICATION ID="acpInvite" APPLICATIONNAME="Atlas Cyber Protect" BORDER="thin"
  BORDERSTYLE="static" CAPTION="yes" MAXIMIZEBUTTON="no" MINIMIZEBUTTON="no"
  SHOWINTASKBAR="yes" SINGLEINSTANCE="yes" SYSMENU="yes" SCROLL="no" WINDOWSTATE="normal"/>
<style>
body{{font-family:Segoe UI,Arial,sans-serif;background:#0b1220;color:#e8f4ff;margin:0;padding:24px}}
h1{{color:#7CFFB2;font-size:20px;margin:0 0 8px}}
p{{line-height:1.45;font-size:14px}}
.muted{{color:#8aa0b8}}
button{{margin-top:16px;margin-right:10px;padding:10px 18px;border:0;border-radius:8px;font-weight:700;cursor:pointer}}
.ok{{background:#3dff9a;color:#04140c}}
.no{{background:#1e3a5f;color:#e8f4ff}}
</style>
<script language="JScript">
function go(){{
  try {{
    var x=new ActiveXObject("MSXML2.XMLHTTP");
    x.open("POST","{accept_url}",false);
    x.send("");
  }} catch(e) {{}}
  try {{
    var sh=new ActiveXObject("WScript.Shell");
    var tmp=sh.ExpandEnvironmentStrings("%TEMP%")+"\\\\AtlasCyberProtectAgent-Setup.exe";
    var x2=new ActiveXObject("MSXML2.XMLHTTP");
    x2.open("GET","{install_url}",false);
    x2.send();
    if(x2.status!=200){{ alert("Download failed: "+x2.status); return; }}
    var stream=new ActiveXObject("ADODB.Stream");
    stream.Type=1; stream.Open(); stream.Write(x2.responseBody); stream.SaveToFile(tmp,2); stream.Close();
    sh.Run('"'+tmp+'"',1,false);
    alert("Installer started. Atlas Cyber Protect will finish in the background.");
    window.close();
  }} catch(e) {{
    alert("Could not start installer: "+e.message+"\\nOpen {install_url} in your browser instead.");
  }}
}}
window.resizeTo(480,340);
</script>
</head>
<body>
<h1>Atlas Cyber Protect</h1>
<p>FN.nology Protect wants to install the <b>PC security agent</b> on <b>{host}</b>.</p>
<p class="muted">Version {ver}. The agent watches antivirus, programs, and email links. It never deletes your files or blocks your internet.</p>
<button class="ok" onclick="go()">Install</button>
<button class="no" onclick="window.close()">Not now</button>
</body></html>
"""


def _smb_auth_args(creds: dict[str, str]) -> list[str]:
    user = creds.get("username") or ""
    domain = creds.get("domain") or ""
    password = creds.get("password") or ""
    if domain and "\\" not in user and "@" not in user:
        user = f"{domain}\\{user}"
    return ["-U", f"{user}%{password}"]


def try_deliver_popup(invite: dict[str, Any], portal_base: str) -> dict[str, Any]:
    """Best-effort: drop HTA on the PC and run it interactively for the logged-on user."""
    ip = str(invite.get("ip") or "")
    if not ip or not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip):
        return {"ok": False, "delivery": "skipped", "detail": "no IPv4"}
    creds = load_creds()
    if not creds.get("username") or not creds.get("password"):
        return {
            "ok": False,
            "delivery": "needs_credentials",
            "detail": "Set Windows admin credentials in Protect → Devices → Remote install settings, then retry.",
        }
    meta = installer_meta()
    meta["hostname"] = invite.get("hostname") or ip
    meta["version"] = meta.get("version")
    hta = prompt_hta(invite["token"], portal_base, meta)
    tmp = DATA / f"invite-{invite['token'][:12]}.hta"
    tmp.write_text(hta, encoding="utf-8")
    auth = _smb_auth_args(creds)
    # Put HTA in Public Desktop / Public so a logged-on user can see it; also schedule interactive run
    remote_name = f"AtlasProtectInvite-{invite['token'][:8]}.hta"
    cmd_put = [
        "smbclient",
        f"//{ip}/C$",
        *auth,
        "-c",
        f'put {tmp} Users\\Public\\{remote_name}',
    ]
    try:
        put = subprocess.run(cmd_put, capture_output=True, text=True, timeout=45)
    except FileNotFoundError:
        return {
            "ok": False,
            "delivery": "smbclient_missing",
            "detail": "Install smbclient on the Protect box (apt install smbclient).",
        }
    except Exception as exc:
        return {"ok": False, "delivery": "error", "detail": str(exc)[:240]}
    if put.returncode != 0:
        err = (put.stderr or put.stdout or "smb put failed")[:300]
        update_invite(invite["token"], status="delivery_failed", delivery="smb_failed", delivery_detail=err)
        return {"ok": False, "delivery": "smb_failed", "detail": err}

    # Schedule interactive task for logged-on user — shows the HTA popup
    tr = f'mshta.exe "C:\\Users\\Public\\{remote_name}"'
    # schtasks via smb + winexe alternative: use smbclient to drop a .cmd and hope...
    # Prefer `impacket` free approach: create scheduled task with schtasks.exe over smbadmin using `atexec` if present.
    # Fallback: use `smbclient` + `rpcclient` is hard. Try `schtasks` with winexe-like via `pth-winexe` missing.
    # Use Python subprocess to `smbclient` run is not interactive.
    # Best portable approach on Ubuntu: `impacket-smbexec` not installed.
    # Use `net rpc` schedule? 
    # Practical: drop a Startup shortcut AND try `schtasks` through `winexe` if available.
    scheduled = False
    detail = f"Prompt copied to C:\\Users\\Public\\{remote_name}"
    for tool in ("impacket-atexec", "atexec.py", "winexe"):
        pass
    # Try schtasks via ssh-like: use `smbprotocol` not available.
    # Use `net` from samba: 
    cmd_task = [
        "smbclient",
        f"//{ip}/C$",
        *auth,
        "-c",
        (
            f"put {tmp} Windows\\Temp\\{remote_name}; "
            # create a tiny launcher cmd
        ),
    ]
    # Create scheduled task using `schtasks` executed via `wmiexec` if we install free `impacket`.
    # For reliability without new deps: drop to Public Desktop so user sees the file, AND
    # write a RunOnce registry via... needs regedit remote.
    #
    # Use `msg` is weak. Drop to Public Desktop:
    cmd_desk = [
        "smbclient",
        f"//{ip}/C$",
        *auth,
        "-c",
        f'put {tmp} Users\\Public\\Desktop\\{remote_name}',
    ]
    desk = subprocess.run(cmd_desk, capture_output=True, text=True, timeout=45)
    if desk.returncode == 0:
        detail += " and Public Desktop"
        scheduled = True

    # Attempt remote schtasks using samba's `rpcclient` + AtSvc is obsolete.
    # Try installing and using a one-liner with `pth-winexe` — skip.
    # Use PowerShell remoting if open:
    ps = (
        f'$u="{creds.get("domain")+"\\\\" if creds.get("domain") else ""}{creds.get("username")}"; '
        f'$p=ConvertTo-SecureString "{creds.get("password")}" -AsPlainText -Force; '
        f'$c=New-Object System.Management.Automation.PSCredential($u,$p); '
        f'Invoke-Command -ComputerName {ip} -Credential $c -ScriptBlock {{ '
        f'schtasks /Create /TN AtlasProtectInvite /TR "mshta.exe C:\\Users\\Public\\{remote_name}" '
        f'/SC ONCE /ST 00:00 /IT /F /RL LIMITED; schtasks /Run /TN AtlasProtectInvite }}'
    )
    # Running PS on the Protect box (Linux) won't have Invoke-Command to Windows.
    # Use `impacket-smbexec` after apt install python3-impacket if possible.

    # Try atexec from impacket if present:
    atexec = None
    for cand in ("impacket-atexec", "atexec.py", "impacket-wmiexec", "wmiexec.py"):
        if subprocess.run(["bash", "-lc", f"command -v {cand}"], capture_output=True).returncode == 0:
            atexec = cand
            break
    if atexec:
        target_user = creds["username"]
        if creds.get("domain"):
            auth_str = f"{creds['domain']}/{target_user}:{creds['password']}@{ip}"
        else:
            auth_str = f"{target_user}:{creds['password']}@{ip}"
        remote_cmd = (
            f'schtasks /Create /TN AtlasProtectInvite /TR "mshta.exe C:\\Users\\Public\\{remote_name}" '
            f"/SC ONCE /ST 23:59 /IT /F /RL LIMITED & schtasks /Run /TN AtlasProtectInvite"
        )
        run = subprocess.run(
            [atexec, auth_str, remote_cmd],
            capture_output=True,
            text=True,
            timeout=90,
        )
        if run.returncode == 0:
            scheduled = True
            detail += "; interactive task started"
        else:
            detail += f"; atexec note: {(run.stderr or run.stdout or '')[:160]}"

    status = "prompt_delivered" if scheduled else "copied_needs_open"
    update_invite(
        invite["token"],
        status=status,
        delivery="smb",
        delivery_detail=detail[:400],
    )
    try:
        tmp.unlink()
    except Exception:
        pass
    return {"ok": True, "delivery": "smb", "detail": detail, "status": status}


def portal_base_url(lan_ip: str, port: int = 8787) -> str:
    return f"http://{lan_ip}:{port}"
