from __future__ import annotations

from http.client import HTTPException, HTTPResponse
import io
import os
import logging
from pathlib import Path
import shlex
from socket import socket
from urllib.parse import urljoin

from .util import DEFAULT_LOGGER, truncate
from .task import Task

__all__ = [
    "DEFAULT_CURL_PATH",
    "Fetch",
    "FetchCurlError",
    "FetchException",
    "FetchMeter",
    "FetchParseException",
    "FetchResponse",
]

DEFAULT_CURL_PATH = "curl"


class FetchException(HTTPException):
    """Exceptions raised by Fetch."""

    pass


class FetchParseException(FetchException):
    """HTTP parse exception."""

    pass


class FetchCurlError(FetchException):
    """Subprocess (cURL) error."""

    def __init__(self, code: int, stderr: bytes):
        if stderr:
            msg = stderr.strip().decode("utf-8", errors="ignore")
        else:
            msg = f"curl ({code})"
        self.code = code
        super().__init__(msg)


class FetchMeter:
    """
    An abstract class describing a meter that can be used with Fetch to provide
    a progress meter.
    """

    def sleep_duration(self) -> float | int:
        """Returns the time in seconds to sleep per iteration."""
        return 0.1

    def begin(self) -> None:
        """Called at the beginning of the loop."""
        pass

    def step(self, index: int) -> None:
        """
        Called with each loop iteration.

        The index argument will provide a running count, starting at 0.
        """
        pass

    def end(self) -> None:
        """Called at the end of the loop."""
        pass


class FetchResponse(HTTPResponse):
    """An HTTPResponse wrapper supporting static, raw bytes."""

    def __init__(
        self,
        raw: bytes,
        url: str,
        debuglevel: int = 0,
        method: str | None = None,
    ):
        """
        Patches HTTPResponse initializer to support bytes instead of a socket
        argument.
        """

        class FakeSocket(socket):
            def __init__(self, fp: io.IOBase) -> None:
                self.fp = fp

            def makefile(self, *args, **kwargs) -> io.IOBase:  # type: ignore
                return self.fp

        raw = self._patch_raw(raw)
        raw_stream = io.BytesIO(raw)
        sock = FakeSocket(raw_stream)
        super().__init__(sock, debuglevel=debuglevel, method=method, url=url)

        self.url = url  # assign url due to a bug in HTTPResponse
        self.begin()

    def begin(self) -> None:
        """Raises a FetchException when unable to parse the raw response."""
        try:
            super().begin()
        except HTTPException:
            raise FetchParseException("cannot parse response")

    @classmethod
    def make_list_from_raw(cls, raw_list: bytes, url: str) -> list[FetchResponse]:
        """
        Returns a list of FetchResponses from the raw bytes of one or more HEAD
        requests.
        """
        responses = []
        for raw in raw_list.strip().split(b"\r\n\r\n"):
            response = FetchResponse(raw=raw, url=url)
            responses.append(response)
            location = response.headers["location"]
            if location:
                url = urljoin(url, location)
        return responses

    @staticmethod
    def _patch_raw(raw: bytes) -> bytes:
        """
        Replaces HTTP/x with HTTP/1.1 in a raw response so HTTPResponse.begin()
        doesn't throw an exception. Also, adds a fake HTTP status line if we
        don't find one to handle non-HTTP protocols.
        """
        raw_lines = raw.strip().split(b"\r\n")
        parts = raw_lines[0].split(None, 1) if raw_lines else []
        if parts and parts[0].startswith(b"HTTP"):
            raw_lines[0] = b" ".join([b"HTTP/1.1"] + parts[1:])
        else:
            raw_lines.insert(0, b"HTTP/1.1 200 OK")
        return b"\r\n".join(raw_lines + [])


class Fetch:
    """Head and download request handler using cURL as a backend."""

    curl_path: Path
    logger: logging.Logger
    meter: FetchMeter | None

    def __init__(
        self,
        curl_path: os.PathLike | str | None = None,
        logger: logging.Logger | None = None,
        meter: FetchMeter | None = None,
    ) -> None:
        """
        Creates a new Fetch object.

        If meter is specified, then its methods will be called while subprocess
        is running. See FetchMeter for details.
        """
        if curl_path is None:
            curl_path = DEFAULT_CURL_PATH
        if logger is None:
            logger = DEFAULT_LOGGER

        self.curl_path = Path(curl_path)
        self.logger = logger
        self.meter = meter

    def download(
        self,
        url: str,
        path: os.PathLike | str,
        resume: bool = False,
    ) -> None:
        """
        Downloads url to path using cURL.

        Raises a FetchCurlError when cURL exits with an error.
        """
        args = [
            "--silent",
            "--show-error",
            "--fail",
            "--location",
            "--output",
            str(path),
            *(["--continue-at", "-"] if resume else []),
            "--",
            url,
        ]
        self._curl_run(args)

    def head(self, url: str) -> list[FetchResponse]:
        """
        Makes HEAD requests to url and returns a list of FetchResponses using
        cURL.

        Raises a FetchCurlError when cURL exits with an error.
        """
        args = [
            "--silent",
            "--show-error",
            "--location",
            "--head",
            "--",
            url,
        ]
        task = self._curl_run(args)
        if isinstance(task.stdout, bytes):
            return FetchResponse.make_list_from_raw(raw_list=task.stdout, url=url)
        else:
            raise RuntimeError(f"unexpected Task state: stdout={task.stdout!r}")

    def run(self, args: list) -> Task:
        """Runs a subprocess of args with Task, then returns the completed
        Task."""
        cmd = " ".join(shlex.quote(arg) for arg in args)
        self.logger.debug(truncate(f">>> {cmd}", 80))

        task = Task(args)
        if self.meter:
            self.meter.begin()
            for index in task.loop(self.meter.sleep_duration()):
                self.meter.step(index)
            self.meter.end()
        task.wait()

        msg = f"<<< code={task.code} out={task.stdout!r} err={task.stderr!r}"
        self.logger.debug(truncate(msg, 80))
        return task

    # Private

    def _curl_run(self, args: list) -> Task:
        """
        Runs a cURL subprocess of args with Task, then returns the completed
        Task.

        Raises a FetchCurlError when cURL exits with an error.
        """
        args = [str(self.curl_path), *args]
        task = self.run(args)
        if task.code == 0:
            return task
        elif isinstance(task.code, int) and isinstance(task.stderr, bytes):
            raise FetchCurlError(code=task.code, stderr=task.stderr)
        else:
            raise RuntimeError(
                f"unexpected Task state: code={task.code!r} stderr={task.stderr!r}"
            )
