"""Utilities for formatting API responses and model objects, for convenience/readability when exploring data.
Not used directly by API functions.

These functions will accept any of the following:

* A JSON response
* A list of response objects
* A single response object
"""

import json
from datetime import date, datetime, timedelta
from logging import basicConfig, getLogger
from typing import TYPE_CHECKING, List, Mapping, Type, Union

from requests import PreparedRequest, Response
from requests_cache import CachedResponse
from rich import pretty, print
from rich.box import SIMPLE_HEAVY
from rich.logging import RichHandler
from rich.table import Column, Table
from rich.tree import Tree

from pyinaturalist.constants import DATETIME_SHORT_FORMAT, ResponseResult
from pyinaturalist.converters import ensure_list, format_file_size
from pyinaturalist.models import (
    Annotation,
    BaseModel,
    BaseModelCollection,
    Comment,
    ControlledTerm,
    ControlledTermCount,
    ControlledTermValue,
    Flag,
    Identification,
    LifeList,
    ListedTaxon,
    Message,
    Observation,
    ObservationField,
    ObservationFieldValue,
    Photo,
    Place,
    Project,
    QualityMetric,
    ResponseOrObjects,
    SearchResult,
    Sound,
    Taxon,
    TaxonCount,
    TaxonSummary,
    User,
    Vote,
)
from pyinaturalist.models.taxon import make_tree
from pyinaturalist.paginator import Paginator

pretty.install()


# Default colors for table headers
HEADER_COLORS = {
    '': 'green',
    'Category': 'violet',
    'Comment': 'green',
    'Comments': 'blue',
    'Common name': 'blue',
    'Count': 'cyan',
    'Created at': 'blue',
    'Date': 'blue',
    'Day': 'blue',
    'Description': 'green',
    'Dimensions': 'blue',
    'Display name': 'violet',
    'Establishment means': 'violet',
    'From CV': 'white',
    'From': 'magenta',
    'ID': 'cyan',
    'Identifications': 'blue',
    'Label': 'green',
    'Latitude': 'blue',
    'License': 'green',
    'Life list': 'green',
    'Location': 'white',
    'Longitude': 'blue',
    'Month': 'blue',
    'Name': 'magenta',
    'Observations': 'blue',
    'Observed on': 'blue',
    'Place ID': 'cyan',
    'Rank': 'violet',
    'Scientific name': 'green',
    'Score': 'green',
    'Subject': 'green',
    'Taxon ID': 'cyan',
    'Taxon': 'green',
    'Title': 'green',
    'To': 'magenta',
    'Type': 'blue',
    'URL': 'white',
    'User': 'magenta',
    'Username': 'magenta',
    'Value': 'green',
    'Votes': 'blue',
    'Week': 'blue',
    'Year': 'blue',
}

# Unique response attributes used to auto-detect response types
UNIQUE_RESPONSE_ATTRS = {
    'vote_score': Annotation,
    'disagreement': Identification,
    'thread_id': Message,
    'body': Comment,  # Subset of ID attrs; if it's not an ID, assume it's a comment
    'multivalued': ControlledTerm,
    'controlled_value': ControlledTermCount,
    'blocking': ControlledTermValue,
    'count_without_taxon': LifeList,
    'captive': Observation,
    'allowed_values': ObservationField,
    'field_id': ObservationFieldValue,
    'flag': Flag,
    'original_dimensions': Photo,
    'place_type': Place,
    'standard': Place,
    'project_type': Project,
    'score': SearchResult,
    'last_observation_id': ListedTaxon,
    'listed_taxon': TaxonSummary,
    'metric': QualityMetric,
    'native_sound_id': Sound,
    'count': TaxonCount,
    'rank': Taxon,
    'roles': User,
    'vote_flag': Vote,
}


def enable_logging(level: str = 'INFO', external_level: str = 'WARNING'):
    """Configure logging to standard output with prettier tracebacks, formatting, and terminal
    colors (if supported).

    If you prefer, logging can be configured with the stdlib ``logging`` module instead; this just
    provides some convenient defaults.

    Args:
        level: Logging level to use for pyinaturalist
        external_level: Logging level to use for other libraries
    """

    basicConfig(
        format='%(message)s',
        datefmt='[%m-%d %H:%M:%S]',
        handlers=[RichHandler(rich_tracebacks=True, markup=True)],
        level=external_level,
    )
    getLogger('pyinaturalist').setLevel(level)
    getLogger('pyinaturalist_convert').setLevel(level)


def pprint(values: ResponseOrObjects):
    """Pretty-print any model object or list into a condensed summary.

    **Experimental:** May also be used on most raw JSON API responses
    """
    try:
        print(format_table(values))
    except ValueError:
        print(values)


def pprint_tree(taxa: Union[Taxon, LifeList, List[Taxon]], **kwargs):
    """Pretty-print a taxon and its descendants as a tree.

    Args:
        taxa: A single taxon with children, a :py:class:`.LifeList`, or a list of taxa
    """
    if isinstance(taxa, LifeList) or (
        isinstance(taxa, list) and len(taxa) > 0 and isinstance(taxa[0], Taxon)
    ):
        taxa = make_tree(taxa)
    elif not isinstance(taxa, Taxon):
        raise ValueError('Invalid taxon or taxon collection')

    print(format_tree(taxa, **kwargs))


def detect_type(value: ResponseResult) -> Type[BaseModel]:
    """Attempt to determine the model class corresponding to an API result"""
    for key, cls in UNIQUE_RESPONSE_ATTRS.items():
        if key in value:
            return cls

    raise ValueError(f'Could not detect response type: {value}')


def ensure_model_list(values: ResponseOrObjects) -> List[BaseModel]:
    """If the given values are raw JSON responses, attempt to detect their type and convert to
    model objects
    """
    if isinstance(values, Paginator):
        return values.all()

    values = ensure_list(values)
    if isinstance(values, BaseModelCollection) or isinstance(values[0], BaseModel):
        return values  # type: ignore

    cls = detect_type(values[0])
    return [cls.from_json(value) for value in values]


def format_table(values: ResponseOrObjects) -> Union[Table, str]:
    """Format model objects as a table.

    If the model doesn't have a table format defined, return a basic list of stringified objects.
    """
    try:
        if isinstance(values, Table):
            return values

        values = ensure_model_list(values)
        headers = {k: HEADER_COLORS.get(k, '') for k in values[0]._row.keys()}
    except NotImplementedError:
        return '\n'.join([str(obj) for obj in ensure_model_list(values)])

    # Display any date/datetime values in short format
    def _str(value):
        if isinstance(value, (date, datetime)):
            return value.strftime(DATETIME_SHORT_FORMAT)
        return str(value) if value is not None else ''

    columns = [Column(header, style=style) for header, style in headers.items()]
    table = Table(*columns, box=SIMPLE_HEAVY, header_style='bold white', row_styles=['dim', 'none'])

    for obj in values:
        table.add_row(*[_str(value) for value in obj._row.values()])
    return table


def format_request(request: PreparedRequest, dry_run: bool = False) -> str:
    """Format HTTP request info"""
    headers = _format_headers(request.headers)
    body = _format_body(request.body)
    dry_run_str = ' (DRY RUN) ' if dry_run else ''
    return f'Request:{dry_run_str}\n{request.method} {request.url}\n{headers}\n{body}'


def format_response(response: Response) -> str:
    """Format HTTP response info, including whether it came from the cache"""
    error_msg = f' {response.text}' if not response.ok else ''

    return (
        f'Response ({_format_expiration(response)}):\n'
        f'{response.status_code} {response.reason}{error_msg}\n'
        f'{_format_transfer(response)}\n'
        f'{_format_headers(response.headers)}'
    )


def _format_expiration(response: Response) -> str:
    if not getattr(response, 'from_cache', False):
        return 'not cached'
    if TYPE_CHECKING:
        assert isinstance(response, CachedResponse)
    if response.expires:
        expires_delta = timedelta(seconds=response.expires_delta)
        expires_str = f'expires in {expires_delta}'
    else:
        expires_str = 'never expires'
    return f'cached; {expires_str}'


def _format_transfer(response: Response) -> str:
    elapsed = response.elapsed.total_seconds()
    transfer_str = f'Elapsed: {elapsed:.3f}s'

    try:
        sent_size = int(response.request.headers.get('Content-Length', 0))
    except (AttributeError, TypeError, ValueError):
        sent_size = 0

    if sent_size and elapsed > 0:
        rate = format_file_size(int(sent_size / elapsed))
        transfer_str += f'; sent {format_file_size(sent_size)} @ {rate}/s'

    return transfer_str


def format_tree(taxon: Taxon, **kwargs) -> Tree:
    """Format a taxon and its descendants as a tree"""
    node = Tree(f'{taxon.rich_full_name}', **kwargs)
    for child in taxon.children:
        node.add(format_tree(child))
    return node


def _format_headers(headers: Mapping[str, str]) -> str:
    ignore_headers = [
        'Access-Control-Allow-Headers',
        'Access-Control-Allow-Methods',
        'Access-Control-Allow-Origin',
        'X-Content-Type-Options',
    ]
    headers_dict = {k: v for k, v in headers.items() if k not in ignore_headers}
    if 'Authorization' in headers_dict:
        headers_dict['Authorization'] = '[REDACTED]'

    return '\n'.join([f'{k}: {v}' for k, v in headers_dict.items()])


def _format_body(body):
    if not body:
        return ''
    try:
        body = json.loads(body)
        for key in ['password', 'client_secret']:
            if key in body:
                body[key] = '[REDACTED]'
        return body
    except Exception:
        size_str = f' ({format_file_size(len(body))})' if isinstance(body, (str, bytes)) else ''
        return f'(non-JSON request body{size_str})'
