"""
Gitea REST API Client

Handles all HTTP communication with Gitea REST API.
Separates API communication from business logic.
"""

import base64
import logging
from typing import Any

import aiohttp


class GiteaClient:
    """
    Gitea REST API client for HTTP communication.

    Handles authentication, request formatting, and error handling
    for all Gitea API endpoints.
    """

    def __init__(self, site_url: str, token: str | None = None, oauth_enabled: bool = False):
        """
        Initialize Gitea API client.

        Args:
            site_url: Gitea instance URL (e.g., https://gitea.example.com)
            token: Personal access token for authentication
            oauth_enabled: Whether OAuth is enabled for this site
        """
        self.site_url = site_url.rstrip("/")
        self.api_base = f"{self.site_url}/api/v1"
        self.token = token
        self.oauth_enabled = oauth_enabled

        # Initialize logger
        self.logger = logging.getLogger(f"GiteaClient.{site_url}")

    def _get_headers(self, additional_headers: dict | None = None) -> dict[str, str]:
        """
        Get request headers with authentication.

        Args:
            additional_headers: Additional headers to include

        Returns:
            Dict: Headers with authentication
        """
        headers = {"Content-Type": "application/json", "accept": "application/json"}

        # Add token authentication if available
        if self.token:
            headers["Authorization"] = f"token {self.token}"

        # Merge additional headers
        if additional_headers:
            headers.update(additional_headers)

        return headers

    async def request(
        self,
        method: str,
        endpoint: str,
        params: dict | None = None,
        json_data: dict | None = None,
        headers_override: dict | None = None,
    ) -> Any:
        """
        Make authenticated request to Gitea REST API.

        Args:
            method: HTTP method (GET, POST, PUT, DELETE, PATCH)
            endpoint: API endpoint (without base URL)
            params: Query parameters
            json_data: JSON body data
            headers_override: Override default headers

        Returns:
            API response (dict, list, or None)

        Raises:
            Exception: On API errors with status code and message
        """
        # Build full URL
        url = f"{self.api_base}/{endpoint.lstrip('/')}"

        # Setup headers
        headers = self._get_headers(headers_override)

        # Filter out None values from params
        if params:
            params = {k: v for k, v in params.items() if v is not None}

        # Filter None values from JSON data
        if json_data:
            json_data = {k: v for k, v in json_data.items() if v is not None}

        # Make request
        self.logger.debug(f"{method} {url}")
        self.logger.debug(f"Params: {params}")
        self.logger.debug(f"Data: {json_data}")

        async with (
            aiohttp.ClientSession() as session,
            session.request(
                method=method, url=url, params=params, json=json_data, headers=headers
            ) as response,
        ):
            # Log response
            self.logger.debug(f"Response status: {response.status}")

            # Handle empty responses (e.g., 204 No Content)
            if response.status == 204:
                return {"success": True, "message": "Operation completed successfully"}

            # Try to parse JSON response
            try:
                response_data = await response.json()
            except Exception:
                response_text = await response.text()
                if response.status >= 400:
                    raise Exception(f"Gitea API error (status {response.status}): {response_text}")
                return {"success": True, "message": response_text}

            # Check for errors
            if response.status >= 400:
                error_msg = response_data.get("message", "Unknown error")
                raise Exception(f"Gitea API error (status {response.status}): {error_msg}")

            return response_data

    # Repository endpoints
    async def list_repositories(
        self, owner: str | None = None, page: int = 1, limit: int = 30
    ) -> list[dict]:
        """List repositories for a user/org or current user"""
        if owner:
            endpoint = f"users/{owner}/repos"
        else:
            endpoint = "user/repos"

        params = {"page": page, "limit": limit}
        return await self.request("GET", endpoint, params=params)

    async def get_repository(self, owner: str, repo: str) -> dict:
        """Get repository details"""
        return await self.request("GET", f"repos/{owner}/{repo}")

    async def create_repository(self, data: dict, org: str | None = None) -> dict:
        """Create a new repository"""
        if org:
            endpoint = f"orgs/{org}/repos"
        else:
            endpoint = "user/repos"
        return await self.request("POST", endpoint, json_data=data)

    async def update_repository(self, owner: str, repo: str, data: dict) -> dict:
        """Update repository settings"""
        return await self.request("PATCH", f"repos/{owner}/{repo}", json_data=data)

    async def delete_repository(self, owner: str, repo: str) -> dict:
        """Delete a repository"""
        return await self.request("DELETE", f"repos/{owner}/{repo}")

    # Branch endpoints
    async def list_branches(
        self, owner: str, repo: str, page: int = 1, limit: int = 30
    ) -> list[dict]:
        """List repository branches"""
        params = {"page": page, "limit": limit}
        return await self.request("GET", f"repos/{owner}/{repo}/branches", params=params)

    async def get_branch(self, owner: str, repo: str, branch: str) -> dict:
        """Get branch details"""
        return await self.request("GET", f"repos/{owner}/{repo}/branches/{branch}")

    async def create_branch(self, owner: str, repo: str, data: dict) -> dict:
        """Create a new branch"""
        return await self.request("POST", f"repos/{owner}/{repo}/branches", json_data=data)

    async def delete_branch(self, owner: str, repo: str, branch: str) -> dict:
        """Delete a branch"""
        return await self.request("DELETE", f"repos/{owner}/{repo}/branches/{branch}")

    # Tag endpoints
    async def list_tags(self, owner: str, repo: str, page: int = 1, limit: int = 30) -> list[dict]:
        """List repository tags"""
        params = {"page": page, "limit": limit}
        return await self.request("GET", f"repos/{owner}/{repo}/tags", params=params)

    async def create_tag(self, owner: str, repo: str, data: dict) -> dict:
        """Create a new tag"""
        return await self.request("POST", f"repos/{owner}/{repo}/tags", json_data=data)

    async def delete_tag(self, owner: str, repo: str, tag: str) -> dict:
        """Delete a tag"""
        return await self.request("DELETE", f"repos/{owner}/{repo}/tags/{tag}")

    # File endpoints
    async def get_file(self, owner: str, repo: str, filepath: str, ref: str | None = None) -> dict:
        """Get file contents"""
        params = {"ref": ref} if ref else {}
        return await self.request("GET", f"repos/{owner}/{repo}/contents/{filepath}", params=params)

    async def create_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
        """Create a file"""
        if "content" in data:
            data["content"] = self._normalise_file_content(
                data["content"], data.get("content_is_base64", False)
            )
            data.pop("content_is_base64", None)

        return await self.request(
            "POST", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
        )

    async def update_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
        """Update a file"""
        if "content" in data:
            data["content"] = self._normalise_file_content(
                data["content"], data.get("content_is_base64", False)
            )
            data.pop("content_is_base64", None)

        return await self.request(
            "PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
        )

    @staticmethod
    def _normalise_file_content(content: Any, is_already_base64: bool) -> str:
        """F.17 ergonomics: turn ``content`` into a base64 string suitable
        for Gitea's contents endpoints, with actionable error messages.

        ``is_already_base64=True`` validates that ``content`` decodes
        cleanly and re-emits it stripped of whitespace; if the decode
        fails, raise a message that tells the caller exactly how to
        recover (drop ``data:`` prefix, use ``content_is_base64=False``
        for raw text). ``is_already_base64=False`` UTF-8 + base64
        encodes the string.
        """
        if is_already_base64:
            if not isinstance(content, str):
                raise ValueError(
                    "content_is_base64=True but content is not a string. "
                    "Pass the raw base64 text; do not wrap it in bytes / dicts."
                )
            stripped = content.strip()
            if stripped.lower().startswith("data:"):
                raise ValueError(
                    "content looks like a data: URL (starts with 'data:'). "
                    "Strip the 'data:<mime>;base64,' prefix before sending — "
                    "Gitea expects the base64 payload only."
                )
            try:
                # Validate round-trip and normalise whitespace.
                raw = base64.b64decode(stripped, validate=True)
                return base64.b64encode(raw).decode()
            except Exception as exc:  # noqa: BLE001
                raise ValueError(
                    "content_is_base64=True but content is not valid base64. "
                    "If you meant to send raw text, set content_is_base64=False "
                    "(the client will base64-encode it for you). Original "
                    f"decoder error: {exc}"
                ) from exc

        # Plain text / bytes → base64-encode for the caller.
        if isinstance(content, bytes):
            return base64.b64encode(content).decode()
        if isinstance(content, str):
            return base64.b64encode(content.encode("utf-8")).decode()
        raise ValueError(
            "content must be a string or bytes when content_is_base64=False. "
            f"Got {type(content).__name__}."
        )

    async def delete_file(
        self,
        owner: str,
        repo: str,
        filepath: str,
        sha: str,
        message: str,
        branch: str | None = None,
    ) -> dict:
        """Delete a file"""
        data = {"sha": sha, "message": message}
        if branch:
            data["branch"] = branch
        return await self.request(
            "DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
        )

    # ------------------------------------------------------------------
    # F.17 — ergonomics: batch file write, tree listing, search, compare,
    #         releases, fork.
    # ------------------------------------------------------------------

    async def change_files(
        self,
        owner: str,
        repo: str,
        data: dict,
    ) -> dict:
        """POST /repos/{owner}/{repo}/contents — apply a batch of file
        create/update/delete operations in a single commit.

        Gitea's ``files`` endpoint takes a list of ``{operation, path,
        content, sha}`` entries. Operations: ``create`` | ``update`` |
        ``delete``. ``content`` must be base64 for create/update.
        """
        return await self.request("POST", f"repos/{owner}/{repo}/contents", json_data=data)

    async def get_tree(
        self,
        owner: str,
        repo: str,
        sha: str = "HEAD",
        *,
        recursive: bool = False,
        page: int = 1,
        per_page: int = 100,
    ) -> dict:
        """GET /repos/{owner}/{repo}/git/trees/{sha}

        Returns ``{sha, url, tree: [{path, mode, type, size, sha, url}],
        truncated}``. Set ``recursive=True`` to get the entire tree.
        """
        params: dict[str, Any] = {"page": page, "per_page": per_page}
        if recursive:
            params["recursive"] = "true"
        return await self.request("GET", f"repos/{owner}/{repo}/git/trees/{sha}", params=params)

    async def search_code(
        self,
        *,
        keyword: str,
        owner: str | None = None,
        repo: str | None = None,
        page: int = 1,
        per_page: int = 30,
    ) -> dict:
        """Search code either across all repos (``/repos/search/code``)
        or within a specific repo (``/repos/{owner}/{repo}/search/code``).

        Returns the raw Gitea payload: ``{ok, data: [...]}``.
        """
        params: dict[str, Any] = {"q": keyword, "page": page, "per_page": per_page}
        if owner and repo:
            path = f"repos/{owner}/{repo}/search/code"
        else:
            path = "repos/search/code"
        return await self.request("GET", path, params=params)

    async def compare(
        self,
        owner: str,
        repo: str,
        base: str,
        head: str,
    ) -> dict:
        """GET /repos/{owner}/{repo}/compare/{base}...{head}"""
        # Gitea's compare endpoint uses ``...`` as the separator. The HTTP
        # client URL-encodes path params, so we pre-join instead of
        # passing them as separate path parameters.
        spec = f"{base}...{head}"
        return await self.request("GET", f"repos/{owner}/{repo}/compare/{spec}")

    async def list_releases(
        self,
        owner: str,
        repo: str,
        *,
        page: int = 1,
        per_page: int = 30,
    ) -> list[dict]:
        """GET /repos/{owner}/{repo}/releases"""
        return await self.request(
            "GET",
            f"repos/{owner}/{repo}/releases",
            params={"page": page, "per_page": per_page},
        )

    async def create_release(
        self,
        owner: str,
        repo: str,
        data: dict,
    ) -> dict:
        """POST /repos/{owner}/{repo}/releases"""
        return await self.request("POST", f"repos/{owner}/{repo}/releases", json_data=data)

    async def get_release(self, owner: str, repo: str, release_id: int) -> dict:
        return await self.request("GET", f"repos/{owner}/{repo}/releases/{release_id}")

    async def delete_release(self, owner: str, repo: str, release_id: int) -> dict:
        return await self.request("DELETE", f"repos/{owner}/{repo}/releases/{release_id}")

    async def upload_release_asset(
        self,
        owner: str,
        repo: str,
        release_id: int,
        *,
        filename: str,
        content_b64: str,
    ) -> dict:
        """Upload a release asset.

        Gitea's API accepts multipart/form-data on
        ``POST /repos/{owner}/{repo}/releases/{id}/assets?name=FILE``.
        We accept base64 content so the tool schema stays JSON-friendly;
        callers supply ``content_b64``. Decoded bytes go into the
        multipart body. This bypasses ``self.request`` because that
        helper only understands JSON bodies.
        """
        import aiohttp

        try:
            raw = base64.b64decode(content_b64, validate=True)
        except Exception as exc:  # noqa: BLE001
            raise ValueError(
                "content_b64 must be a valid base64 string. "
                "For small text files, base64-encode the UTF-8 bytes; "
                "do not include a 'data:' URL prefix."
            ) from exc

        url = f"{self.api_base}/repos/{owner}/{repo}/releases/{release_id}/assets"
        headers = self._get_headers()
        # Requests library-style: multipart/form-data with attachment field.
        form = aiohttp.FormData()
        form.add_field(
            "attachment",
            raw,
            filename=filename,
            content_type="application/octet-stream",
        )
        async with aiohttp.ClientSession() as session:
            async with session.post(
                url, params={"name": filename}, data=form, headers=headers
            ) as resp:
                text = await resp.text()
                if resp.status >= 400:
                    raise Exception(
                        f"Gitea upload_release_asset failed ({resp.status}): {text[:500]}"
                    )
                try:
                    import json as _json

                    return _json.loads(text) if text else {"success": True}
                except Exception:
                    return {"success": True, "raw": text[:500]}

    async def fork_repository(
        self,
        owner: str,
        repo: str,
        *,
        organization: str | None = None,
        name: str | None = None,
    ) -> dict:
        """POST /repos/{owner}/{repo}/forks

        ``organization``: target org (omit to fork under the caller).
        ``name``: custom name for the fork.
        """
        payload: dict[str, Any] = {}
        if organization:
            payload["organization"] = organization
        if name:
            payload["name"] = name
        return await self.request("POST", f"repos/{owner}/{repo}/forks", json_data=payload)

    # Issue endpoints
    async def list_issues(self, owner: str, repo: str, params: dict) -> list[dict]:
        """List repository issues"""
        return await self.request("GET", f"repos/{owner}/{repo}/issues", params=params)

    async def get_issue(self, owner: str, repo: str, index: int) -> dict:
        """Get issue details"""
        return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}")

    async def create_issue(self, owner: str, repo: str, data: dict) -> dict:
        """Create a new issue"""
        return await self.request("POST", f"repos/{owner}/{repo}/issues", json_data=data)

    async def update_issue(self, owner: str, repo: str, index: int, data: dict) -> dict:
        """Update an issue"""
        return await self.request("PATCH", f"repos/{owner}/{repo}/issues/{index}", json_data=data)

    async def list_issue_comments(self, owner: str, repo: str, index: int) -> list[dict]:
        """List issue comments"""
        return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}/comments")

    async def create_issue_comment(self, owner: str, repo: str, index: int, data: dict) -> dict:
        """Create issue comment"""
        return await self.request(
            "POST", f"repos/{owner}/{repo}/issues/{index}/comments", json_data=data
        )

    # Label endpoints
    async def list_labels(self, owner: str, repo: str) -> list[dict]:
        """List repository labels"""
        return await self.request("GET", f"repos/{owner}/{repo}/labels")

    async def create_label(self, owner: str, repo: str, data: dict) -> dict:
        """Create a label"""
        return await self.request("POST", f"repos/{owner}/{repo}/labels", json_data=data)

    async def delete_label(self, owner: str, repo: str, label_id: int) -> dict:
        """Delete a label"""
        return await self.request("DELETE", f"repos/{owner}/{repo}/labels/{label_id}")

    # Milestone endpoints
    async def list_milestones(self, owner: str, repo: str, state: str | None = None) -> list[dict]:
        """List repository milestones"""
        params = {"state": state} if state else {}
        return await self.request("GET", f"repos/{owner}/{repo}/milestones", params=params)

    async def create_milestone(self, owner: str, repo: str, data: dict) -> dict:
        """Create a milestone"""
        return await self.request("POST", f"repos/{owner}/{repo}/milestones", json_data=data)

    # Pull Request endpoints
    async def list_pull_requests(self, owner: str, repo: str, params: dict) -> list[dict]:
        """List repository pull requests"""
        return await self.request("GET", f"repos/{owner}/{repo}/pulls", params=params)

    async def get_pull_request(self, owner: str, repo: str, index: int) -> dict:
        """Get pull request details"""
        return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}")

    async def create_pull_request(self, owner: str, repo: str, data: dict) -> dict:
        """Create a new pull request"""
        return await self.request("POST", f"repos/{owner}/{repo}/pulls", json_data=data)

    async def update_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict:
        """Update a pull request"""
        return await self.request("PATCH", f"repos/{owner}/{repo}/pulls/{index}", json_data=data)

    async def merge_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict:
        """Merge a pull request"""
        return await self.request(
            "POST", f"repos/{owner}/{repo}/pulls/{index}/merge", json_data=data
        )

    async def list_pr_commits(self, owner: str, repo: str, index: int) -> list[dict]:
        """List pull request commits"""
        return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/commits")

    async def list_pr_files(self, owner: str, repo: str, index: int) -> list[dict]:
        """List pull request files"""
        return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/files")

    async def get_pr_diff(self, owner: str, repo: str, index: int) -> str:
        """Get pull request diff"""
        # Override accept header for diff
        headers = {"accept": "text/plain"}
        response = await self.request(
            "GET", f"repos/{owner}/{repo}/pulls/{index}.diff", headers_override=headers
        )
        return response

    async def list_pr_reviews(self, owner: str, repo: str, index: int) -> list[dict]:
        """List pull request reviews"""
        return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/reviews")

    async def create_pr_review(self, owner: str, repo: str, index: int, data: dict) -> dict:
        """Create pull request review"""
        return await self.request(
            "POST", f"repos/{owner}/{repo}/pulls/{index}/reviews", json_data=data
        )

    async def request_pr_reviewers(self, owner: str, repo: str, index: int, data: dict) -> dict:
        """Request pull request reviewers"""
        return await self.request(
            "POST", f"repos/{owner}/{repo}/pulls/{index}/requested_reviewers", json_data=data
        )

    # User endpoints
    async def get_user(self, username: str) -> dict:
        """Get user information"""
        return await self.request("GET", f"users/{username}")

    async def list_user_repos(self, username: str, page: int = 1, limit: int = 30) -> list[dict]:
        """List user repositories"""
        params = {"page": page, "limit": limit}
        return await self.request("GET", f"users/{username}/repos", params=params)

    async def search_users(self, query: str | None = None, uid: int | None = None) -> list[dict]:
        """Search users"""
        params = {}
        if query:
            params["q"] = query
        if uid:
            params["uid"] = uid
        response = await self.request("GET", "users/search", params=params)
        return response.get("data", [])

    # Organization endpoints
    async def list_organizations(self, page: int = 1, limit: int = 30) -> list[dict]:
        """List current user's organizations"""
        params = {"page": page, "limit": limit}
        return await self.request("GET", "user/orgs", params=params)

    async def get_organization(self, org: str) -> dict:
        """Get organization information"""
        return await self.request("GET", f"orgs/{org}")

    async def list_org_repos(self, org: str, page: int = 1, limit: int = 30) -> list[dict]:
        """List organization repositories"""
        params = {"page": page, "limit": limit}
        return await self.request("GET", f"orgs/{org}/repos", params=params)

    async def list_org_teams(self, org: str, page: int = 1, limit: int = 30) -> list[dict]:
        """List organization teams"""
        params = {"page": page, "limit": limit}
        return await self.request("GET", f"orgs/{org}/teams", params=params)

    async def list_team_members(self, team_id: int, page: int = 1, limit: int = 30) -> list[dict]:
        """List team members"""
        params = {"page": page, "limit": limit}
        return await self.request("GET", f"teams/{team_id}/members", params=params)

    # Webhook endpoints
    async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
        """List repository webhooks"""
        return await self.request("GET", f"repos/{owner}/{repo}/hooks")

    async def create_webhook(self, owner: str, repo: str, data: dict) -> dict:
        """Create a webhook"""
        return await self.request("POST", f"repos/{owner}/{repo}/hooks", json_data=data)

    async def get_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
        """Get webhook details"""
        return await self.request("GET", f"repos/{owner}/{repo}/hooks/{hook_id}")

    async def update_webhook(self, owner: str, repo: str, hook_id: int, data: dict) -> dict:
        """Update a webhook"""
        return await self.request("PATCH", f"repos/{owner}/{repo}/hooks/{hook_id}", json_data=data)

    async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
        """Delete a webhook"""
        return await self.request("DELETE", f"repos/{owner}/{repo}/hooks/{hook_id}")

    async def test_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
        """Test a webhook"""
        return await self.request("POST", f"repos/{owner}/{repo}/hooks/{hook_id}/tests")
