from __future__ import annotations

import base64
import json
from dataclasses import dataclass
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen


class SFTPGoError(RuntimeError):
    pass


@dataclass(frozen=True)
class SFTPGoAdminClient:
    """
    Minimal SFTPGo admin API client (v3.0).

    SFTPGo OpenAPI documents the admin token flow:
      - GET /api/v2/token with HTTP BasicAuth -> returns {"access_token": "..."}.
      - Use Authorization: Bearer <token> for admin endpoints like POST /api/v2/users.

    See upstream OpenAPI for details:
      https://raw.githubusercontent.com/drakkan/sftpgo/main/openapi/openapi.yaml
    """

    admin_api_base: str
    admin_user: str
    admin_password: str
    common_root: str = "/private/common"

    def _url(self, path: str) -> str:
        base = self.admin_api_base.rstrip("/")
        p = path if path.startswith("/") else f"/{path}"
        return f"{base}{p}"

    def _basic_auth_header(self) -> str:
        raw = f"{self.admin_user}:{self.admin_password}".encode("utf-8")
        return "Basic " + base64.b64encode(raw).decode("ascii")

    def _get_json(self, url: str, headers: dict[str, str]) -> dict:
        req = Request(url, headers=headers, method="GET")
        try:
            with urlopen(req, timeout=10) as resp:
                return json.loads(resp.read().decode("utf-8"))
        except HTTPError as e:
            raise SFTPGoError(f"sftpgo http error: {e.code} {e.reason}") from e
        except URLError as e:
            raise SFTPGoError(f"sftpgo connection error: {e!r}") from e

    def _post_json(self, url: str, payload: dict, headers: dict[str, str]) -> None:
        data = json.dumps(payload).encode("utf-8")
        req = Request(url, data=data, headers=headers, method="POST")
        try:
            with urlopen(req, timeout=10) as resp:
                _ = resp.read()
        except HTTPError as e:
            raise SFTPGoError(f"sftpgo http error: {e.code} {e.reason}") from e
        except URLError as e:
            raise SFTPGoError(f"sftpgo connection error: {e!r}") from e

    def _put_json(self, url: str, payload: dict, headers: dict[str, str]) -> None:
        data = json.dumps(payload).encode("utf-8")
        req = Request(url, data=data, headers=headers, method="PUT")
        try:
            with urlopen(req, timeout=10) as resp:
                _ = resp.read()
        except HTTPError as e:
            raise SFTPGoError(f"sftpgo http error: {e.code} {e.reason}") from e
        except URLError as e:
            raise SFTPGoError(f"sftpgo connection error: {e!r}") from e

    def _admin_token(self) -> str:
        url = self._url("/token")
        obj = self._get_json(url, headers={"Authorization": self._basic_auth_header()})
        tok = str(obj.get("access_token") or "").strip()
        if not tok:
            raise SFTPGoError("sftpgo token response missing access_token")
        return tok

    def _auth_headers(self, tok: str) -> dict[str, str]:
        return {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}

    def _ensure_folder(self, *, tok: str, name: str, mapped_path: str) -> None:
        url = self._url("/folders")
        try:
            self._post_json(url, {"name": name, "mapped_path": mapped_path}, headers=self._auth_headers(tok))
        except SFTPGoError as e:
            # Idempotent + self-healing:
            # If it already exists, update mapped_path to the desired value.
            if "409" in str(e):
                self._put_json(
                    self._url(f"/folders/{name}"),
                    {"name": name, "mapped_path": mapped_path},
                    headers=self._auth_headers(tok),
                )
                return
            raise

    def _ensure_common_folders(self, *, tok: str) -> None:
        # Important: do NOT map datasets to /private/common/datasets because that path is
        # a symlink farm (e.g. gsm8k -> /private/datasets/gsm8k) and SFTPGo WebClient
        # will treat symlink traversal as escaping the virtual folder root, resulting
        # in "permission denied". Map directly to the real datasets root.
        common = self.common_root.rstrip("/")
        if common.endswith("/common"):
            shared_root = common[: -len("/common")] or "/private"
        else:
            shared_root = "/private"

        self._ensure_folder(tok=tok, name="common_datasets", mapped_path=f"{shared_root}/datasets")
        # Expose HF cache read-only so users can inspect downloaded models/datasets.
        self._ensure_folder(tok=tok, name="common_hf", mapped_path=f"{shared_root}/hf")

    def _get_user(self, *, tok: str, username: str) -> dict:
        url = self._url(f"/users/{username}")
        return self._get_json(url, headers={"Authorization": f"Bearer {tok}"})

    def _put_user(self, *, tok: str, username: str, payload: dict) -> None:
        url = self._url(f"/users/{username}")
        self._put_json(url, payload, headers=self._auth_headers(tok))

    def _apply_common_readonly(self, user_payload: dict) -> dict:
        # Path-based permissions: make /common/* read-only while keeping home writeable.
        perms = dict(user_payload.get("permissions") or {"/": ["*"]})
        # Ensure /common is visible as a directory and can be traversed.
        perms["/common"] = ["list"]
        # SFTPGo permissions are path-scoped. In practice, granting on the directory itself may
        # not always cover nested paths depending on client/API behavior, so include common
        # wildcard forms to ensure users can read files under these virtual folders.
        perms["/common/datasets"] = ["list"]
        perms["/common/datasets/*"] = ["list", "download"]
        perms["/common/hf"] = ["list"]
        perms["/common/hf/*"] = ["list", "download"]
        user_payload["permissions"] = perms

        desired_vf = [
            {"name": "common_datasets", "virtual_path": "/common/datasets"},
            {"name": "common_hf", "virtual_path": "/common/hf"},
        ]
        existing = user_payload.get("virtual_folders") or []
        if not isinstance(existing, list):
            existing = []
        seen = {(vf.get("name"), vf.get("virtual_path")) for vf in existing if isinstance(vf, dict)}
        merged = list(existing)
        for vf in desired_vf:
            key = (vf["name"], vf["virtual_path"])
            if key not in seen:
                merged.append(vf)
        user_payload["virtual_folders"] = merged
        return user_payload

    def create_user(self, *, username: str, password: str, home_dir: str) -> None:
        tok = self._admin_token()
        self._ensure_common_folders(tok=tok)
        url = self._url("/users")
        payload: dict = {
            "status": 1,
            "username": username,
            "password": password,
            "home_dir": home_dir,
            "permissions": {
                "/": ["*"],
                "/common": ["list"],
                "/common/datasets": ["list"],
                "/common/datasets/*": ["list", "download"],
                "/common/hf": ["list"],
                "/common/hf/*": ["list", "download"],
            },
            "virtual_folders": [
                {"name": "common_datasets", "virtual_path": "/common/datasets"},
                {"name": "common_hf", "virtual_path": "/common/hf"},
            ],
        }
        self._post_json(
            url,
            payload,
            headers=self._auth_headers(tok),
        )

    def disable_user(self, *, username: str, home_dir: str) -> None:
        tok = self._admin_token()
        self._ensure_common_folders(tok=tok)
        cur = self._get_user(tok=tok, username=username)
        payload: dict = {
            "username": username,
            "status": 0,
            "home_dir": home_dir,
            "uid": cur.get("uid", 0),
            "gid": cur.get("gid", 0),
            "permissions": cur.get("permissions") or {"/": ["*"]},
            "virtual_folders": cur.get("virtual_folders") or [],
        }
        self._apply_common_readonly(payload)
        self._put_user(tok=tok, username=username, payload=payload)

    def enable_user(self, *, username: str, home_dir: str) -> None:
        tok = self._admin_token()
        self._ensure_common_folders(tok=tok)
        cur = self._get_user(tok=tok, username=username)
        payload: dict = {
            "username": username,
            "status": 1,
            "home_dir": home_dir,
            "uid": cur.get("uid", 0),
            "gid": cur.get("gid", 0),
            "permissions": cur.get("permissions") or {"/": ["*"]},
            "virtual_folders": cur.get("virtual_folders") or [],
        }
        self._apply_common_readonly(payload)
        self._put_user(tok=tok, username=username, payload=payload)

    def reset_password(self, *, username: str, new_password: str, home_dir: str) -> None:
        tok = self._admin_token()
        self._ensure_common_folders(tok=tok)
        cur = self._get_user(tok=tok, username=username)
        payload: dict = {
            "username": username,
            "status": 1,
            "home_dir": home_dir,
            "uid": cur.get("uid", 0),
            "gid": cur.get("gid", 0),
            "permissions": cur.get("permissions") or {"/": ["*"]},
            "virtual_folders": cur.get("virtual_folders") or [],
            "password": new_password,
        }
        self._apply_common_readonly(payload)
        self._put_user(tok=tok, username=username, payload=payload)
