"""Library for parsing and encoding rfc5545 components with pydantic.

The data model returned by the contentlines parsing is a bag of ParsedProperty
objects that support all the flexibility of the rfc5545 spec. However in the
common case the spec has a lot more flexibility than is needed for handling
simple property types e.g. a single summary field that is specified only once.

This library helps reduce boilerplate for translating that complex structure
into the simpler pydantic data model, and handles custom field types and
validators.

Just as the pydantic model provides syntax glue for parsing data and
associating necessary validators, this is also doing the same thing
in the opposite direction to encode back to ICS.
"""

from __future__ import annotations

import copy
import datetime
import json
import logging
from typing import Any, Union, get_args, get_origin

from pydantic import BaseModel, root_validator
from pydantic.fields import SHAPE_LIST

from .parsing.component import ParsedComponent
from .parsing.property import ParsedProperty
from .types.data_types import DATA_TYPE

_LOGGER = logging.getLogger(__name__)


ATTR_VALUE = "VALUE"

# Repeated values can either be specified as multiple separate values, but
# also some values support repeated values within a single value with a
# comma delimiter, listed here.
EXPAND_REPEATED_VALUES = {
    "categories",
    "classification",
    "exdate",
    "rdate",
    "resources",
    "freebusy",
}


def validate_until_dtstart(_cls: BaseModel, values: dict[str, Any]) -> dict[str, Any]:
    """Verify the until time and dtstart are the same."""
    if (
        (rule := values.get("rrule"))
        and (until := rule.until)
        and (dtstart := values.get("dtstart"))
    ):
        if isinstance(dtstart, datetime.datetime) and isinstance(
            until, datetime.datetime
        ):
            if dtstart.tzinfo is None:
                if until.tzinfo is not None:
                    raise ValueError("DTSTART is date local but UNTIL was not")
            else:
                if until.utcoffset():
                    raise ValueError("DTSTART had UTC or local and UNTIL must be UTC")
        elif isinstance(dtstart, datetime.datetime) or isinstance(
            until, datetime.datetime
        ):
            raise ValueError("DTSTART and UNTIL must be the same value type")
    return values


class ComponentModel(BaseModel):
    """Abstract class for rfc5545 component model."""

    @root_validator(pre=True, allow_reuse=True)
    def parse_extra_fields(
        cls, values: dict[str, list[ParsedProperty | ParsedComponent]]
    ) -> dict[str, Any]:
        """Parse extra fields not in the model."""
        all_fields = set()
        for field in cls.__fields__.values():
            all_fields |= {field.alias, field.name}

        extras: list[ParsedProperty | ParsedComponent] = []
        for (field_name, value) in values.items():
            if field_name in all_fields:
                continue
            for prop in value:
                if isinstance(prop, ParsedProperty):
                    extras.append(prop)
        if extras:
            values["extras"] = extras
        return values

    @root_validator(pre=True, allow_reuse=True)
    def parse_property_values(cls, values: dict[str, Any]) -> dict[str, Any]:
        """Parse individual ParsedProperty value fields."""
        _LOGGER.debug("Parsing value data %s", values)

        for field in cls.__fields__.values():
            if field.alias == "extras":
                continue
            if not (value := values.get(field.alias)):
                continue
            if not (isinstance(value, list) and isinstance(value[0], ParsedProperty)):
                # The incoming value is not from the parse tree
                continue
            if field.alias in EXPAND_REPEATED_VALUES:
                value = cls._expand_repeated_property(value)
            # Repeated values will accept a list, otherwise truncate to a single
            # value when repeated is not allowed.
            allow_repeated = field.shape == SHAPE_LIST
            if not allow_repeated and len(value) > 1:
                raise ValueError(f"Expected one value for field: {field.alias}")
            field_types = cls._get_field_types(field.type_)
            validated = [cls._parse_property(field_types, prop) for prop in value]
            values[field.alias] = validated if allow_repeated else validated[0]

        return values

    @classmethod
    def _parse_property(cls, field_types: list[type], prop: ParsedProperty) -> Any:
        """Parse an individual field value from a ParsedProperty as the specified types."""
        errors = []
        for sub_type in field_types:
            try:
                return cls._parse_single_property(sub_type, prop)
            except ValueError as err:
                _LOGGER.debug("Unable to parse property value: %s", err)
                errors.append(str(err))
                continue
        raise ValueError(f"Failed to validate: {prop.value}, errors: ({errors})")

    @classmethod
    def _parse_single_property(cls, field_type: type, prop: ParsedProperty) -> Any:
        """Parse an individual field as a single type."""
        if (
            value_type := prop.get_parameter_value(ATTR_VALUE)
        ) and field_type not in DATA_TYPE.disable_value_param:
            # Property parameter specified a strong type
            if func := DATA_TYPE.parse_parameter_by_name.get(value_type):
                return func(prop)
            # Consider graceful degradation instead in the future
            raise ValueError(
                f"Property parameter specified unsupported type: {value_type}"
            )

        if decoder := DATA_TYPE.parse_property_value.get(field_type):
            return decoder(prop)

        return prop.value

    @classmethod
    def _expand_repeated_property(
        cls, value: list[ParsedProperty]
    ) -> list[ParsedProperty]:
        """Expand properties with repeated values into separate properties."""
        result: list[ParsedProperty] = []
        for prop in value:
            if "," in prop.value:
                for sub_value in prop.value.split(","):
                    sub_prop = copy.deepcopy(prop)
                    sub_prop.value = sub_value
                    result.append(sub_prop)
            else:
                result.append(prop)
        return result

    @classmethod
    def _get_field_types(cls, field_type: type) -> list[type]:
        """Return type to attempt for encoding/decoding based on the field type."""
        origin = get_origin(field_type)
        if origin is Union:
            if not (args := get_args(field_type)):
                raise ValueError(f"Unable to determine args of type: {field_type}")
            # Decoder for any type in the union
            return list(args)
        return [field_type]

    def __encode_component_root__(self) -> ParsedComponent:
        """Encode the calendar stream as an rfc5545 iCalendar content."""
        # The overall data model hierarchy is created by pydantic and properties
        # are encoded using the json encoders specific for each type. These are
        # marshalled through as string values. There are then additional passes
        # to ge the data in to the right final format for ics encoding.
        model_data = json.loads(
            self.json(by_alias=True, exclude_none=True, exclude_defaults=True)
        )
        # The component name is ignored as we're really only encoding children components
        return self.__encode_component__(self.__class__.__name__, model_data)

    @classmethod
    def __encode_component__(
        cls, name: str, model_data: dict[str, Any]
    ) -> ParsedComponent:
        """Encode this object as a component to prepare for serialization.

        The data passed in have already been encoded with one pass from the root json
        encoder. This method takes additional passes to add more field specific encoding,
        as well as overall component objects.
        """
        parent = ParsedComponent(name=name)
        for field in cls.__fields__.values():
            key = field.alias
            values = model_data.get(key)
            if values is None or key == "extras":
                continue
            if not isinstance(values, list):
                values = [values]
            for value in values:
                if component_encoder := getattr(
                    field.type_, "__encode_component__", None
                ):
                    parent.components.append(component_encoder(key, value))
                    continue
                if prop := cls._encode_property(key, field.type_, value):
                    parent.properties.append(prop)
        return parent

    @classmethod
    def _encode_property(cls, key: str, field_type: type, value: Any) -> ParsedProperty:
        """Encode an individual property for the specified field."""
        # A property field may have multiple possible types, like for
        # a Union. Pick the first type that is able to encode the value.
        errors = []
        for sub_type in cls._get_field_types(field_type):
            if value_encoder := DATA_TYPE.encode_property_value.get(sub_type):
                try:
                    encoded_value = value_encoder(value)
                except ValueError as err:
                    _LOGGER.debug("Encoding failed for property: %s", err)
                    errors.append(str(err))
                    continue
            else:
                encoded_value = value

            if encoded_value is not None:
                prop = ParsedProperty(name=key, value=encoded_value)
                if params_encoder := DATA_TYPE.encode_property_params.get(
                    sub_type, None
                ):
                    if params := params_encoder(value):
                        prop.params = params
                return prop

        raise ValueError(f"Unable to encode property: {value}, errors: {errors}")

    class Config:
        """Pyandtic model configuration."""

        validate_assignment = True
        allow_population_by_field_name = True
