#!/usr/bin/env python3
"""ownCloud server client for the `owncloud` skill (GPS backup verification).

Talks to ACG's live ownCloud 10.16 server (cloud.acghosting.com / 172.16.3.22)
over SSH and drives the `occ` admin CLI. Read-only: enumerates users, their
quota + actual storage used, and last login so the GPS backup audit can see who
is actually backing up to ownCloud and how much data they hold.

There is NO ownCloud admin WEB/OCS account in the vault, so the access channel is
SSH (root@172.16.3.22, paramiko password auth) + `occ`, not the OCS HTTP API.
`occ` must run as the web/service user, so every invocation is:

    sudo -u apache php /var/www/owncloud/occ <cmd>

Per-user "used bytes" is not exposed by occ on this version (no `user:info`).
Instead we read the authoritative figure straight from ownCloud's filecache: the
size ownCloud itself reports for each user's root. DB creds are read live from
the server's config.php (we already have root), so nothing is duplicated and it
survives a DB-password rotation. If a user's cached size is dirty (-1), we fall
back to `du -sb <home>/files` for that one user (slower, generous timeout).

Credentials load lazily from the SOPS vault (never hardcoded):
`infrastructure/owncloud-vm.sops.yaml` -> credentials.username / credentials.password.
"""
from __future__ import annotations

import json
import shlex
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

import paramiko

# Import the shared backup helpers from .claude/scripts.
_SKILL_DIR = Path(__file__).resolve().parent.parent            # .../skills/owncloud
_SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts"            # .../.claude/scripts
sys.path.insert(0, str(_SCRIPTS_DIR))
import backup_common as bc  # noqa: E402

VAULT_ENTRY = "infrastructure/owncloud-vm.sops.yaml"
VAULT_FIELD_USER = "credentials.username"
VAULT_FIELD_PASS = "credentials.password"

DEFAULT_HOST = "172.16.3.22"
SSH_PORT = 22
# occ must run as the web user (apache) with the system php against the install dir.
OCC_INSTALL_DIR = "/var/www/owncloud"
OCC = f"sudo -u apache php {OCC_INSTALL_DIR}/occ"
CONFIG_PHP = f"{OCC_INSTALL_DIR}/config/config.php"

EXEC_TIMEOUT = 60       # seconds for normal occ / mysql calls
DU_TIMEOUT = 900        # du on a dirty-cache home can be hundreds of GB


class OwncloudClient:
    def __init__(self, host: Optional[str] = None):
        self.host = host or DEFAULT_HOST
        self._ssh: Optional[paramiko.SSHClient] = None
        self._db: Optional[dict] = None

    # -- ssh -------------------------------------------------------------------
    def _connect(self) -> paramiko.SSHClient:
        if self._ssh is not None:
            return self._ssh
        user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER)
        pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS)
        cli = paramiko.SSHClient()
        cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        try:
            cli.connect(self.host, port=SSH_PORT, username=user, password=pw,
                        timeout=20, look_for_keys=False, allow_agent=False)
        except Exception as exc:  # paramiko raises several distinct types
            raise bc.BackupError(f"SSH connect to {user}@{self.host} failed: {exc}") from exc
        self._ssh = cli
        return cli

    def run(self, cmd: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
        """Run a shell command over SSH; return (exit_status, stdout, stderr)."""
        cli = self._connect()
        try:
            _in, out, err = cli.exec_command(cmd, timeout=timeout)
            stdout = out.read().decode("utf-8", "replace")
            stderr = err.read().decode("utf-8", "replace")
            rc = out.channel.recv_exit_status()
        except Exception as exc:
            raise bc.BackupError(f"SSH command failed ({cmd[:60]}...): {exc}") from exc
        return rc, stdout, stderr

    def occ(self, args: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
        return self.run(f"{OCC} {args}", timeout=timeout)

    def close(self) -> None:
        if self._ssh is not None:
            try:
                self._ssh.close()
            finally:
                self._ssh = None

    # -- server status ---------------------------------------------------------
    def server_status(self) -> dict:
        """occ status (installed/version/edition) plus the `occ -V` version string."""
        rc, out, err = self.occ("status --output=json")
        if rc != 0:
            raise bc.BackupError(f"occ status failed (rc={rc}): {err.strip()[:300]}")
        try:
            status = json.loads(out)
        except json.JSONDecodeError:
            raise bc.BackupError(f"occ status returned non-JSON: {out.strip()[:300]}")
        rc2, ver, _ = self.occ("-V")
        status["occ_version"] = ver.strip() if rc2 == 0 else None
        status["host"] = self.host
        return status

    # -- storage used (filecache primary, du fallback) -------------------------
    def _db_config(self) -> dict:
        """Read DB name/user/password/prefix live from config.php (we have root)."""
        if self._db is not None:
            return self._db
        rc, out, err = self.run(
            "grep -E \"'db(name|user|password|tableprefix)'\" " + CONFIG_PHP)
        if rc != 0:
            raise bc.BackupError(f"reading {CONFIG_PHP} failed (rc={rc}): {err.strip()[:200]}")
        cfg = {"dbname": "owncloud", "dbuser": "owncloud",
               "dbpassword": "", "dbtableprefix": "oc_"}
        for line in out.splitlines():
            # form:  'dbname' => 'value',
            if "=>" not in line:
                continue
            key, _, val = line.partition("=>")
            k = key.strip().strip("'\" ")
            v = val.strip().rstrip(",").strip().strip("'\"")
            if k in cfg:
                cfg[k] = v
        self._db = cfg
        return cfg

    def _mysql(self, sql: str) -> str:
        cfg = self._db_config()
        cmd = (f"MYSQL_PWD={shlex.quote(cfg['dbpassword'])} "
               f"mysql -N -B -u {shlex.quote(cfg['dbuser'])} "
               f"{shlex.quote(cfg['dbname'])} -e {shlex.quote(sql)}")
        rc, out, err = self.run(cmd)
        if rc != 0:
            raise bc.BackupError(f"mysql query failed (rc={rc}): {err.strip()[:200]}")
        return out

    def _filecache_sizes(self) -> dict[str, int]:
        """uid -> cached used bytes for each per-user home storage (ownCloud's own
        figure). A size of -1 means the cache is dirty; such uids are omitted here
        and handled by the du fallback."""
        pfx = self._db_config()["dbtableprefix"]
        sql = (f"SELECT s.id, fc.size FROM {pfx}storages s "
               f"JOIN {pfx}filecache fc ON fc.storage=s.numeric_id "
               f"WHERE s.id LIKE 'home::%' AND fc.path='';")
        out = self._mysql(sql)
        sizes: dict[str, int] = {}
        for row in out.splitlines():
            if "\t" not in row:
                continue
            sid, _, val = row.partition("\t")
            if not sid.startswith("home::"):
                continue
            uid = sid[len("home::"):]
            try:
                n = int(val.strip())
            except ValueError:
                continue
            if n >= 0:
                sizes[uid] = n
        return sizes

    def _du_bytes(self, uid: str, home: str) -> Optional[int]:
        """Fallback used-bytes via du on the on-disk home (dirty-cache users only)."""
        if not home:
            return None
        rc, out, err = self.run(
            f"du -sb {shlex.quote(home + '/files')} 2>/dev/null | cut -f1",
            timeout=DU_TIMEOUT)
        try:
            return int(out.strip())
        except (ValueError, AttributeError):
            return None

    # -- users -----------------------------------------------------------------
    def list_users(self, with_usage: bool = True) -> list[dict]:
        """All users with attributes (uid, display name, email, quota, enabled,
        last login, home, backend). When with_usage, enrich each with used bytes
        from the filecache, falling back to du for dirty-cache users."""
        rc, out, err = self.occ("user:list --show-all-attributes --output=json")
        if rc != 0:
            raise bc.BackupError(f"occ user:list failed (rc={rc}): {err.strip()[:300]}")
        try:
            raw = json.loads(out)
        except json.JSONDecodeError:
            raise bc.BackupError(f"occ user:list returned non-JSON: {out.strip()[:300]}")

        users: list[dict] = []
        for uid, attrs in raw.items():
            attrs = attrs or {}
            users.append({
                "uid": uid,
                "display_name": attrs.get("displayName"),
                "email": attrs.get("email"),
                "quota": attrs.get("quota"),
                "enabled": _as_bool(attrs.get("enabled")),
                "last_login": _epoch(attrs.get("lastLogin")),
                "creation_time": _epoch(attrs.get("creationTime")),
                "backend": attrs.get("backend"),
                "home": attrs.get("home"),
                "used_bytes": None,
                "used_source": None,
            })

        if with_usage:
            sizes = self._filecache_sizes()
            for u in users:
                uid = u["uid"]
                if uid in sizes:
                    u["used_bytes"] = sizes[uid]
                    u["used_source"] = "filecache"
                else:
                    # dirty cache (-1) or no storage row: compute on disk
                    du = self._du_bytes(uid, u.get("home"))
                    u["used_bytes"] = du
                    u["used_source"] = "du" if du is not None else None
        return users

    # -- occ helpers for management ops ----------------------------------------
    def occ_env(self, env: dict, args: str,
                timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]:
        """Run occ with extra environment (e.g. OC_PASS) exported for the apache
        user. `sudo -u apache env KEY=val php occ ...` — the explicit `env` sets
        the var inside apache's context regardless of sudo's env_reset policy.

        Caveat: the value is briefly visible in `ps` on the server to root; this
        is occ's own documented mechanism for passwords and the box is root-only.
        """
        prefix = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items())
        cmd = f"sudo -u apache env {prefix} php {OCC_INSTALL_DIR}/occ {args}"
        return self.run(cmd, timeout=timeout)

    def _occ_ok(self, args: str, timeout: int = EXEC_TIMEOUT) -> str:
        """Run occ, raise BackupError on non-zero, return stdout."""
        rc, out, err = self.occ(args, timeout=timeout)
        if rc != 0:
            label = args.split()[0] if args else "occ"
            raise bc.BackupError(f"occ {label} failed (rc={rc}): "
                                 f"{(err or out).strip()[:300]}")
        return out

    def _occ_env_ok(self, env: dict, args: str,
                    timeout: int = EXEC_TIMEOUT) -> str:
        """occ_env variant of _occ_ok (occ_env is not wrapped by _occ_ok)."""
        rc, out, err = self.occ_env(env, args, timeout=timeout)
        if rc != 0:
            label = args.split()[0] if args else "occ"
            raise bc.BackupError(f"occ {label} failed (rc={rc}): "
                                 f"{(err or out).strip()[:300]}")
        return out

    def _occ_json(self, args: str, timeout: int = EXEC_TIMEOUT) -> Any:
        """occ with --output=json; parse defensively so a non-JSON banner
        (maintenance notice, PHP deprecation, update line) surfaces as a clean
        BackupError rather than an uncaught JSONDecodeError traceback."""
        out = self._occ_ok(args, timeout=timeout).strip()
        try:
            return json.loads(out or "null")
        except json.JSONDecodeError:
            label = args.split()[0] if args else "occ"
            raise bc.BackupError(f"occ {label} returned non-JSON: {out[:300]}")

    # -- user lifecycle --------------------------------------------------------
    def create_user(self, uid: str, password: str,
                    display_name: Optional[str] = None,
                    email: Optional[str] = None,
                    groups: Optional[list[str]] = None) -> dict:
        # options first, then `--`, then the positional uid so a uid that
        # happens to start with '-' is never parsed by occ as an option flag.
        parts = ["user:add --password-from-env -n"]
        if display_name:
            parts.append(f"--display-name={shlex.quote(display_name)}")
        if email:
            parts.append(f"--email={shlex.quote(email)}")
        for g in (groups or []):
            parts.append(f"--group={shlex.quote(g)}")
        parts.append(f"-- {shlex.quote(uid)}")
        out = self._occ_env_ok({"OC_PASS": password}, " ".join(parts))
        return {"uid": uid, "output": out.strip()}

    def delete_user(self, uid: str, force: bool = False) -> str:
        return self._occ_ok(f"user:delete{' -f' if force else ''} -- "
                            f"{shlex.quote(uid)}", timeout=DU_TIMEOUT).strip()

    def set_user_enabled(self, uid: str, enabled: bool) -> str:
        verb = "user:enable" if enabled else "user:disable"
        return self._occ_ok(f"{verb} -- {shlex.quote(uid)}").strip()

    def reset_password(self, uid: str, password: str) -> str:
        return self._occ_env_ok(
            {"OC_PASS": password},
            f"user:resetpassword --password-from-env -- {shlex.quote(uid)}").strip()

    def modify_user(self, uid: str, key: str, value: str) -> str:
        """key is one of: displayname, email (per occ user:modify)."""
        return self._occ_ok(
            f"user:modify -- {shlex.quote(uid)} {shlex.quote(key)} "
            f"{shlex.quote(value)}").strip()

    # -- quota (occ user:setting <uid> files quota) ----------------------------
    def get_quota(self, uid: str) -> Optional[str]:
        """Return the user's quota string, or None when it is unset (= default).
        A non-existent user or a genuine occ failure raises BackupError - occ
        returns rc=1 for both 'setting unset' and 'user missing', so we key off
        the message ('setting does not exist' == unset)."""
        rc, out, err = self.occ(f"user:setting -- {shlex.quote(uid)} files quota")
        if rc == 0:
            return out.strip() or None
        msg = (out or err).strip()
        if "setting does not exist" in msg.lower():
            return None
        raise bc.BackupError(f"occ user:setting quota failed (rc={rc}): {msg[:300]}")

    def set_quota(self, uid: str, value: str) -> str:
        return self._occ_ok(
            f"user:setting --value={shlex.quote(value)} "
            f"-- {shlex.quote(uid)} files quota").strip()

    def reset_quota(self, uid: str) -> str:
        return self._occ_ok(
            f"user:setting --delete -- {shlex.quote(uid)} files quota").strip()

    # -- groups ----------------------------------------------------------------
    def list_groups(self) -> Any:
        return self._occ_json("group:list --output=json")

    def group_members(self, group: str) -> Any:
        return self._occ_json(
            f"group:list-members --output=json -- {shlex.quote(group)}")

    def user_groups(self, uid: str) -> Any:
        return self._occ_json(
            f"user:list-groups --output=json -- {shlex.quote(uid)}")

    def create_group(self, name: str) -> str:
        return self._occ_ok(f"group:add -- {shlex.quote(name)}").strip()

    def delete_group(self, name: str) -> str:
        return self._occ_ok(f"group:delete -- {shlex.quote(name)}").strip()

    def add_group_members(self, group: str, members: list[str]) -> str:
        # --member= binds each value even if it starts with '-'.
        ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
        return self._occ_ok(
            f"group:add-member {ms} -- {shlex.quote(group)}").strip()

    def remove_group_members(self, group: str, members: list[str]) -> str:
        ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
        return self._occ_ok(
            f"group:remove-member {ms} -- {shlex.quote(group)}").strip()

    # -- shares (read-only, straight from the DB; occ has no share list) --------
    SHARE_TYPES = {0: "user", 1: "group", 3: "public_link",
                   4: "email", 6: "federated"}

    def list_shares(self, uid: Optional[str] = None) -> list[dict]:
        # NB: uid is NOT inlined into SQL. MySQL honours backslash escapes by
        # default, so ''-doubling would not safely contain a hostile/odd uid
        # (and occ allows "'" in uids). Fetch all rows, filter in Python.
        pfx = self._db_config()["dbtableprefix"]
        sql = (f"SELECT s.id, s.share_type, s.share_with, s.uid_owner, "
               f"s.file_target, s.permissions, s.stime, fc.path "
               f"FROM {pfx}share s "
               f"LEFT JOIN {pfx}filecache fc ON fc.fileid=s.file_source "
               f"ORDER BY s.stime DESC;")
        out = self._mysql(sql)
        shares: list[dict] = []
        for row in out.splitlines():
            f = [None if v in ("NULL", "") else v for v in row.split("\t")]
            if len(f) < 8:
                continue
            if uid and uid not in (f[2], f[3]):  # share_with / owner
                continue
            st = _int(f[1])
            shares.append({
                "id": _int(f[0]),
                "share_type": self.SHARE_TYPES.get(st, str(st)),
                "share_with": f[2],
                "owner": f[3],
                "target": f[4],
                "permissions": _int(f[5]),
                "created": _epoch(f[6]),
                "path": f[7],
            })
        return shares

    # -- server / apps / config ------------------------------------------------
    def maintenance_mode(self, on: bool) -> str:
        return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip()

    def maintenance_status(self) -> bool:
        # reuse config_get so a genuine occ failure raises (not a false 'off');
        # the maintenance key is always set, so None would itself be anomalous.
        val = self.config_get("maintenance")
        return (val or "").strip().lower() in ("true", "1", "yes")

    def list_apps(self) -> Any:
        return self._occ_json("app:list --output=json")

    def enable_app(self, app: str) -> str:
        return self._occ_ok(f"app:enable -- {shlex.quote(app)}").strip()

    def disable_app(self, app: str) -> str:
        return self._occ_ok(f"app:disable -- {shlex.quote(app)}").strip()

    def config_get(self, name: str) -> Optional[str]:
        """Return the system config value, or None when the key is genuinely
        unset (occ rc=1 with empty output). A non-zero rc that carries an error
        message is a real failure and raises, so callers never mistake a broken
        occ read for 'value is unset'."""
        rc, out, err = self.occ(f"config:system:get -- {shlex.quote(name)}")
        if rc == 0:
            return out.strip()
        msg = (err or out).strip()
        if not msg:
            return None
        raise bc.BackupError(f"occ config:system:get failed (rc={rc}): {msg[:300]}")

    def config_list(self) -> Any:
        return self._occ_json("config:list system --output=json")

    def config_set(self, name: str, value: str, vtype: str = "string") -> str:
        return self._occ_ok(
            f"config:system:set {shlex.quote(name)} "
            f"--value={shlex.quote(value)} --type={shlex.quote(vtype)}").strip()

    # -- files admin -----------------------------------------------------------
    def files_scan(self, uid: Optional[str] = None, all_users: bool = False,
                   path: Optional[str] = None) -> str:
        opts = "files:scan"
        if path:
            opts += f" --path={shlex.quote(path)}"
        if all_users:
            args = f"{opts} --all"
        elif uid:
            args = f"{opts} -- {shlex.quote(uid)}"
        else:
            raise bc.BackupError("files_scan requires a uid or all_users=True")
        return self._occ_ok(args, timeout=DU_TIMEOUT).strip()

    def transfer_ownership(self, src: str, dst: str,
                           path: Optional[str] = None) -> str:
        args = "files:transfer-ownership -s"
        if path:
            args += f" --path={shlex.quote(path)}"
        args += f" -- {shlex.quote(src)} {shlex.quote(dst)}"
        return self._occ_ok(args, timeout=DU_TIMEOUT).strip()

    def trashbin_cleanup(self, users: list[str]) -> str:
        us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
        return self._occ_ok(f"trashbin:cleanup{us}", timeout=DU_TIMEOUT).strip()

    def versions_cleanup(self, users: list[str]) -> str:
        us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
        return self._occ_ok(f"versions:cleanup{us}", timeout=DU_TIMEOUT).strip()


def _int(v: Any) -> Optional[int]:
    try:
        return int(str(v).strip())
    except (TypeError, ValueError):
        return None


def _epoch(v: Any) -> Optional[str]:
    """occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never."""
    try:
        n = int(v)
    except (TypeError, ValueError):
        return None
    if n <= 0:
        return None
    return datetime.fromtimestamp(n, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")


def _as_bool(v: Any) -> bool:
    if isinstance(v, bool):
        return v
    if isinstance(v, str):
        return v.strip().lower() in ("true", "yes", "1", "enabled")
    return bool(v)


def main() -> int:
    c = OwncloudClient()
    try:
        s = c.server_status()
        print(f"[OK] ownCloud reachable at {c.host}; version={s.get('versionstring')} "
              f"edition={s.get('edition')}")
        return 0
    except bc.BackupError as exc:
        print(f"[ERROR] {exc}", file=sys.stderr)
        return 1
    finally:
        c.close()


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