from __future__ import annotations

import asyncio
import json
import mimetypes
from collections.abc import AsyncIterable, Iterable
from datetime import timezone
from email.utils import formatdate, parsedate_to_datetime
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any, Literal, Mapping

from .typing import Receive, Scope, Send


class Response:
    def __init__(
        self,
        body: bytes | str = b"",
        status: int = 200,
        headers: Mapping[str, str] | None = None,
        content_type: str | None = "text/plain; charset=utf-8",
    ) -> None:
        if isinstance(body, str):
            self.body = body.encode("utf-8")
        elif isinstance(body, bytes):
            self.body = body
        else:
            raise TypeError("Response body must be str or bytes")

        self.status = int(status)
        self.headers = {str(k): str(v) for k, v in (headers or {}).items()}
        self._extra_headers: list[tuple[str, str]] = []

        has_content_type = any(k.lower() == "content-type" for k in self.headers)
        if content_type is not None and not has_content_type:
            self.headers["Content-Type"] = content_type

    def set_cookie(
        self,
        key: str,
        value: str,
        *,
        max_age: int | None = None,
        expires: str | None = None,
        path: str = "/",
        domain: str | None = None,
        secure: bool = False,
        httponly: bool = False,
        samesite: str | None = "Lax",
    ) -> None:
        cookie = SimpleCookie()
        cookie[key] = value
        morsel = cookie[key]
        morsel["path"] = path
        if max_age is not None:
            morsel["max-age"] = str(max_age)
        if expires is not None:
            morsel["expires"] = expires
        if domain is not None:
            morsel["domain"] = domain
        if secure:
            morsel["secure"] = True
        if httponly:
            morsel["httponly"] = True
        if samesite is not None:
            morsel["samesite"] = samesite
        self._extra_headers.append(("Set-Cookie", morsel.OutputString()))

    async def asgi(self, scope: Scope, receive: Receive, send: Send) -> None:
        _ = scope, receive
        is_head = _scope_method(scope) == "HEAD"
        headers: list[tuple[bytes, bytes]] = []

        has_content_length = False
        for key, value in self.headers.items():
            if key.lower() == "content-length":
                has_content_length = True
            headers.append((key.encode("latin-1"), value.encode("latin-1")))

        if not has_content_length:
            headers.append((b"content-length", str(len(self.body)).encode("latin-1")))

        for key, value in self._extra_headers:
            headers.append((key.encode("latin-1"), value.encode("latin-1")))

        await send(
            {
                "type": "http.response.start",
                "status": self.status,
                "headers": headers,
            }
        )
        await send(
            {
                "type": "http.response.body",
                "body": b"" if is_head else self.body,
                "more_body": False,
            }
        )


class JSONResponse(Response):
    def __init__(
        self,
        content: Any,
        status: int = 200,
        headers: Mapping[str, str] | None = None,
    ) -> None:
        body = json.dumps(content, ensure_ascii=False, separators=(",", ":"))
        super().__init__(
            body=body,
            status=status,
            headers=headers,
            content_type="application/json; charset=utf-8",
        )


class HTMLResponse(Response):
    def __init__(
        self,
        body: bytes | str = "",
        status: int = 200,
        headers: Mapping[str, str] | None = None,
    ) -> None:
        super().__init__(
            body=body,
            status=status,
            headers=headers,
            content_type="text/html; charset=utf-8",
        )


class RedirectResponse(Response):
    def __init__(
        self,
        location: str,
        status: int = 307,
        headers: Mapping[str, str] | None = None,
    ) -> None:
        merged = dict(headers or {})
        merged["Location"] = location
        super().__init__(body=b"", status=status, headers=merged, content_type=None)


class FileResponse(Response):
    def __init__(
        self,
        path: str | Path,
        status: int = 200,
        headers: Mapping[str, str] | None = None,
        content_type: str | None = None,
    ) -> None:
        super().__init__(body=b"", status=status, headers=headers, content_type=content_type)
        self.path = Path(path)

    async def asgi(self, scope: Scope, receive: Receive, send: Send) -> None:
        stat = await asyncio.to_thread(self.path.stat)
        size = stat.st_size

        if _header_lookup(self.headers, "etag") is None:
            self.headers["ETag"] = f'W/"{int(stat.st_mtime)}-{size}"'
        if _header_lookup(self.headers, "last-modified") is None:
            self.headers["Last-Modified"] = formatdate(stat.st_mtime, usegmt=True)
        if _header_lookup(self.headers, "content-length") is None:
            self.headers["Content-Length"] = str(size)
        if _header_lookup(self.headers, "accept-ranges") is None:
            self.headers["Accept-Ranges"] = "bytes"
        if not any(key.lower() == "content-type" for key in self.headers):
            guessed_type, _ = mimetypes.guess_type(str(self.path))
            self.headers["Content-Type"] = guessed_type or "application/octet-stream"

        request_headers = _scope_headers(scope)
        if _is_not_modified(request_headers, etag=_header_lookup(self.headers, "etag"), last_modified=stat.st_mtime):
            self.status = 304
            self.body = b""
            self.headers["Content-Length"] = "0"
            await super().asgi(scope, receive, send)
            return

        range_result = _parse_single_byte_range(request_headers.get("range"), size)
        if range_result == "unsatisfiable":
            self.status = 416
            self.body = b""
            self.headers["Content-Range"] = f"bytes */{size}"
            self.headers["Content-Length"] = "0"
            await super().asgi(scope, receive, send)
            return
        if range_result is not None:
            start, end = range_result
            self.status = 206
            self.headers["Content-Range"] = f"bytes {start}-{end}/{size}"
            self.headers["Content-Length"] = str(end - start + 1)
            if _scope_method(scope) != "HEAD":
                self.body = await asyncio.to_thread(_read_file_range, self.path, start, end - start + 1)
            else:
                self.body = b""
            await super().asgi(scope, receive, send)
            return

        if _scope_method(scope) != "HEAD":
            body = await asyncio.to_thread(self.path.read_bytes)
            self.body = body
        else:
            self.body = b""
        await super().asgi(scope, receive, send)


class StreamingResponse(Response):
    def __init__(
        self,
        content: AsyncIterable[bytes | str] | Iterable[bytes | str],
        status: int = 200,
        headers: Mapping[str, str] | None = None,
        content_type: str | None = "application/octet-stream",
    ) -> None:
        super().__init__(body=b"", status=status, headers=headers, content_type=content_type)
        self.content = content

    async def asgi(self, scope: Scope, receive: Receive, send: Send) -> None:
        _ = scope, receive
        if _scope_method(scope) == "HEAD":
            headers: list[tuple[bytes, bytes]] = []
            for key, value in self.headers.items():
                headers.append((key.encode("latin-1"), value.encode("latin-1")))
            await send(
                {
                    "type": "http.response.start",
                    "status": self.status,
                    "headers": headers,
                }
            )
            await send({"type": "http.response.body", "body": b"", "more_body": False})
            return

        headers: list[tuple[bytes, bytes]] = []
        for key, value in self.headers.items():
            if key.lower() == "content-length":
                continue
            headers.append((key.encode("latin-1"), value.encode("latin-1")))

        await send(
            {
                "type": "http.response.start",
                "status": self.status,
                "headers": headers,
            }
        )

        if hasattr(self.content, "__aiter__"):
            async for chunk in self.content:  # type: ignore[attr-defined]
                payload = chunk.encode("utf-8") if isinstance(chunk, str) else chunk
                await send({"type": "http.response.body", "body": payload, "more_body": True})
        else:
            for chunk in self.content:  # type: ignore[not-an-iterable]
                payload = chunk.encode("utf-8") if isinstance(chunk, str) else chunk
                await send({"type": "http.response.body", "body": payload, "more_body": True})

        await send({"type": "http.response.body", "body": b"", "more_body": False})


def make_response(
    body: Any,
    status: int = 200,
    headers: Mapping[str, str] | None = None,
) -> Response:
    response: Response
    if isinstance(body, Response):
        response = body
        if status != 200:
            response.status = status
    elif isinstance(body, (dict, list)):
        response = JSONResponse(body, status=status, headers=headers)
        return response
    elif isinstance(body, (str, bytes)):
        response = Response(body=body, status=status, headers=headers)
        return response
    else:
        response = Response(body=str(body), status=status, headers=headers)
        return response

    if headers:
        response.headers.update({str(k): str(v) for k, v in headers.items()})
    return response


def jsonify(*args: Any, **kwargs: Any) -> JSONResponse:
    if args and kwargs:
        raise TypeError("jsonify accepts either args or kwargs, not both")
    if len(args) == 1:
        return JSONResponse(args[0])
    if len(args) > 1:
        return JSONResponse(list(args))
    return JSONResponse(kwargs)


def _scope_method(scope: Scope) -> str:
    return str(scope.get("method", "GET")).upper()


def _scope_headers(scope: Scope) -> dict[str, str]:
    raw_headers = scope.get("headers", [])
    parsed: dict[str, str] = {}
    for name, value in raw_headers:
        parsed[name.decode("latin-1").lower()] = value.decode("latin-1")
    return parsed


def _header_lookup(headers: Mapping[str, str], key: str) -> str | None:
    normalized = key.lower()
    for name, value in headers.items():
        if name.lower() == normalized:
            return value
    return None


def _is_not_modified(request_headers: Mapping[str, str], *, etag: str | None, last_modified: float) -> bool:
    if_none_match = request_headers.get("if-none-match")
    if if_none_match:
        candidates = {item.strip() for item in if_none_match.split(",")}
        if "*" in candidates:
            return True
        if etag is not None and etag in candidates:
            return True
        return False

    if_modified_since = request_headers.get("if-modified-since")
    if not if_modified_since:
        return False
    try:
        parsed = parsedate_to_datetime(if_modified_since)
    except (TypeError, ValueError, IndexError):
        return False
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return int(last_modified) <= int(parsed.timestamp())


def _parse_single_byte_range(
    range_header: str | None,
    total_size: int,
) -> tuple[int, int] | Literal["unsatisfiable"] | None:
    if not range_header:
        return None
    if not range_header.lower().startswith("bytes="):
        return None

    payload = range_header[len("bytes=") :].strip()
    if not payload or "," in payload or "-" not in payload:
        return None

    start_text, end_text = payload.split("-", 1)
    start_text = start_text.strip()
    end_text = end_text.strip()

    if total_size <= 0:
        return "unsatisfiable"

    if not start_text:
        if not end_text:
            return None
        try:
            suffix_length = int(end_text)
        except ValueError:
            return None
        if suffix_length <= 0:
            return "unsatisfiable"
        if suffix_length >= total_size:
            return (0, total_size - 1)
        return (total_size - suffix_length, total_size - 1)

    try:
        start = int(start_text)
    except ValueError:
        return None
    if start < 0 or start >= total_size:
        return "unsatisfiable"

    if not end_text:
        return (start, total_size - 1)

    try:
        end = int(end_text)
    except ValueError:
        return None
    if end < start:
        return "unsatisfiable"
    return (start, min(end, total_size - 1))


def _read_file_range(path: Path, start: int, length: int) -> bytes:
    with path.open("rb") as file_obj:
        file_obj.seek(start)
        return file_obj.read(length)
