"""Type conversion utilities used for both requests and responses"""

import re
from collections.abc import Mapping, MutableSequence
from datetime import date, datetime
from io import BytesIO
from logging import getLogger
from os import SEEK_END
from pathlib import Path
from typing import IO, Any
from warnings import catch_warnings, simplefilter

from dateutil.parser import UnknownTimezoneWarning
from dateutil.parser import parse as parse_date
from dateutil.tz import tzlocal
from requests import Session

from pyinaturalist.constants import (
    HISTOGRAM_INTERVALS,
    MAX_FILESIZE,
    AnyFile,
    Coordinates,
    Dimensions,
    HistogramResponse,
    JsonResponse,
    ResponseResult,
)

GENERIC_TIME_FIELDS = ('created_at', 'last_post_at', 'updated_at')
OBSERVATION_TIME_FIELDS = (
    'created_at_details',
    'created_time_zone',
    'observed_on',
    'observed_on_details',
    'observed_on_string',
    'observed_time_zone',
    'time_zone_offset',
)

# Extremely simplified URL regex, just enough to differentiate from local paths
URL_PATTERN = re.compile(r'^https?://.+')

logger = getLogger(__name__)


# Wrapper functions to apply conversions to all results in a response
# --------------------


def convert_all_coordinates(results: list[ResponseResult]) -> list[ResponseResult]:
    """Convert coordinate pairs in response items from strings to floats, if valid

    Args:
        results: Results from API response; expects coordinates in either 'location' key or
            'latitude' and 'longitude' keys
    """
    results = [convert_lat_long_dict(result) for result in results]
    results = [convert_lat_long_list(result) for result in results]
    return results


def convert_all_place_coordinates(response: JsonResponse) -> JsonResponse:
    """Convert locations for both standard and community-contributed places to floats"""
    response['results'] = {
        'standard': convert_all_coordinates(response['results'].get('standard')),
        'community': convert_all_coordinates(response['results'].get('community')),
    }
    return response


def convert_all_timestamps(results: list[ResponseResult]) -> list[ResponseResult]:
    """Replace all date/time info with datetime objects, where possible"""
    results = [convert_generic_timestamps(result) for result in results]
    results = [convert_observation_timestamps(result) for result in results]
    return results


def convert_histogram(result: JsonResponse) -> HistogramResponse:
    """Convert histogram keys (bin labels) to the appropriate type depending on interval"""
    interval = get_histogram_interval(result)
    data = result[interval]
    if interval in ['month_of_year', 'week_of_year']:
        return {int(k): v for k, v in data.items()}
    else:
        return {parse_date(k): v for k, v in data.items()}


def get_histogram_interval(result: JsonResponse) -> str:
    """Get the histogram interval from the result keys.

    The key containing histogram data will be the name of the interval requested
    example: ``{'month_of_year': {'1': 5, ...}}``
    """
    try:
        return next(k for k in result.keys() if k in HISTOGRAM_INTERVALS)
    except StopIteration as e:
        raise ValueError('Histogram interval not found in result keys') from e


# Type conversion functions for individual results or values
# --------------------


def convert_isoformat(value: date | datetime | str) -> str:
    """Convert a date, datetime, or timestamps to a string in ISO 8601 format.
    If it's a datetime and doesn't already have tzinfo, set it to the system's local timezone.

    Raises:
        :py:exc:`dateutil.parser._parser.ParserError` if a date/datetime format is invalid
    """
    if isinstance(value, str):
        value = parse_date(value)
    if isinstance(value, datetime) and not value.tzinfo:
        value = value.replace(tzinfo=tzlocal())
    return value.isoformat()


def convert_lat_long(obj: dict | list | None | str) -> Coordinates | None:
    """Convert a coordinate pair as a dict, list, or string into a pair of floats, if valid"""
    if not obj:
        return None
    elif isinstance(obj, str):
        return try_float_pair(*str(obj).split(','))
    elif isinstance(obj, (list, tuple)):
        return try_float_pair(*obj)
    elif isinstance(obj, dict):
        return try_float_pair(obj.get('latitude'), obj.get('longitude'))


def convert_lat_long_dict(result: ResponseResult) -> ResponseResult:
    """Convert a coordinate pair dict within a response to floats, if valid"""
    if 'latitude' in result and 'longitude' in result:
        result['latitude'] = try_float(result['latitude'])
        result['longitude'] = try_float(result['longitude'])
    return result


def convert_lat_long_list(result: ResponseResult):
    """Convert a coordinate pairs in a response item from strings to floats, if valid"""
    # Format inner record if present, e.g. for search results
    if 'record' in result:
        result['record'] = convert_lat_long_list(result['record'])
        return result

    if ',' in (result.get('location') or ''):
        result['location'] = [try_float(coord) for coord in result['location'].split(',')]
    return result


def convert_generic_timestamps(result: ResponseResult) -> ResponseResult:
    """Replace generic created/updated info that's returned by multiple endpoints.
    **Note:** Compared to observation timestamps, these are generally more reliable. These seem to
    be consistently in ISO 8601 format.
    """
    if not result:
        return result

    # Format inner record if present, e.g. for search results
    if 'record' in result:
        result['record'] = convert_generic_timestamps(result['record'])
        return result

    for field in GENERIC_TIME_FIELDS:
        datetime_obj = try_datetime(result.get(field, ''))
        if datetime_obj:
            result[field] = datetime_obj
    return result


def convert_observation_timestamps(result: ResponseResult) -> ResponseResult:
    """Replace observation date/time info with datetime objects"""
    observation = result.copy()

    observed_on = observation.pop('time_observed_at', None) or observation.get('observed_on')
    if not isinstance(observation.get('observed_on'), datetime) and observed_on:
        observation['observed_on'] = try_datetime(observed_on)

    created_at = observation.get('created_at')
    if not isinstance(created_at, datetime):
        observation['created_at'] = try_datetime(created_at)

    return observation


def safe_split(value: Any, delimiter: str = '|') -> list[str]:
    """Split a pipe-(or other token)-delimited string"""
    return list(ensure_list(value, split_str_list=True, delimiter=delimiter))


# Type conversion functions for files and collections
# -------------------


def ensure_file_obj(value: AnyFile, session: Session | None = None) -> IO:
    """Load data into a file-like object, if it isn't already. Accepts local file paths and URLs."""
    file_size = 0

    # Load from URL
    if isinstance(value, str) and URL_PATTERN.match(value):
        session = session or Session()
        response = session.get(value)
        file_obj: IO = response.raw  # type: ignore  # HTTPResponse acts as an IO class
        try:
            file_size = int(response.headers.get('Content-Length'))  # type: ignore [arg-type]
        except (TypeError, ValueError):
            pass
    # Load from local file path
    elif isinstance(value, (str, Path)):
        file_path = Path(value).expanduser().resolve()
        logger.info(f'Reading from file: {file_path}')
        file_obj = BytesIO(file_path.read_bytes())
    # Otherwise, assume it's already a file or file-like object
    elif hasattr(value, 'read'):
        file_obj = value
    else:
        file_obj = BytesIO(value)  # type: ignore [arg-type]

    # Verify maximum file size
    if not file_size and hasattr(file_obj, 'seek'):
        file_obj.seek(0, SEEK_END)
        file_size = file_obj.tell()
        file_obj.seek(0)
    if file_size > MAX_FILESIZE:
        raise ValueError(f'File size exceeds maximum allowed ({MAX_FILESIZE} bytes): {file_size}')

    return file_obj


def ensure_list(
    value: Any, split_str_list: bool = False, delimiter: str = ','
) -> MutableSequence[Any]:
    """Convert an object, response, or (optionally) comma-separated string into a list"""
    # Handle the occacsional stray numpy array that got lost and made its way here
    try:
        value = value.tolist()
    except (AttributeError, TypeError):
        pass
    if not value:
        return []

    # Handle response results and comma-separated strings
    elif isinstance(value, dict) and 'results' in value:
        value = value['results']
    elif split_str_list and isinstance(value, str) and delimiter in value:
        return [s.strip() for s in value.split(delimiter)]

    if isinstance(value, MutableSequence):
        return value
    elif isinstance(value, (tuple, set)):
        return list(value)
    else:
        return [value]


# Formatting functions
# --------------------


def format_dimensions(dimensions: dict[str, int] | Dimensions | None) -> Dimensions:
    """Simplify a 'dimensions' dict into a ``(width, height)`` tuple"""
    if not dimensions:
        return (0, 0)
    if isinstance(dimensions, (tuple, list, set)):
        return (dimensions[0], dimensions[1])
    elif isinstance(dimensions, Mapping):
        return dimensions.get('width', 0), dimensions.get('height', 0)


def format_file_size(n_bytes: int) -> str:
    """Convert a file size in bytes into a human-readable format"""
    filesize = float(n_bytes or 0)

    def _format(unit):
        return f'{int(filesize)} {unit}' if unit == 'bytes' else f'{filesize:.2f} {unit}'

    for unit in ['bytes', 'KB', 'MB', 'GB']:
        if filesize < 1024 or unit == 'GB':
            return _format(unit)
        filesize /= 1024

    return _format(unit)


def format_license(value: str) -> str | None:
    """Format a Creative Commons license code"""
    return str(value).upper().replace('_', '-') if value else None


# 'Safe' conversion functions that return invalid values as None instead of raising an error
# --------------------


def try_datetime(timestamp: Any, **kwargs) -> datetime | None:
    """Parse a date/time string into a datetime, if valid; return ``None`` otherwise"""
    if isinstance(timestamp, datetime):
        return timestamp
    if not timestamp or not str(timestamp).strip():
        return None

    try:
        # Suppress UnknownTimezoneWarning
        with catch_warnings():
            simplefilter('ignore', category=UnknownTimezoneWarning)
            return parse_date(timestamp, **kwargs)
    except (AttributeError, TypeError, ValueError) as e:
        logger.debug(f'Could not parse timestamp: {timestamp}: "{str(e)}"')
        return None


def try_date(timestamp: Any, **kwargs) -> date | None:
    """Parse a date string into a date, if valid; return ``None`` otherwise"""
    dt = try_datetime(timestamp, **kwargs)
    return dt.date() if dt else None


def try_float(value: Any) -> float | None:
    """Convert a value to a float, if valid; return ``None`` otherwise"""
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def try_float_pair(*values: Any) -> Coordinates | None:
    """Convert a pair of coordinate values to floats, if both are valid; return ``None`` otherwise"""
    if len(values) != 2:
        return None
    try:
        return float(values[0]), float(values[1])
    except (TypeError, ValueError):
        return None


def try_int(value: Any) -> float | None:
    """Convert a value to a int, if valid; return ``None`` otherwise"""
    try:
        return int(value)
    except (TypeError, ValueError):
        return None


def try_int_or_float(value: Any) -> int | float | None:
    """Convert a value to either an int or a float, if valid; return ``None`` otherwise"""
    return try_int(str(value)) or try_float(str(value))
