"""CLI transport client using gh/glab for API access.

When no API token is provided, falls back to the user's existing CLI login
via `gh api` (GitHub) or `glab api` (GitLab) subprocess calls.
"""

from __future__ import annotations

import asyncio
import json
import logging
import shutil
from typing import Any
from urllib.parse import urlencode

from ..errors import (
    AuthenticationFailed,
    NetworkError,
    NotFound,
    RequestFailed,
)
from ._transport import APIResponse

logger = logging.getLogger(__name__)


class CLIClient:
    """API client that delegates to gh/glab CLI tools via subprocess."""

    def __init__(self, binary: str, api_url: str | None = None):
        """
        Args:
            binary: CLI binary name ("gh" or "glab").
            api_url: Base API URL. For gh, paths are relative to api.github.com
                     by default. For glab, paths are relative to gitlab.com/api/v4.
                     If a custom URL is provided, full URLs are used.
        """
        self.binary = binary
        self.api_url = api_url
        self._verify_binary()

    def _verify_binary(self) -> None:
        """Check that the CLI binary exists on PATH."""
        if not shutil.which(self.binary):
            raise NetworkError(
                f"'{self.binary}' CLI not found on PATH. "
                f"Install it from https://cli.github.com or https://gitlab.com/gitlab-org/cli"
            )

    def _build_endpoint(self, url: str) -> str:
        """Convert a full API URL to a CLI-relative endpoint path.

        gh api expects paths like /repos/owner/repo/pulls/1
        glab api expects paths like /projects/:id/merge_requests/1
        """
        if self.api_url and url.startswith(self.api_url):
            return url[len(self.api_url) :]
        return url

    async def get(
        self,
        url: str,
        headers: dict[str, str] | None = None,
        params: dict[str, Any] | None = None,
        timeout: float | None = None,
    ) -> APIResponse:
        """Execute GET request via CLI."""
        endpoint = self._build_endpoint(url)

        # Append query params to endpoint
        if params:
            encoded = urlencode({k: v for k, v in params.items() if v is not None})
            separator = "&" if "?" in endpoint else "?"
            endpoint = f"{endpoint}{separator}{encoded}"

        args = [self.binary, "api", endpoint, "--include"]

        # Add custom headers (skip auth headers — CLI handles auth)
        if headers:
            for key, value in headers.items():
                args.extend(["-H", f"{key}: {value}"])

        return await self._exec(args, timeout=timeout)

    async def post(
        self,
        url: str,
        headers: dict[str, str] | None = None,
        json: dict[str, Any] | None = None,
    ) -> APIResponse:
        """Execute POST request via CLI."""
        endpoint = self._build_endpoint(url)

        args = [self.binary, "api", endpoint, "--include", "--method", "POST"]

        # Add custom headers
        if headers:
            for key, value in headers.items():
                args.extend(["-H", f"{key}: {value}"])

        # Pass JSON body via --input -
        stdin_data: bytes | None = None
        if json is not None:
            args.extend(["-H", "Content-Type: application/json", "--input", "-"])
            stdin_data = _json_encode(json)

        return await self._exec(args, stdin_data=stdin_data)

    async def close(self) -> None:
        """No-op — CLI client has no persistent connections."""
        pass

    async def _exec(
        self,
        args: list[str],
        stdin_data: bytes | None = None,
        timeout: float | None = None,
    ) -> APIResponse:
        """Run CLI command and parse response."""
        timeout_secs = timeout if timeout is not None else 120

        logger.debug(f"CLI exec: {' '.join(args)}")

        try:
            proc = await asyncio.create_subprocess_exec(
                *args,
                stdin=asyncio.subprocess.PIPE if stdin_data else None,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout, stderr = await asyncio.wait_for(
                proc.communicate(input=stdin_data),
                timeout=timeout_secs,
            )
        except TimeoutError as e:
            raise NetworkError(f"CLI command timed out after {timeout_secs}s") from e
        except FileNotFoundError as e:
            raise NetworkError(f"CLI binary '{self.binary}' not found") from e
        except OSError as e:
            raise NetworkError(f"Failed to execute CLI: {e}") from e

        err_output = stderr.decode("utf-8", errors="replace").strip()

        # Handle non-zero exit codes
        if proc.returncode != 0:
            output_text = stdout.decode("utf-8", errors="replace")
            self._handle_cli_error(proc.returncode, err_output, output_text)

        # Parse --include output: HTTP status line + headers + blank line + body
        return self._parse_include_response(stdout)

    def _parse_include_response(self, output: str | bytes) -> APIResponse:
        """Parse CLI --include output into APIResponse.

        Works with raw bytes to preserve binary payloads (e.g. ZIP archives).

        Format:
            HTTP/2.0 200 OK
            Header: Value
            ...
            <blank line>
            body (text or binary)
        """
        raw = output if isinstance(output, bytes) else output.encode("utf-8")

        # Find header/body boundary: \r\n\r\n or \n\n (whichever comes first)
        crlf_sep = raw.find(b"\r\n\r\n")
        lf_sep = raw.find(b"\n\n")

        if crlf_sep == -1 and lf_sep == -1:
            # No blank line separator — treat entire output as body
            return APIResponse(status_code=200, _body=raw.strip())

        # Pick the earliest separator found
        if crlf_sep != -1 and (lf_sep == -1 or crlf_sep <= lf_sep):
            header_bytes = raw[:crlf_sep]
            body = raw[crlf_sep + 4 :]  # skip \r\n\r\n
        else:
            header_bytes = raw[:lf_sep]
            body = raw[lf_sep + 2 :]  # skip \n\n

        # Decode only the header section as text
        header_text = header_bytes.decode("utf-8", errors="replace")
        lines = header_text.strip().split("\n")
        # Clean any remaining \r from mixed line endings
        lines = [line.rstrip("\r") for line in lines]

        # Parse status code from first line (e.g. "HTTP/2.0 200 OK")
        status_code = 200
        if lines:
            parts_status = lines[0].split(None, 2)
            if len(parts_status) >= 2:
                try:
                    status_code = int(parts_status[1])
                except ValueError:
                    pass

        # Parse headers
        headers: dict[str, str] = {}
        for line in lines[1:]:
            if ":" in line:
                key, _, value = line.partition(":")
                headers[key.strip().lower()] = value.strip()

        response = APIResponse(
            status_code=status_code,
            _body=body,
            headers=headers,
        )

        # Check for error status codes and raise domain exceptions
        self._check_response(response)

        return response

    def _check_response(self, response: APIResponse) -> None:
        """Check response status and raise appropriate errors."""
        if response.status_code in (401, 403):
            raise AuthenticationFailed(
                f"CLI authentication failed (HTTP {response.status_code}): {response.text[:200]}"
            )
        elif response.status_code == 404:
            raise NotFound("Resource", "not found")
        elif response.status_code >= 400:
            raise RequestFailed(response.status_code, response.text[:500])

    def _handle_cli_error(self, returncode: int | None, stderr: str, stdout: str) -> None:
        """Map CLI exit codes and stderr to domain exceptions."""
        # gh exit code 4 = authentication required
        if returncode == 4:
            raise AuthenticationFailed(
                f"'{self.binary}' authentication required. Run '{self.binary} auth login' first."
            )

        # Try to extract HTTP error from stdout (--include may still have output)
        if stdout.strip():
            try:
                _ = self._parse_include_response(stdout)
                # _parse_include_response already raises on error status codes
                return  # If we got here, it parsed successfully
            except Exception:
                pass  # Fall through to generic error

        msg = stderr or stdout or f"exit code {returncode}"
        raise NetworkError(f"'{self.binary} api' failed: {msg}")


def _json_encode(data: Any) -> bytes:
    """Encode data as JSON bytes."""
    return json.dumps(data).encode("utf-8")
