from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta
import getpass
import hashlib
import json
import os
from pathlib import Path
import platform
import socket
from typing import Any
from urllib import error, request
from urllib.parse import quote
import uuid


class AuthClientError(Exception):
    pass


class AuthServerRejectedError(AuthClientError):
    pass


def auth_state_path() -> Path:
    base = Path(os.environ.get("APPDATA") or Path.home()) / "HydrologyCalculationSoftware"
    return base / "membership.json"


def device_identity_path() -> Path:
    return auth_state_path().with_name("device_identity.json")


def load_device_identity() -> dict[str, str]:
    path = device_identity_path()
    if not path.exists():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    if not isinstance(data, dict):
        return {}
    return {str(key): str(value) for key, value in data.items() if isinstance(key, str)}


def save_device_identity(identity: dict[str, str]) -> None:
    device_id = str(identity.get("device_id") or "").strip()
    if len(device_id) < 16:
        return
    payload = {
        "device_id": device_id,
        "device_name": str(identity.get("device_name") or "").strip() or get_device_name(),
    }
    path = device_identity_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")


def remember_device_identity(state: dict[str, str]) -> None:
    # @hook AUTH-device-identity 20260814
    # Keep the local device identifier outside the login token so logout does not consume a new server device slot.
    device_id = str(state.get("device_id") or "").strip()
    if len(device_id) < 16:
        return
    save_device_identity(
        {
            "device_id": device_id,
            "device_name": str(state.get("device_name") or "").strip() or get_device_name(),
        }
    )


def load_auth_state() -> dict[str, str]:
    path = auth_state_path()
    if not path.exists():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    return data if isinstance(data, dict) else {}


def save_auth_state(state: dict[str, str]) -> None:
    path = auth_state_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
    remember_device_identity(state)


def clear_auth_state() -> None:
    path = auth_state_path()
    state = load_auth_state()
    if state:
        remember_device_identity(state)
    try:
        path.unlink()
    except FileNotFoundError:
        pass


def parse_datetime(value: str) -> datetime | None:
    try:
        return datetime.fromisoformat((value or "").strip())
    except ValueError:
        return None


def is_state_active(state: dict[str, str]) -> bool:
    license_active = str(state.get("license_active", "")).strip().lower()
    if license_active in {"0", "false", "no", "inactive"}:
        return False
    expire_at = parse_datetime(state.get("expire_at", ""))
    return bool(state.get("phone")) and expire_at is not None and expire_at > datetime.now()


def days_left(state: dict[str, str]) -> int:
    expire_at = parse_datetime(state.get("expire_at", ""))
    if expire_at is None:
        return 0
    return max((expire_at.date() - datetime.now().date()).days, 0)


def is_offline_usable(state: dict[str, str], grace_days: int) -> bool:
    if not is_state_active(state):
        return False
    verified_at = parse_datetime(state.get("last_verified_at", ""))
    if verified_at is None:
        return False
    return datetime.now() <= verified_at + timedelta(days=max(grace_days, 0))


def get_device_name() -> str:
    node = platform.node() or socket.gethostname() or "unknown-device"
    user = getpass.getuser() or "unknown-user"
    return f"{node}\\{user}"


def get_device_id() -> str:
    remembered = load_device_identity().get("device_id", "").strip()
    if len(remembered) >= 16:
        return remembered
    raw = "|".join(
        [
            platform.node(),
            getpass.getuser(),
            str(uuid.getnode()),
            platform.platform(),
        ]
    )
    return hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest()


@dataclass(frozen=True)
class AuthClientConfig:
    server_url: str
    timeout_seconds: int = 10
    use_system_proxy: bool = False


class OnlineAuthClient:
    def __init__(self, config: AuthClientConfig) -> None:
        self.config = config
        self.server_url = config.server_url.rstrip("/")
        proxy_handler = request.ProxyHandler() if config.use_system_proxy else request.ProxyHandler({})
        self._opener = request.build_opener(proxy_handler)

    def login(self, phone: str, password: str, app_version: str) -> dict[str, str]:
        payload = {
            "phone": phone,
            "password": password,
            "device_id": get_device_id(),
            "device_name": get_device_name(),
            "app_version": app_version,
        }
        data = self._post_json("/api/auth/login", payload)
        return self._state_from_response(data)

    def request_login_code(self, phone: str) -> dict[str, Any]:
        return self._post_json("/api/auth/request-code", {"phone": phone})

    def get_membership_plans(self, phone: str = "") -> dict[str, Any]:
        query = f"?phone={quote(phone, safe='')}" if phone else ""
        return self._get_json(f"/api/pay/plans{query}")

    def create_payment_order(self, phone: str, plan_id: str, provider: str) -> dict[str, Any]:
        return self._post_json(
            "/api/pay/create-order",
            {"phone": phone, "plan_id": plan_id, "provider": provider},
        )

    def get_payment_order(self, order_no: str) -> dict[str, Any]:
        return self._get_json(f"/api/pay/order/{order_no}")

    def get_my_payment_orders(self, state: dict[str, str]) -> dict[str, Any]:
        token = state.get("access_token", "")
        if not token:
            raise AuthClientError("本地未保存登录令牌，请重新登录")
        return self._get_json("/api/pay/my-orders", token=token)

    def request_refund(self, state: dict[str, str], order_no: str, reason: str = "") -> dict[str, Any]:
        token = state.get("access_token", "")
        if not token:
            raise AuthClientError("本地未保存登录令牌，请重新登录")
        return self._post_json("/api/refunds/request", {"order_no": order_no, "reason": reason}, token=token)

    def login_with_code(self, phone: str, code: str, app_version: str) -> dict[str, str]:
        payload = {
            "phone": phone,
            "code": code,
            "device_id": get_device_id(),
            "device_name": get_device_name(),
            "app_version": app_version,
        }
        data = self._post_json("/api/auth/code-login", payload)
        return self._state_from_response(data)

    def start_wechat_login(self, app_version: str) -> dict[str, Any]:
        payload = {
            "device_id": get_device_id(),
            "device_name": get_device_name(),
            "app_version": app_version,
        }
        return self._post_json("/api/auth/wechat/start", payload)

    def get_wechat_login_status(self, state: str) -> dict[str, Any]:
        return self._get_json(f"/api/auth/wechat/status?state={quote(state, safe='')}")

    def bind_wechat_phone(self, state: str, phone: str, code: str, app_version: str) -> dict[str, Any]:
        payload = {
            "state": state,
            "phone": phone,
            "code": code,
            "device_id": get_device_id(),
            "device_name": get_device_name(),
            "app_version": app_version,
        }
        return self._post_json("/api/auth/wechat/bind-phone", payload)

    def confirm_wechat_device_replace(self, state: str) -> dict[str, Any]:
        return self._post_json("/api/auth/wechat/replace-device", {"state": state, "confirm": True})

    def request_bind_phone_code(self, state: dict[str, str], phone: str) -> dict[str, Any]:
        token = state.get("access_token", "")
        if not token:
            raise AuthClientError("本地未保存登录令牌，请重新登录")
        return self._post_json("/api/account/request-bind-phone-code", {"phone": phone}, token=token)

    def bind_account_phone(self, state: dict[str, str], phone: str, code: str) -> dict[str, str]:
        token = state.get("access_token", "")
        if not token:
            raise AuthClientError("本地未保存登录令牌，请重新登录")
        data = self._post_json("/api/account/bind-phone", {"phone": phone, "code": code}, token=token)
        new_state = dict(state)
        new_state.update(self._state_from_response(data))
        return new_state

    def auth_state_from_response(self, data: dict[str, Any]) -> dict[str, str]:
        return self._state_from_response(data)

    def heartbeat(self, state: dict[str, str], app_version: str) -> dict[str, str]:
        token = state.get("access_token", "")
        if not token:
            raise AuthClientError("本地未保存登录令牌，请重新登录")
        payload = {
            "device_id": state.get("device_id") or get_device_id(),
            "device_name": state.get("device_name") or get_device_name(),
            "app_version": app_version,
        }
        data = self._post_json("/api/device/heartbeat", payload, token=token)
        new_state = dict(state)
        new_state.update(self._state_from_response(data))
        return new_state

    def report_calculation_usage(
        self,
        state: dict[str, str],
        app_version: str,
        module: str,
        method: str,
        action: str = "calculate",
    ) -> dict[str, Any]:
        token = state.get("access_token", "")
        if not token:
            raise AuthClientError("本地未保存登录令牌，请重新登录")
        payload = {
            "device_id": state.get("device_id") or get_device_id(),
            "device_name": state.get("device_name") or get_device_name(),
            "app_version": app_version,
            "module": module,
            "method": method,
            "action": action,
        }
        return self._post_json("/api/usage/calculation", payload, token=token)

    def get_latest_version(self, current_version: str, channel: str) -> dict[str, Any]:
        query = f"?current_version={current_version}&channel={channel}"
        return self._get_json(f"/api/app/version{query}")

    def _state_from_response(self, data: dict[str, Any]) -> dict[str, str]:
        user = data.get("user") if isinstance(data.get("user"), dict) else {}
        license_info = data.get("license") if isinstance(data.get("license"), dict) else {}
        trial_info = data.get("trial") if isinstance(data.get("trial"), dict) else {}
        purchase_rules = license_info.get("purchase_rules") if isinstance(license_info.get("purchase_rules"), dict) else {}
        if not trial_info and isinstance(license_info.get("trial"), dict):
            trial_info = license_info["trial"]
        expire_at = str(license_info.get("expire_at") or user.get("expire_at") or "")
        state = {
            "phone": str(user.get("phone") or data.get("phone") or ""),
            "nickname": str(
                user.get("nickname")
                or user.get("name")
                or data.get("nickname")
                or data.get("name")
                or ""
            ),
            "openid": str(user.get("openid") or data.get("openid") or ""),
            "unionid": str(user.get("unionid") or data.get("unionid") or ""),
            "plan": str(license_info.get("plan") or user.get("plan") or "在线授权"),
            "expire_at": expire_at,
            "license_mode": "online",
            "license_active": "1" if bool(license_info.get("active")) else "0",
            "max_devices": str(license_info.get("max_devices") or ""),
            "trial_enabled": "1" if bool(trial_info.get("enabled")) else "0",
            "trial_limit": str(trial_info.get("limit") if trial_info.get("limit") is not None else ""),
            "trial_used": str(trial_info.get("used") if trial_info.get("used") is not None else ""),
            "trial_remaining": str(trial_info.get("remaining") if trial_info.get("remaining") is not None else ""),
            "trial_exhausted": "1" if bool(trial_info.get("exhausted")) else "0",
            "monthly_membership_available": "1" if bool(purchase_rules.get("monthly_available", True)) else "0",
            "monthly_membership_used": "1" if bool(purchase_rules.get("monthly_used")) else "0",
            "monthly_membership_pending": "1" if bool(purchase_rules.get("monthly_pending")) else "0",
            "membership_purchase_rule": str(purchase_rules.get("monthly_rule") or ""),
            "server_url": self.server_url,
            "access_token": str(data.get("access_token") or ""),
            "token_type": str(data.get("token_type") or "bearer"),
            "device_id": str(data.get("device_id") or get_device_id()),
            "device_name": str(data.get("device_name") or get_device_name()),
            "last_verified_at": datetime.now().isoformat(timespec="seconds"),
        }
        refresh_token = data.get("refresh_token")
        if refresh_token:
            state["refresh_token"] = str(refresh_token)
        if not state["phone"] or not state["expire_at"]:
            raise AuthClientError("服务器返回的授权信息不完整")
        return state

    def _post_json(self, path: str, payload: dict[str, Any], token: str | None = None) -> dict[str, Any]:
        body = json.dumps(payload).encode("utf-8")
        headers = {"Content-Type": "application/json"}
        if token:
            headers["Authorization"] = f"Bearer {token}"
        req = request.Request(self.server_url + path, data=body, headers=headers, method="POST")
        return self._open_json(req)

    def _get_json(self, path: str, token: str | None = None) -> dict[str, Any]:
        headers = {}
        if token:
            headers["Authorization"] = f"Bearer {token}"
        req = request.Request(self.server_url + path, headers=headers, method="GET")
        return self._open_json(req)

    def _open_json(self, req: request.Request) -> dict[str, Any]:
        try:
            with self._opener.open(req, timeout=self.config.timeout_seconds) as response:
                raw = response.read().decode("utf-8")
        except error.HTTPError as exc:
            detail = exc.read().decode("utf-8", errors="ignore")
            if exc.code >= 500 and not _extract_error_message(detail):
                raise AuthClientError("授权服务器暂时繁忙，请稍后重试；若持续出现，请联系管理员检查服务器磁盘和服务状态。") from exc
            raise AuthServerRejectedError(_extract_error_message(detail) or f"服务器拒绝请求：HTTP {exc.code}") from exc
        except error.URLError as exc:
            raise AuthClientError(f"无法连接登录服务：{exc.reason}") from exc
        except TimeoutError as exc:
            raise AuthClientError("连接登录服务超时") from exc
        try:
            data = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise AuthClientError("服务器返回内容不是有效JSON") from exc
        if not isinstance(data, dict):
            raise AuthClientError("服务器返回内容格式不正确")
        return data


def _extract_error_message(raw: str) -> str:
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        if raw.strip().lower() == "internal server error":
            return ""
        return raw.strip()
    detail = data.get("detail") if isinstance(data, dict) else None
    if isinstance(detail, str):
        return detail
    return raw.strip()
