from __future__ import annotations

import os
from contextlib import contextmanager
from typing import BinaryIO

from cli115.api.web.p115client import check_response
from cli115.client.base import (
    FileClient,
    DEFAULT_PAGE_SIZE,
    MAX_PAGE_SIZE,
    MIN_INSTANT_UPLOAD_SIZE,
    RemoteFile,
)
from cli115.client.lazy import new_lazy_cls
from cli115.client.models import (
    Directory,
    DownloadUrl,
    File,
    FileSystemEntry,
    Pagination,
    Progress,
    SortField,
    SortOrder,
    UploadStatus,
)
from cli115.client.utils import parse_item, parse_ts
from cli115.client.webapi.base import BaseClient
from cli115.exceptions import InstantUploadNotAvailableError
from cli115.helpers import normalize_path, sha1_file, join_path


DEFAULT_USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0"
)


@contextmanager
def patch_filelike(file: BinaryIO, progress: Progress):
    original_read = file.read

    def patched_read(size=-1) -> bytes:
        chunk = original_read(size)
        progress.update(progress._completed_bytes + len(chunk))
        return chunk

    file.read = patched_read
    try:
        yield
    finally:
        file.read = original_read


class WebAPIFileClient(FileClient, BaseClient):

    # -- public API --

    def id(self, file_id: str) -> Directory | File:
        resp = self._api.fs_file(file_id)
        resp = check_response(resp)
        data = resp.get("data", [])
        if not data:
            raise FileNotFoundError(f"file id not found: {file_id}")
        item = parse_item(data[0])
        return new_lazy_cls(item, self)

    def stat(self, path: str) -> Directory | File:
        return self._resolve_entry(path)

    def _list(
        self,
        path: str | Directory = "/",
        *,
        sort: SortField = SortField.FILENAME,
        sort_order: SortOrder = SortOrder.ASC,
        limit: int = DEFAULT_PAGE_SIZE,
        offset: int = 0,
    ) -> tuple[list[Directory | File], Pagination]:
        if isinstance(path, Directory):
            dir_id = path.id
            path = path.path
        else:
            path = normalize_path(path)
            dir_id = self._resolve_dir_id(path)
        # both fs_order_set and fs_files need to be called to get correct
        # sorting results, otherwise the sorting parameters are ignored
        self._api.fs_order_set(
            {
                "file_id": dir_id,
                "user_order": sort.value,
                "user_asc": sort_order.value,
                "fc_mix": 1,
            }
        )
        resp = self._api.fs_files(
            {
                "cid": dir_id,
                "offset": offset,
                "limit": min(limit, MAX_PAGE_SIZE),
                "natsort": 1,
                "o": sort.value,
                "asc": sort_order.value,
                "fc_mix": 1,
            }
        )
        resp = check_response(resp)

        items: list[Directory | File] = []
        for raw in resp.get("data", []):
            item = parse_item(raw)
            if path is not None:
                item.path = join_path(path, item.name)
            items.append(item)

        pagination = Pagination(
            total=int(resp.get("count", 0)),
            offset=int(resp.get("offset", 0)),
            limit=int(resp.get("limit", limit)),
        )
        return items, pagination

    def _find(
        self,
        query: str,
        *,
        path: str | Directory | None = None,
        limit: int = DEFAULT_PAGE_SIZE,
        offset: int = 0,
    ) -> tuple[list[Directory | File], Pagination]:
        payload: dict = {
            "search_value": query,
            "offset": offset,
            "limit": min(limit, MAX_PAGE_SIZE),
        }
        if path is not None:
            payload["cid"] = self._resolve_dir_id(path)

        resp = self._api.fs_search(payload)
        resp = check_response(resp)

        items: list[Directory | File] = []
        for raw in resp.get("data", []):
            item = parse_item(raw)
            items.append(new_lazy_cls(item, self))

        pagination = Pagination(
            total=int(resp.get("count", 0)),
            offset=int(resp.get("offset", 0)),
            limit=int(resp.get("limit", limit)),
        )
        return items, pagination

    def create_directory(self, path: str, *, parents: bool = False) -> Directory:
        path = normalize_path(path)
        dirname = os.path.dirname(path)
        name = os.path.basename(path)

        try:
            pid = self._resolve_dir_id(dirname)
        except FileNotFoundError:
            if not parents:
                raise
            parent_dir = self.create_directory(dirname, parents=True)
            pid = parent_dir.id

        try:
            resp = self._api.fs_mkdir(name, pid=pid)
            resp = check_response(resp)
        except FileExistsError:
            if parents:
                return self.stat(path)  # directory already exists, return it
            raise
        return Directory(
            id=str(resp.get("cid") or resp.get("file_id", "")),
            parent_id=pid,
            name=resp.get("cname") or resp.get("file_name", ""),
            path=path,
            pickcode="",
            created_time=None,
            modified_time=None,
            open_time=None,
        )

    def delete(self, path: str | FileSystemEntry, *, recursive: bool = False) -> None:
        entry = self.stat(path)
        if not recursive and entry.is_directory:
            items = self.list(path)
            if len(items) > 0:
                raise FileExistsError(f"directory is not empty: {path}")
        resp = self._api.fs_delete(entry.id)
        check_response(resp)

    def batch_delete(
        self, *paths: str | FileSystemEntry, recursive: bool = False
    ) -> None:
        if recursive:
            raise NotImplementedError("recursive batch delete is not yet supported")
        ids = [self._resolve_id(p) for p in paths]
        resp = self._api.fs_delete(ids)
        check_response(resp)

    def rename(self, path: str | FileSystemEntry, name: str) -> None:
        file_id = self._resolve_id(path)
        resp = self._api.fs_rename((file_id, name))
        check_response(resp)

    def move(self, src: str | FileSystemEntry, dest_dir: str | Directory) -> None:
        src_id = self._resolve_id(src)
        dest_id = self._resolve_dir_id(dest_dir)
        resp = self._api.fs_move(src_id, pid=dest_id)
        check_response(resp)

    def batch_move(
        self, *srcs: str | FileSystemEntry, dest_dir: str | Directory
    ) -> None:
        src_ids = [self._resolve_id(s) for s in srcs]
        dest_id = self._resolve_dir_id(dest_dir)
        resp = self._api.fs_move(src_ids, pid=dest_id)
        check_response(resp)

    def copy(self, src: str | FileSystemEntry, dest_dir: str | Directory) -> None:
        src_id = self._resolve_id(src)
        dest_id = self._resolve_dir_id(dest_dir)
        resp = self._api.fs_copy(src_id, pid=dest_id)
        check_response(resp)

    def batch_copy(
        self, *srcs: str | FileSystemEntry, dest_dir: str | Directory
    ) -> None:
        src_ids = [self._resolve_id(s) for s in srcs]
        dest_id = self._resolve_dir_id(dest_dir)
        resp = self._api.fs_copy(src_ids, pid=dest_id)
        check_response(resp)

    def _upload(
        self,
        path: str,
        file: BinaryIO,
        *,
        instant_only: int | None = None,
        status: UploadStatus | None = None,
    ) -> File:
        if not status:
            status = UploadStatus()
        path = normalize_path(path)

        # raise an error if the file already exists
        try:
            self.stat(path)
        except FileNotFoundError:
            pass
        else:
            raise FileExistsError(f"remote path '{path}' already exists")

        parent_path = os.path.dirname(path)
        filename = os.path.basename(path)
        dir_id = self._resolve_dir_id(parent_path)

        sha1, file_size = sha1_file(file)
        with Progress(file_size) as progress:
            status.progress = progress
            progress.start()

            status.use_instant_upload = file_size >= MIN_INSTANT_UPLOAD_SIZE
            # Only attempt instant upload when the file meets the minimum size.
            if status.use_instant_upload:
                status.use_instant_upload = True
                force_instant = instant_only is not None and file_size >= instant_only
                try:
                    success = self._try_instant_upload(
                        file=file,
                        filename=filename,
                        file_size=file_size,
                        sha1=sha1,
                        dir_id=dir_id,
                        path=path,
                    )
                except Exception as exc:
                    if force_instant:
                        raise
                    status.instant_upload_error = exc
                else:
                    if success:
                        status.is_instant_uploaded = True
                        return self.stat(path)
                    if force_instant:
                        raise InstantUploadNotAvailableError(
                            "instant upload is not available for this file "
                            "(file not found on server)"
                        )
                file.seek(0)

            if isinstance(file, RemoteFile):
                file.set_stream(True)  # use streaming upload for RemoteFile

            status.is_instant_uploaded = False
            with patch_filelike(file, progress):
                resp = self._api.upload_file_sample(
                    file,
                    pid=dir_id,
                    filename=filename,
                )
            resp = check_response(resp)
            data = resp.get("data", {})

            return File(
                id=str(data.get("file_id", "")),
                parent_id=str(dir_id),
                name=data.get("file_name", ""),
                path=path,
                pickcode=data.get("pick_code", ""),
                created_time=parse_ts(data.get("file_ptime")),
                modified_time=None,
                open_time=None,
                sha1=data.get("sha1", ""),
                size=int(data.get("file_size", 0)),
            )

    def _try_instant_upload(
        self,
        *,
        file: BinaryIO,
        filename: str,
        file_size: int,
        sha1: str,
        dir_id: str,
        path: str,
    ) -> bool:
        """Attempt instant upload.  Return `True` on success, `False` if the
        server does not have this file and a regular upload is required."""

        def read_range(range_str: str) -> bytes:
            # sign_check format is "start-end" (inclusive), like HTTP Range.
            start, end = [int(x) for x in range_str.split("-")]
            file.seek(start)
            return file.read(end - start + 1)

        resp = self._api.upload_file_init(
            filename=filename,
            filesize=file_size,
            filesha1=sha1,
            read_range_bytes_or_hash=(read_range if file_size >= 1024 * 1024 else None),
            pid=dir_id,
        )

        return bool(resp.get("reuse"))

    def url(self, path: str | File, *, user_agent: str | None = None) -> DownloadUrl:
        entry = self._resolve_entry(path)
        if entry.is_directory:
            raise IsADirectoryError("cannot get download info for a directory")

        ua = user_agent or DEFAULT_USER_AGENT
        p115url = self._api.download_url(entry.pickcode, user_agent=ua)
        cookie_str = "; ".join(f"{k}={m.value}" for k, m in self._api.cookies.items())
        return DownloadUrl(
            url=str(p115url),
            file_name=entry.name,
            file_size=entry.size,
            sha1=entry.sha1,
            user_agent=ua,
            referer="https://115.com/",
            cookies=cookie_str,
        )

    # -- path resolution helpers --

    def _resolve_id(self, path: str | FileSystemEntry) -> str:
        entry = self._resolve_entry(path)
        return entry.id

    def _resolve_entry(self, path: str | FileSystemEntry) -> FileSystemEntry:
        if isinstance(path, FileSystemEntry):
            return path
        path = normalize_path(path)
        if path == "/":
            return Directory(
                id="0",
                parent_id="",
                path="/",
                name="/",
                pickcode="",
                created_time=None,
                modified_time=None,
                open_time=None,
                file_count=0,
            )
        dirname = os.path.dirname(path)
        name = os.path.basename(path)
        pid = self._resolve_dir_id(dirname)
        offset = 0
        while True:
            resp = self._api.fs_files(
                {
                    "cid": pid,
                    "aid": 1,
                    "offset": offset,
                    "limit": MAX_PAGE_SIZE,
                    "show_dir": 1,
                }
            )
            resp = check_response(resp)
            data = resp.get("data", [])
            for item in data:
                if item.get("n") == name:
                    rv = parse_item(item)
                    rv.path = path
                    return rv
            total = int(resp.get("count", 0))
            offset += len(data)
            if offset >= total or not data:
                break
        raise FileNotFoundError(f"entry not found: {path}")
