from __future__ import annotations

import gzip
import json
import random
import time
from collections.abc import Sequence
from email.utils import parsedate_to_datetime
from typing import Dict, Optional, Tuple, Union

import httpx

from .types import (
    BatchingOptions,
    CompressionOptions,
    GzipCompressionOptions,
    RetryOptions,
    SendOptions,
    WyndDataIngestError,
    WyndDataIngestPayload,
    WyndDataIngestRequestResult,
    WyndDataIngestTableOperation,
)

DEFAULT_GZIP_COMPRESSION = GzipCompressionOptions(min_bytes=100 * 1_024)
DEFAULT_BATCHING = BatchingOptions()


class _BaseWyndDataIngestClient:
    def __init__(
        self,
        *,
        url: str,
        table_name: Optional[str] = None,
        table_operation: Optional[WyndDataIngestTableOperation] = None,
        headers: Optional[Dict[str, str]] = None,
        timeout_ms: int = 30_000,
        user_agent: str = "wynd-dataingest/1.0.0",
        retry: Optional[RetryOptions] = None,
        compression: Optional[CompressionOptions] = None,
        batching: Optional[BatchingOptions] = None,
    ) -> None:
        self._url = url
        self._default_timeout = httpx.Timeout(timeout_ms / 1000.0)
        self._retry = retry or RetryOptions()
        self._compression = compression
        self._batching = _normalize_batching_options(batching)
        self._batching_enabled = batching is not None
        self._default_headers = {
            "content-type": _resolve_content_type(),
            "user-agent": user_agent,
            **_resolve_table_headers(table_name, table_operation),
            **_lowercase_headers(headers),
        }

    def _prepare_send(
        self,
        payload: WyndDataIngestPayload,
        options: Optional[SendOptions] = None,
    ) -> Tuple[bytes, Dict[str, str], httpx.Timeout]:
        body = self._serialize_payload(payload)
        resolved_options = options or SendOptions()
        headers = {
            **self._default_headers,
            **_lowercase_headers(resolved_options.headers),
        }
        if resolved_options.content_type:
            headers["content-type"] = resolved_options.content_type

        request_body = self._prepare_request_body(body, headers)
        timeout = (
            self._default_timeout
            if resolved_options.timeout_ms is None
            else httpx.Timeout(resolved_options.timeout_ms / 1000.0)
        )
        return request_body, headers, timeout

    def _build_result(self, response: httpx.Response, attempt: int) -> WyndDataIngestRequestResult:
        return WyndDataIngestRequestResult(
            status_code=response.status_code,
            ok=True,
            headers=_headers_to_record(response.headers),
            attempts=attempt,
        )

    def _raise_request_error(
        self,
        *,
        attempt: int,
        status_code: Optional[int] = None,
        response_body: Optional[str] = None,
        cause: Optional[BaseException] = None,
    ) -> None:
        if status_code is None:
            raise WyndDataIngestError(
                "WyndDataIngest request failed.",
                attempts=attempt,
                cause=cause,
            ) from cause

        raise WyndDataIngestError(
            f"WyndDataIngest request failed with status {status_code}.",
            attempts=attempt,
            status_code=status_code,
            response_body=response_body,
        )

    def _compute_delay(self, attempt: int, retry_after_ms: Optional[int] = None) -> int:
        if retry_after_ms is not None:
            return min(retry_after_ms, self._retry.max_delay_ms)

        exponential = min(
            int(self._retry.initial_delay_ms * (self._retry.factor ** max(0, attempt - 1))),
            self._retry.max_delay_ms,
        )
        jitter_offset = int(exponential * self._retry.jitter_ratio * random.random())
        return exponential + jitter_offset

    def _serialize_payload(self, payload: WyndDataIngestPayload) -> bytes:
        if isinstance(payload, (bytes, bytearray)):
            return bytes(payload)

        if isinstance(payload, str) or not isinstance(payload, Sequence):
            raise TypeError("NDJSON ingest requires a sequence payload.")

        return _format_ndjson_lines(self._serialize_payload_lines(payload))

    def _serialize_payload_lines(self, payload: WyndDataIngestPayload) -> list[str]:
        if isinstance(payload, (bytes, bytearray)) or isinstance(payload, str) or not isinstance(payload, Sequence):
            raise TypeError("NDJSON ingest batching requires a sequence payload.")

        return [json.dumps(item) for item in payload]

    def _prepare_request_body(self, body: bytes, headers: Dict[str, str]) -> bytes:
        gzip_options = _normalize_gzip_compression(self._compression.gzip if self._compression else None)
        if not gzip_options or "content-encoding" in headers or len(body) < gzip_options.min_bytes:
            return body

        headers["content-encoding"] = "gzip"
        compresslevel = gzip_options.level if gzip_options.level is not None else 9
        return gzip.compress(body, compresslevel=compresslevel)


def _resolve_content_type() -> str:
    return "application/x-ndjson"


def _normalize_gzip_compression(
    value: Union[bool, GzipCompressionOptions, None],
) -> Optional[GzipCompressionOptions]:
    if value is True:
        return DEFAULT_GZIP_COMPRESSION
    if not value:
        return None
    return GzipCompressionOptions(min_bytes=value.min_bytes, level=value.level)


def _normalize_batching_options(value: Optional[BatchingOptions]) -> BatchingOptions:
    if value is None:
        return BatchingOptions(
            linger_ms=DEFAULT_BATCHING.linger_ms,
            max_records=DEFAULT_BATCHING.max_records,
            max_bytes=DEFAULT_BATCHING.max_bytes,
            max_inflight_batches=DEFAULT_BATCHING.max_inflight_batches,
        )

    return BatchingOptions(
        linger_ms=value.linger_ms,
        max_records=value.max_records,
        max_bytes=value.max_bytes,
        max_inflight_batches=max(1, value.max_inflight_batches),
    )


def _resolve_table_headers(
    table_name: Optional[str],
    table_operation: Optional[WyndDataIngestTableOperation],
) -> Dict[str, str]:
    if not table_name:
        return {}

    if table_operation in (None, "insert", "new"):
        return {"table-name": table_name}
    if table_operation == "update":
        return {"table-name": f"{table_name}-updates"}
    return {"table-name": f"{table_name}-deletes"}


def _lowercase_headers(headers: Optional[Dict[str, str]]) -> Dict[str, str]:
    if not headers:
        return {}
    return {key.lower(): value for key, value in headers.items()}


def _headers_to_record(headers: httpx.Headers) -> Dict[str, str]:
    return dict(headers.items())


def _format_ndjson_lines(lines: Sequence[str]) -> bytes:
    return ("\n".join(lines) + "\n").encode("utf-8")


def _get_lines_size(lines: Sequence[str]) -> int:
    return sum(len(line.encode("utf-8")) + 1 for line in lines)


def _parse_retry_after_ms(value: Optional[str]) -> Optional[int]:
    if not value:
        return None

    try:
        return max(0, int(float(value) * 1000))
    except ValueError:
        pass

    try:
        target = parsedate_to_datetime(value).timestamp()
        return max(0, int((target - time.time()) * 1000))
    except Exception:
        return None


def _is_retryable_error(exc: httpx.HTTPError) -> bool:
    return isinstance(
        exc,
        (
            httpx.ConnectError,
            httpx.PoolTimeout,
        ),
    )
