"""Contains internal utils for dealing with the client"""

import argparse
import functools
import inspect
import json
import logging
import re
import sys
from collections import Counter
from collections.abc import Callable, Iterable, MutableMapping
from datetime import datetime
from enum import Enum
from functools import wraps
from http import HTTPStatus
from typing import Any, Protocol, TypeVar

import flexpolyline.decoding
import numpy as np
import pandas as pd
import shapely
from shapely import Point

from . import _consts
from ._errors import CasaGeoError

T = TypeVar("T")

logger = logging.getLogger("casageo.tools")

ietf_bcp47_language_tag_pattern = re.compile(r"[A-Za-z0-9]+(-[A-Za-z0-9]+)*")
iso3166_alpha3_country_code_pattern = re.compile(r"[A-Z]{3}")
# https://www.here.com/docs/bundle/geocoding-and-search-api-developer-guide/page/topics-places/places-category-system-full.html
here_api_category_code_pattern = re.compile(r"!?\d{3}(-\d{4}){0,2}", re.ASCII)
# https://www.here.com/docs/bundle/geocoding-and-search-api-developer-guide/page/topics-places/places-chain-system-full.html
here_api_chain_code_pattern = re.compile(r"!?\d+", re.ASCII)
# https://www.here.com/docs/bundle/geocoding-and-search-api-developer-guide/page/topics-places/food-types-category-system-full.html
here_api_food_type_code_pattern = re.compile(r"!?\d{3}-\d{3}", re.ASCII)


class Response(Protocol[T]):
    """A response from an endpoint"""

    status_code: HTTPStatus
    content: bytes
    headers: MutableMapping[str, str]
    parsed: T | None


def validate_ietf_bcp47_language_tag(string: str) -> None:
    if not ietf_bcp47_language_tag_pattern.fullmatch(string):
        raise ValueError(f"Invalid IETF BCP 47 language tag: {string!r}")


def validate_iso3166_alpha3_country_code(string: str) -> None:
    if not iso3166_alpha3_country_code_pattern.fullmatch(string):
        raise ValueError(f"Invalid ISO 3166-1 alpha-3 country code: {string!r}")


def validate_here_api_category_code(string: str) -> None:
    if not here_api_category_code_pattern.fullmatch(string):
        raise ValueError(f"Invalid HERE API category code: {string!r}")


def validate_here_api_chain_code(string: str) -> None:
    if not here_api_chain_code_pattern.fullmatch(string):
        raise ValueError(f"Invalid HERE API chain code: {string!r}")


def validate_here_api_food_type_code(string: str) -> None:
    if not here_api_food_type_code_pattern.fullmatch(string):
        raise ValueError(f"Invalid HERE API food type code: {string!r}")


def load_json(response: Response[Any]) -> dict:
    if response.status_code != 200:
        raise CasaGeoError(
            f"Bad status code {response.status_code}: {response.content!r}"
        )
    data = json.loads(response.content)
    if "error" in data:
        raise CasaGeoError(data["error"])
    return data


def flexpolyline_points(encoded: str) -> np.ndarray:
    """
    Decode a flexpolyline into an array of points.

    While flexpolyline encodes points as lat-lng, this function returns an array
    of lng-lat points to match GeoPandas.

    If the input contains a third dimension, it will be reflected in the output.
    The type of the third dimension is ignored. The third dimension can be
    removed using ``shapely.force_2d()``.

    Args:
        encoded: The encoded flexpolyline.

    Returns:
        np.ndarray: An array of points with either (lng, lat) or (lng, lat, z)
        coordinates, depending on whether the input contained a third dimension.
    """
    return shapely.points([
        (lng, lat, *z) for lat, lng, *z in flexpolyline.decoding.iter_decode(encoded)
    ])


def point_to_latlng(p: Point) -> str:
    """
    Return a lat-lng string representation of a lng-lat Point object.

    The API expects coordinates in the geographic format.
    """
    return f"{p.y},{p.x}"


def lnglat_to_point(data: str) -> Point:
    # FIXME: This function expects a lng-lat string, while the inverse function
    #        produces a lat-lng string. This should at least be documented!
    try:
        lng, lat = (float(val) for val in data.split(","))
    except ValueError as err:
        raise CasaGeoError(
            f"Bad coordinate string {data!r} a good one looks like this 0.0,0.0"
        ) from err
    return Point(lng, lat)


def latlong_to_point(value: str) -> Point:
    lat, lng = value.split(",")
    return Point(float(lng), float(lat))


def fromisoformat_or_null(date: str | None) -> datetime | None:
    if date is None:
        return None
    try:
        return datetime.fromisoformat(date)
    except ValueError:
        # FIXME: Raise warning here.
        return None


def rename(newname: str):
    def decorator(func):
        func.__name__ = newname
        return func

    return decorator


class Transducer(functools.partial):
    # NOTE: You cannot pass a transducer to functools.partial().
    def __call__(self, *args, **keywords):
        x, *args = args
        keywords = {**self.keywords, **keywords}
        return self.func(x, *self.args, *args, **keywords)

    def __ror__(self, other):
        return self(other)


def optional(func):
    """
    Decorator that makes a function optional, returning None if the
    first positional argument is None.
    """

    @wraps(func)
    def wrapper(x, /, *args, **kwargs):
        if x is None:
            return None
        return func(*args, **kwargs)

    return wrapper


def transducer(func):
    """Decorator that makes a function a transducer."""
    return functools.update_wrapper(Transducer(func), func)


def pdgetval(obj: object, key: str, default: Any = None) -> Any:
    val = getattr(obj, key, pd.NA)
    return val if pd.notna(val) else default


def implicit_enum_parameters(func):
    """
    Decorator that allows parameters of enum type to accept values of
    their underlying type directly.
    """

    @wraps(func)
    def wrapper(*args, **kwargs):
        signature = inspect.signature(func)
        bound_args = signature.bind(*args, **kwargs)
        for k, v in bound_args.arguments.items():
            param = signature.parameters[k]
            if (
                param != inspect.Parameter.empty
                and isinstance(param.annotation, type)
                and issubclass(param.annotation, Enum)
            ):
                bound_args.arguments[k] = param.annotation(v)

        return func(*bound_args.args, **bound_args.kwargs)

    return wrapper


def duplicates(inp: Iterable[T]) -> list[T]:
    ctr = Counter(inp)
    return [v for v, c in ctr.items() if c > 1]


@rename("IETF BCP47 language tag")
def cli_ietf_bcp47_language_tag(value: str) -> str:
    validate_ietf_bcp47_language_tag(value)
    return value


@rename("ISO 3166-1 alpha-3 country code")
def cli_iso3166_alpha3_country_code(value: str) -> str:
    validate_iso3166_alpha3_country_code(value)
    return value


@rename("comma-separated ISO 3166-1 alpha-3 country code list")
def cli_iso3166_alpha3_country_code_list(value: str) -> list[str]:
    codes = value.split(",")
    for code in codes:
        validate_iso3166_alpha3_country_code(code)
    return codes


@rename("Latitude,Longitude point")
def cli_latlong_point(value: str) -> shapely.Point:
    return latlong_to_point(value)


@rename("positive integer")
def cli_positive_int(value: str) -> int:
    if (x := int(value)) > 0:
        return x
    raise ValueError("number must be greater than zero")


@rename("non-negative integer")
def cli_non_negative_int(value: str) -> int:
    if (x := int(value)) >= 0:
        return x
    raise ValueError("number must be non-negative")


@rename("ISO 8601 datetime")
def cli_datetime(value: str) -> datetime:
    return datetime.fromisoformat(value)


@rename("comma-separated feature list")
def cli_avoidable_feature_list(value: str) -> list[str]:
    features = value.split(",")
    for feature in features:
        if feature not in _consts.AVOIDABLE_FEATURES:
            raise ValueError(
                f"avoidable features may only contain {_consts.AVOIDABLE_FEATURES}, got {feature!r}"
            )
    return features


@rename("comma-separated HERE API category code list")
def cli_here_api_category_code_list(value: str) -> list[str]:
    codes = value.split(",")
    for code in codes:
        validate_here_api_category_code(code)
    return codes


@rename("comma-separated HERE API chain code list")
def cli_here_api_chain_code_list(value: str) -> list[str]:
    codes = value.split(",")
    for code in codes:
        validate_here_api_chain_code(code)
    return codes


@rename("comma-separated HERE API food type code list")
def cli_here_api_food_type_code_list(value: str) -> list[str]:
    codes = value.split(",")
    for code in codes:
        validate_here_api_food_type_code(code)
    return codes


def cli_throwing_main(
    parser: argparse.ArgumentParser,
    main: Callable[[argparse.Namespace], dict | pd.DataFrame],
):
    args = parser.parse_args()
    if not args.key:
        raise CasaGeoError(
            "Please specify a key in the environment variable CASAGEO_KEY or the --key argument"
        )

    f = sys.stdout
    if args.output:
        f = open(args.output, "w", encoding="utf-8")  # noqa: SIM115

    result = main(args)

    if isinstance(result, dict):
        json.dump(result, f, indent=args.indent, ensure_ascii=False)
    elif isinstance(result, pd.DataFrame):
        result.to_csv(f, sep=";")


def cli_main(
    parser: argparse.ArgumentParser,
    main: Callable[[argparse.Namespace], dict | pd.DataFrame],
):
    try:
        cli_throwing_main(parser, main)
    except Exception as e:
        # this is not pretty, but it will look pretty
        sys.exit(f"{e.__class__.__name__}: {e}")
