from __future__ import annotations

import hashlib
import json
import logging
import secrets
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Callable

from niannian.storage.cloud_base import parse_remote_root_parts
from niannian.storage.feimao_auth import feimao_request_headers
from niannian.storage.feimao_crypto import (
    build_device_headers,
    genak,
    rsa_encrypt_session_key,
    unwrap_response,
    wrap_request,
)

logger = logging.getLogger(__name__)

DEFAULT_FEIMAO_API_BASE = "https://fmpapi.feemoo.com"
FEIMAO_LEGACY_WEB_BASES = {
    "https://www.feemoo.com",
    "https://www.feimaoyun.com",
    "http://www.feemoo.com",
    "http://www.feimaoyun.com",
}


def normalize_feimao_api_base(api_base: str | None) -> str:
    raw = (api_base or DEFAULT_FEIMAO_API_BASE).rstrip("/")
    if raw in FEIMAO_LEGACY_WEB_BASES:
        return DEFAULT_FEIMAO_API_BASE
    return raw

FEIMAO_TIMEOUT_CAP = 8.0
FEIMAO_CDN_TIMEOUT = 600.0
FEIMAO_UPLOAD_PROGRESS_WAIT = 600.0
FEIMAO_UPLOAD_CDN_RETRIES = 3
FEIMAO_UPLOAD_PROGRESS_INTERVAL = 2.0
HttpOpener = Callable[..., Any]


class FeimaoAPIError(Exception):
    pass


def feimao_object_path(rel_path: str, remote_root: str) -> str:
    rel = rel_path.strip("/").replace("\\", "/")
    root = remote_root.strip("/")
    if not root:
        raise ValueError("remote_root must not be empty")
    if rel:
        return f"{root}/{rel}"
    return root


class FeimaoDriveClient:
    """飞猫 fmpapi 真网盘 client — disk-service list/modifolder/upload/selfDownload。"""

    def __init__(
        self,
        session_token: str,
        *,
        remote_root: str | None = None,
        api_base: str = DEFAULT_FEIMAO_API_BASE,
        timeout: float = FEIMAO_TIMEOUT_CAP,
        http_opener: HttpOpener | None = None,
        state_path: Path | None = None,
        cdn_timeout: float | None = None,
        upload_progress_wait: float | None = None,
    ) -> None:
        self._session_token = session_token
        self._root_parts = parse_remote_root_parts(remote_root)
        self._remote_root = "/".join(self._root_parts)
        self._api_base = normalize_feimao_api_base(api_base)
        self._timeout = min(float(timeout), FEIMAO_TIMEOUT_CAP)
        self._cdn_timeout = float(cdn_timeout if cdn_timeout is not None else FEIMAO_CDN_TIMEOUT)
        self._upload_progress_wait = float(
            upload_progress_wait if upload_progress_wait is not None else FEIMAO_UPLOAD_PROGRESS_WAIT
        )
        self._open = http_opener or urllib.request.urlopen
        self._state_path = state_path
        self._request_key = genak()
        self._request_sn = rsa_encrypt_session_key(self._request_key)
        self._device_key = ""
        self._dir_cache: dict[str, str] = {"": "0"}
        self._load_state()

    def physical_path(self, rel_path: str) -> str:
        return feimao_object_path(rel_path, self._remote_root)

    def ping(self) -> bool:
        try:
            raw, _ = self._api_post("/disk-service/file/diskCapacity", {})
            return str(raw.get("status")) == "1"
        except FeimaoAPIError:
            return False

    def upload_object(self, rel_path: str, data: bytes) -> None:
        parent_id, file_name = self._ensure_parent_dir(rel_path)
        self._delete_if_exists(parent_id, file_name)
        self._upload_file(parent_id, file_name, data)

    def download_object(self, rel_path: str) -> bytes | None:
        parent_id, file_name = self._parent_parts(rel_path)
        file_id = self._find_file_id(parent_id, file_name, want_file=True)
        if not file_id:
            return None
        raw, payload = self._api_post("/disk-service/file/selfDownload", {"file_id": file_id})
        if str(raw.get("status")) != "1" or not isinstance(payload, dict):
            raise FeimaoAPIError(f"selfDownload failed: {raw.get('msg', raw)}")
        url = payload.get("url")
        if not url:
            raise FeimaoAPIError("selfDownload missing url")
        return self._http_get(str(url))

    def _load_state(self) -> None:
        if not self._state_path or not self._state_path.is_file():
            self._device_key = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()
            return
        try:
            raw = json.loads(self._state_path.read_text(encoding="utf-8"))
            if raw.get("remote_root") != self._remote_root:
                self._device_key = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()
                return
            self._device_key = str(raw.get("device_key") or "")
            self._dir_cache = {"": "0", **{str(k): str(v) for k, v in (raw.get("dirs") or {}).items()}}
        except (OSError, json.JSONDecodeError, TypeError):
            self._device_key = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()

    def _save_state(self) -> None:
        if not self._state_path:
            return
        if not self._device_key:
            self._device_key = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()
        self._state_path.parent.mkdir(parents=True, exist_ok=True)
        dirs = {k: v for k, v in self._dir_cache.items() if k}
        payload = {
            "remote_root": self._remote_root,
            "device_key": self._device_key,
            "dirs": dirs,
        }
        self._state_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    def _headers(self, *, with_json: bool = False) -> dict[str, str]:
        headers = feimao_request_headers(self._session_token, with_json=with_json)
        headers.update(build_device_headers(self._device_key))
        headers["Content-Type"] = "application/x-www-form-urlencoded"
        headers["os"] = "pc"
        return headers

    def _api_post(self, path: str, payload: dict[str, Any]) -> tuple[dict[str, Any], Any]:
        body = urllib.parse.urlencode(wrap_request(payload, self._request_key, self._request_sn)).encode()
        url = f"{self._api_base}{path}"
        req = urllib.request.Request(url, data=body, method="POST", headers=self._headers())
        try:
            with self._open(req, timeout=self._timeout) as resp:
                raw = json.loads(resp.read().decode("utf-8", "replace"))
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", "replace") if exc.fp else str(exc)
            raise FeimaoAPIError(f"POST {path} HTTP {exc.code}: {detail[:200]}") from exc
        except json.JSONDecodeError as exc:
            raise FeimaoAPIError(f"POST {path} invalid JSON") from exc
        data = unwrap_response(raw.get("data"))
        if str(raw.get("status")) not in ("1", "0"):
            raise FeimaoAPIError(f"POST {path} unexpected status: {raw}")
        if str(raw.get("status")) == "0":
            msg = raw.get("msg") or (data or {}).get("e", {}).get("msg") if isinstance(data, dict) else raw
            raise FeimaoAPIError(f"POST {path} failed: {msg}")
        return raw, data

    def _http_get(self, url: str) -> bytes:
        req = urllib.request.Request(url, method="GET", headers=feimao_request_headers(self._session_token))
        try:
            with self._open(req, timeout=self._timeout) as resp:
                return resp.read()
        except urllib.error.HTTPError as exc:
            if exc.code == 404:
                return b""
            raise FeimaoAPIError(f"GET {url} HTTP {exc.code}") from exc

    def _multipart_post(
        self,
        url: str,
        fields: dict[str, str],
        *,
        timeout: float | None = None,
    ) -> dict[str, Any]:
        boundary = f"----feimao{secrets.token_hex(8)}"
        chunks: list[bytes] = []
        for key, value in fields.items():
            chunks.append(f"--{boundary}\r\n".encode())
            chunks.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
            chunks.append(f"{value}\r\n".encode())
        chunks.append(f"--{boundary}--\r\n".encode())
        body = b"".join(chunks)
        req = urllib.request.Request(
            url,
            data=body,
            method="POST",
            headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
        )
        try:
            with self._open(req, timeout=timeout if timeout is not None else self._timeout) as resp:
                return json.loads(resp.read().decode("utf-8", "replace"))
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", "replace") if exc.fp else str(exc)
            raise FeimaoAPIError(f"multipart POST failed HTTP {exc.code}: {detail[:200]}") from exc

    def _parent_parts(self, rel_path: str) -> tuple[str, str]:
        clean = rel_path.strip("/").replace("\\", "/")
        if not clean:
            raise FeimaoAPIError("rel_path must not be empty")
        parts = clean.split("/")
        if len(parts) == 1:
            root_id = self._ensure_dir_chain([])
            return root_id, parts[0]
        parent = self._ensure_dir_chain(parts[:-1])
        return parent, parts[-1]

    def _ensure_parent_dir(self, rel_path: str) -> tuple[str, str]:
        return self._parent_parts(rel_path)

    def _ensure_dir_chain(self, dir_parts: list[str]) -> str:
        current = "0"
        built: list[str] = []
        for part in self._root_parts + dir_parts:
            built.append(part)
            key = "/".join(built)
            cached = self._dir_cache.get(key)
            if cached:
                current = cached
                continue
            found = self._find_file_id(current, part, want_file=False)
            if found:
                current = found
            else:
                current = self._create_folder(current, part)
            self._dir_cache[key] = current
            self._save_state()
        return current

    def _create_folder(self, parent_id: str, name: str) -> str:
        _, data = self._api_post(
            "/disk-service/file/modifolder",
            {"name": name, "pid": parent_id, "id": ""},
        )
        if not isinstance(data, dict) or not data.get("folder_id"):
            raise FeimaoAPIError(f"modifolder failed for {name}: {data}")
        return str(data["folder_id"])

    def _list_children(self, folder_id: str) -> list[dict[str, Any]]:
        _, data = self._api_post(
            "/disk-service/file/list",
            {
                "folder_id": str(folder_id),
                "page": 0,
                "limit": 200,
                "keywords": "",
                "type": 0,
                "time": 1,
                "views": "",
                "down": "",
                "size": "",
                "tag_id": "",
            },
        )
        if not isinstance(data, dict):
            return []
        items = data.get("list") or []
        return items if isinstance(items, list) else []

    def _find_file_id(self, parent_id: str, name: str, *, want_file: bool) -> str | None:
        for item in self._list_children(parent_id):
            if str(item.get("name")) != name:
                continue
            is_file = str(item.get("is_file")) == "1"
            if want_file == is_file:
                return str(item.get("id"))
        return None

    def _cdn_call(self, label: str, fn: Callable[[], Any]) -> Any:
        last_exc: Exception | None = None
        for attempt in range(1, FEIMAO_UPLOAD_CDN_RETRIES + 1):
            try:
                return fn()
            except (FeimaoAPIError, urllib.error.URLError, TimeoutError, OSError) as exc:
                last_exc = exc
                msg = str(exc).lower()
                transient = any(
                    k in msg
                    for k in ("timeout", "timed out", "ssl", "handshake", "temporarily", "incomplete", "processing")
                )
                if not transient or attempt >= FEIMAO_UPLOAD_CDN_RETRIES:
                    raise
                logger.warning("feimao %s attempt %s/%s failed: %s", label, attempt, FEIMAO_UPLOAD_CDN_RETRIES, exc)
                time.sleep(min(4.0 * attempt, 12.0))
        raise FeimaoAPIError(f"{label} failed") from last_exc

    def _wait_upload_progress(self, progress_url: str, task_id: str) -> None:
        deadline = time.monotonic() + self._upload_progress_wait
        while time.monotonic() < deadline:
            prog = self._multipart_post(
                progress_url,
                {"task_id": task_id},
                timeout=self._cdn_timeout,
            )
            if str(prog.get("status")) != "1":
                raise FeimaoAPIError(f"upload progress failed: {prog.get('msg', prog)}")
            data = prog.get("data") if isinstance(prog.get("data"), dict) else {}
            if str(data.get("is_complete") or "0") == "1":
                return
            time.sleep(FEIMAO_UPLOAD_PROGRESS_INTERVAL)
        logger.warning(
            "feimao upload progress did not complete within %ss; trying merge",
            self._upload_progress_wait,
        )

    def _delete_if_exists(self, parent_id: str, file_name: str) -> None:
        file_id = self._find_file_id(parent_id, file_name, want_file=True)
        if not file_id:
            return
        try:
            self._api_post("/disk-service/recycle/add", {"file_ids": file_id})
        except FeimaoAPIError as exc:
            logger.warning("feimao recycle before upload failed for %s: %s", file_name, exc)

    def _upload_file(self, folder_id: str, file_name: str, data: bytes) -> None:
        digest = hashlib.md5(data).hexdigest()
        _, link = self._api_post(
            "/disk-service/file/getDirectUploadLink",
            {
                "folderid": str(folder_id),
                "type": 1,
                "hash": digest,
                "totalSize": len(data),
                "filename": file_name,
            },
        )
        if not isinstance(link, dict):
            raise FeimaoAPIError("getDirectUploadLink returned empty payload")
        if str(link.get("file_id") or "0") not in ("0", "", "None"):
            return
        task_id = str(link.get("task_id") or "")
        upload_part_url = str(link.get("upload_part_url") or "")
        merge_part_url = str(link.get("merge_part_url") or "")
        progress_url = str(link.get("progress_url") or "")
        if not task_id or not upload_part_url or not merge_part_url:
            raise FeimaoAPIError(f"getDirectUploadLink incomplete: {link}")

        def _sign() -> dict[str, Any]:
            resp = self._multipart_post(
                upload_part_url,
                {"task_id": task_id, "m": digest, "part_number": "1"},
                timeout=self._cdn_timeout,
            )
            if str(resp.get("status")) != "1":
                raise FeimaoAPIError(f"upload sign failed: {resp.get('msg', resp)}")
            return resp

        sign_resp = self._cdn_call("upload sign", _sign)
        sign_url = ((sign_resp.get("data") or {}).get("sign_url")) if isinstance(sign_resp.get("data"), dict) else None
        if not sign_url:
            raise FeimaoAPIError(f"upload sign missing url: {sign_resp}")

        def _put() -> None:
            put_req = urllib.request.Request(
                str(sign_url),
                data=data,
                method="PUT",
                headers={"Content-Type": "text/plain"},
            )
            try:
                with self._open(put_req, timeout=self._cdn_timeout) as resp:
                    status = getattr(resp, "status", 200)
                    if status >= 400:
                        raise FeimaoAPIError(f"upload PUT HTTP {status}")
            except urllib.error.HTTPError as exc:
                raise FeimaoAPIError(f"upload PUT HTTP {exc.code}") from exc

        self._cdn_call("upload PUT", _put)
        if progress_url:
            self._wait_upload_progress(progress_url, task_id)

        def _merge() -> None:
            merge_resp = self._multipart_post(
                merge_part_url,
                {"task_id": task_id},
                timeout=self._cdn_timeout,
            )
            if str(merge_resp.get("status")) == "1":
                return
            msg = str(merge_resp.get("msg") or merge_resp)
            status = str(merge_resp.get("status") or "")
            if status in ("10000", "10001") or any(
                k in msg.lower() for k in ("incomplete", "processing", "wait", "temporarily", "timeout")
            ):
                raise FeimaoAPIError(f"upload merge temporarily failed (status={status}): {msg}")
            raise FeimaoAPIError(f"upload merge failed: {msg}")

        self._cdn_call("upload merge", _merge)
