from __future__ import annotations

import json
import logging
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

from niannian.storage.cloud_auth.network_errors import is_transient_network_error
from niannian.storage.cloud_base import parse_remote_root_parts
from niannian.storage.cloud_http import HttpOpener, open_with_strip_auth_redirect
from niannian.storage.upload_checkpoint import ChunkedUploadHooks, UploadCheckpointStore
from niannian.storage.upload_progress import UploadProgressTracker

logger = logging.getLogger(__name__)

DEFAULT_GRAPH_BASE = "https://graph.microsoft.com/v1.0"
DEFAULT_UPLOAD_MAX_BYTES = 4 * 1024 * 1024
DEFAULT_CHUNK_SIZE_BYTES = 4 * 1024 * 1024
DEFAULT_MAX_UPLOAD_BYTES = 512 * 1024 * 1024
DEFAULT_PING_TIMEOUT = 8.0

def _split_chunks(data: bytes, chunk_size: int) -> list[bytes]:
    if chunk_size <= 0:
        raise ValueError("chunk_size must be positive")
    return [data[i : i + chunk_size] for i in range(0, len(data), chunk_size)]


class OneDriveAPIError(Exception):
    pass


def onedrive_path(rel_path: str, remote_root: str) -> str:
    """rel_path sync/manifest.json → apps/念念/data/sync/manifest.json (Graph colon path)."""
    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


def _quote_graph_path(path: str) -> str:
    clean = path.strip("/")
    if not clean:
        return ""
    return urllib.parse.quote(clean, safe="/")


class OneDriveGraphClient:
    """Microsoft Graph small-file upload/download under remote_root."""

    def __init__(
        self,
        access_token: str,
        *,
        remote_root: str | None = None,
        graph_base: str = DEFAULT_GRAPH_BASE,
        upload_max_bytes: int = DEFAULT_UPLOAD_MAX_BYTES,
        chunk_size_bytes: int = DEFAULT_CHUNK_SIZE_BYTES,
        max_upload_bytes: int = DEFAULT_MAX_UPLOAD_BYTES,
        timeout: float = 60.0,
        http_opener: HttpOpener | None = None,
        state_path: Path | None = None,
        credential_manager: Any | None = None,
    ) -> None:
        self._access_token = access_token
        self._root_parts = parse_remote_root_parts(remote_root)
        self._remote_root = "/".join(self._root_parts)
        self._graph_base = graph_base.rstrip("/")
        self._upload_max_bytes = int(upload_max_bytes)
        self._chunk_size_bytes = int(chunk_size_bytes)
        self._max_upload_bytes = int(max_upload_bytes)
        self._timeout = timeout
        self._open = http_opener or open_with_strip_auth_redirect
        self._state_path = state_path
        self._credential_manager = credential_manager
        self._dir_cache: dict[str, str] = {}
        self._load_state()

    @property
    def access_token(self) -> str:
        return self._access_token

    def physical_path(self, rel_path: str) -> str:
        return f"/{onedrive_path(rel_path, self._remote_root)}"

    def tier_ping(self) -> bool:
        try:
            self._request_json(
                "GET",
                f"/me/drive/root:/{_quote_graph_path(self._remote_root)}",
                timeout=DEFAULT_PING_TIMEOUT,
                auth_retry=False,
            )
            return True
        except OneDriveAPIError:
            return False
        except Exception as exc:
            if is_transient_network_error(exc):
                return False
            raise

    def upload_object(
        self,
        rel_path: str,
        data: bytes,
        *,
        progress: UploadProgressTracker | None = None,
        checkpoint: UploadCheckpointStore | None = None,
        job_id: str | None = None,
    ) -> None:
        if len(data) > self._max_upload_bytes:
            logger.warning(
                "OneDrive upload rejected: %s exceeds max_upload_bytes (%d > %d)",
                rel_path,
                len(data),
                self._max_upload_bytes,
            )
            raise OneDriveAPIError(
                f"file too large: {rel_path} ({len(data)} bytes > max {self._max_upload_bytes})"
            )
        graph_path = onedrive_path(rel_path, self._remote_root)
        self._ensure_parent_dirs(graph_path)
        if len(data) <= self._upload_max_bytes:
            url = f"{self._graph_base}/me/drive/root:/{_quote_graph_path(graph_path)}:/content"
            self._request(url, method="PUT", data=data, content_type="application/octet-stream")
            return
        hooks = ChunkedUploadHooks(
            provider="onedrive",
            rel_path=rel_path,
            remote_root=self._remote_root,
            data=data,
            chunk_size=self._chunk_size_bytes,
            progress=progress,
            checkpoint=checkpoint,
            job_id=job_id,
        )
        if hooks.enabled:
            resume = hooks.begin()
            try:
                self._upload_resumable(graph_path, data, hooks=hooks, resume=resume)
                hooks.done()
            except Exception as exc:
                hooks.failed(str(exc))
                raise
        else:
            self._upload_resumable(graph_path, data)

    def download_object(self, rel_path: str) -> bytes | None:
        graph_path = onedrive_path(rel_path, self._remote_root)
        url = f"{self._graph_base}/me/drive/root:/{_quote_graph_path(graph_path)}:/content"
        try:
            return self._request(url, method="GET")
        except OneDriveAPIError as exc:
            if "HTTP 404" in str(exc):
                return None
            raise

    def _load_state(self) -> None:
        if not self._state_path or not self._state_path.is_file():
            return
        try:
            raw = json.loads(self._state_path.read_text(encoding="utf-8"))
            if raw.get("remote_root") != self._remote_root:
                return
            self._dir_cache = dict(raw.get("dirs") or {})
        except (OSError, json.JSONDecodeError):
            pass

    def _save_state(self) -> None:
        if not self._state_path:
            return
        self._state_path.parent.mkdir(parents=True, exist_ok=True)
        payload = {"remote_root": self._remote_root, "dirs": self._dir_cache}
        self._state_path.write_text(
            json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )

    def _upload_resumable(
        self,
        graph_path: str,
        data: bytes,
        *,
        hooks: ChunkedUploadHooks | None = None,
        resume: Any | None = None,
    ) -> None:
        total = len(data)
        chunks = _split_chunks(data, self._chunk_size_bytes)
        upload_url: str | None = None
        start_chunk = 0
        if resume is not None and resume.provider_state.get("upload_url"):
            upload_url = str(resume.provider_state["upload_url"])
            start_chunk = int(resume.start_chunk_index)

        if not upload_url:
            session_path = f"/me/drive/root:/{_quote_graph_path(graph_path)}:/createUploadSession"
            session = self._request_json(
                "POST",
                session_path,
                body={"item": {"@microsoft.graph.conflictBehavior": "replace"}},
            )
            upload_url = session.get("uploadUrl")
            if not upload_url:
                raise OneDriveAPIError("createUploadSession missing uploadUrl")

        offset = 0
        for chunk_index, chunk in enumerate(chunks):
            if chunk_index < start_chunk:
                offset += len(chunk)
                continue
            start = offset
            end = offset + len(chunk) - 1
            content_range = f"bytes {start}-{end}/{total}"
            self._request(
                upload_url,
                method="PUT",
                data=chunk,
                content_type="application/octet-stream",
                extra_headers={"Content-Range": content_range},
                use_auth=False,
            )
            offset = end + 1
            if hooks is not None:
                hooks.after_chunk(
                    chunk_index,
                    provider_state={
                        "upload_url": upload_url,
                        "graph_path": graph_path,
                        "next_offset": offset,
                    },
                )

    def _headers(
        self,
        *,
        content_type: str | None = None,
        extra_headers: dict[str, str] | None = None,
        use_auth: bool = True,
    ) -> dict[str, str]:
        headers: dict[str, str] = {"User-Agent": "niannian/0.2.9"}
        if use_auth:
            headers["Authorization"] = f"Bearer {self._access_token}"
        if content_type:
            headers["Content-Type"] = content_type
        if extra_headers:
            headers.update(extra_headers)
        return headers

    def _request(
        self,
        url: str,
        *,
        method: str = "GET",
        data: bytes | None = None,
        content_type: str | None = None,
        extra_headers: dict[str, str] | None = None,
        use_auth: bool = True,
        auth_retry: bool = True,
        timeout: float | None = None,
    ) -> bytes:
        req = urllib.request.Request(
            url,
            data=data,
            method=method,
            headers=self._headers(
                content_type=content_type,
                extra_headers=extra_headers,
                use_auth=use_auth,
            ),
        )
        req_timeout = self._timeout if timeout is None else timeout
        try:
            with self._open(req, timeout=req_timeout) as resp:
                return resp.read()
        except urllib.error.HTTPError as exc:
            if method == "GET" and exc.code in (301, 302, 303, 307, 308):
                location = exc.headers.get("Location")
                if location:
                    return self._request(
                        location,
                        method="GET",
                        use_auth=False,
                        auth_retry=False,
                        timeout=timeout,
                    )
            if (
                auth_retry
                and use_auth
                and exc.code == 401
                and self._credential_manager is not None
            ):
                refresh = self._credential_manager.refresh("onedrive")
                if refresh.ok:
                    new_token = self._credential_manager.get_access_token("onedrive")
                    if new_token:
                        self._access_token = new_token
                        return self._request(
                            url,
                            method=method,
                            data=data,
                            content_type=content_type,
                            extra_headers=extra_headers,
                            use_auth=use_auth,
                            auth_retry=False,
                            timeout=timeout,
                        )
            detail = exc.read().decode("utf-8", "replace") if exc.fp else str(exc)
            raise OneDriveAPIError(f"HTTP {exc.code}: {detail[:200]}") from exc

    def _request_json(
        self,
        method: str,
        path: str,
        *,
        body: dict[str, Any] | None = None,
        timeout: float | None = None,
        auth_retry: bool = True,
    ) -> dict[str, Any]:
        url = f"{self._graph_base}{path}"
        data = None
        content_type = None
        if body is not None:
            data = json.dumps(body, ensure_ascii=False).encode("utf-8")
            content_type = "application/json"
        raw = self._request(
            url,
            method=method,
            data=data,
            content_type=content_type,
            timeout=timeout,
            auth_retry=auth_retry,
        )
        if not raw:
            return {}
        try:
            payload = json.loads(raw.decode("utf-8", "replace"))
        except json.JSONDecodeError as exc:
            raise OneDriveAPIError(f"invalid JSON: {raw[:200]!r}") from exc
        if isinstance(payload, dict) and payload.get("error"):
            err = payload["error"]
            msg = err.get("message") if isinstance(err, dict) else str(err)
            raise OneDriveAPIError(f"Graph error: {msg}")
        return payload if isinstance(payload, dict) else {}

    def _ensure_parent_dirs(self, file_path: str) -> None:
        parts = [p for p in file_path.split("/") if p]
        if len(parts) <= 1:
            return
        built: list[str] = []
        for part in parts[:-1]:
            built.append(part)
            key = "/".join(built)
            if key in self._dir_cache:
                continue
            graph_key = _quote_graph_path(key)
            try:
                self._request_json("GET", f"/me/drive/root:/{graph_key}")
            except OneDriveAPIError:
                parent_key = "/".join(built[:-1])
                if parent_key:
                    parent = f"/me/drive/root:/{_quote_graph_path(parent_key)}:/children"
                else:
                    parent = "/me/drive/root/children"
                self._request_json("POST", parent, body={"name": part, "folder": {}, "@microsoft.graph.conflictBehavior": "replace"})
            self._dir_cache[key] = key
            self._save_state()
