# Copyright 2025-2026 Marcel Bollmann <marcel@bollmann.me>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Functions providing converters and validators for [attrs](https://www.attrs.org/) classes."""

from __future__ import annotations

import attrs
from attrs import validators
import datetime
from typing import (
    Any,
    Callable,
    Generator,
    Iterable,
    Optional,
    Sequence,
    TypeAlias,
    TypeVar,
    TYPE_CHECKING,
)
import re

from ..constants import RE_ORCID
from .ids import AnthologyIDTuple, build_id_from_tuple, is_valid_orcid

if TYPE_CHECKING:
    import rich
    from ..collections import Paper, Volume, Event, Talk
    from ..people import NameSpecification


ConvertableIntoDate: TypeAlias = datetime.date | datetime.datetime | str | None

RE_WRAPPED_TYPE = re.compile(r"^([^\[]*)\[(.*)\]$")
C = TypeVar("C", bound=attrs.AttrsInstance)
T = TypeVar("T")


def track_namespec_modifications(
    obj: NameSpecification, attr: attrs.Attribute[Any], value: T
) -> T:
    if attr.name != "parent" and obj.parent is not None:
        obj.parent.collection.is_modified = True
    return value


def track_modifications(
    obj: Paper | Volume | Event | Talk, attr: attrs.Attribute[Any], value: T
) -> T:
    if attr.name != "parent":
        obj.collection.is_modified = True
    return value


def validate_anthology_id_tuple(cls: Any, attr: attrs.Attribute[Any], value: Any) -> None:
    """Validate that an AnthologyIDTuple has the correct format."""
    if (
        isinstance(value, tuple)
        and len(value) == 3
        and isinstance(value[0], str)
        and isinstance(value[1], (type(None), str))
        and isinstance(value[2], (type(None), str))
    ):
        return

    raise TypeError(
        f"'{attr.name}' must be AnthologyIDTuple (got {value!r})",
        attr,
        AnthologyIDTuple,
        value,
    )


def convert_orcid(value: object) -> Optional[str]:
    """Convert an object potentially representing an ORCID."""
    if value is None:
        return None
    value = str(value).upper()
    # e.g. "https://orcid.org/0000-0002-1297-6794" -> "0000-0002-1297-6794"
    if len(value) > 19 and (m := RE_ORCID.search(value)) is not None:
        value = str(m.group(0))
    return value


def validate_orcid(_: Any, __: Any, value: Optional[str]) -> None:
    if isinstance(value, str) and not is_valid_orcid(value):
        raise ValueError(f"ORCID is not valid (wrong format or checksum): {value!r}")


def auto_validate_types(
    cls: type, fields: list[attrs.Attribute[Any]]
) -> list[attrs.Attribute[Any]]:
    """Add validators to attrs classes based on their type annotations.

    Intended to be used with the `field_transformer` parameter of [`@attrs.define`][attrs.define].

    Supported type annotations:
      - str, int
      - `FileReference` and derived classes
      - `Name`, `NameSpecification`
      - `MarkupText`
      - `Optional[<type>]`
      - `list[<type>]`
      - `tuple[<type>, ...]`
      - `set[<type>]`

    The purpose of this function is to reduce the need for explicitly adding validators to the classes in `acl_anthology.collections` and `acl_anthology.people.person`.  It does _not_ automatically validate classes _defined_ in `acl_anthology.collections`, as that would lead to circular imports.

    See also: <https://www.attrs.org/en/stable/extending.html#transform-fields>
    """
    from ..files import (
        FileReference,
        PDFReference,
        PDFThumbnailReference,
        AttachmentReference,
        EventFileReference,
        VideoReference,
    )
    from ..people import Name, NameSpecification
    from ..text import MarkupText

    known_types = {
        t.__name__: t
        for t in {
            str,
            int,
            FileReference,
            PDFReference,
            PDFThumbnailReference,
            AttachmentReference,
            EventFileReference,
            VideoReference,
            MarkupText,
            Name,
            NameSpecification,
        }
    }

    def make_validator(field_type: Any) -> Optional[Callable[..., Any]]:
        if not isinstance(field_type, str):
            return None

        # Handle Optional[], list[], etc.
        if (m := RE_WRAPPED_TYPE.match(field_type)) is not None:
            match m.group(1):
                case "Optional":
                    if (inner := make_validator(m.group(2))) is not None:
                        return validators.optional(inner)
                case "dict":
                    dict_parts = m.group(2).split(", ")
                    if (
                        len(dict_parts) == 2
                        and (key_inner := make_validator(dict_parts[0])) is not None
                        and (value_inner := make_validator(dict_parts[1])) is not None
                    ):
                        return validators.deep_mapping(
                            key_validator=key_inner, value_validator=value_inner
                        )
                case "list":
                    if (inner := make_validator(m.group(2))) is not None:
                        return validators.deep_iterable(
                            member_validator=inner,
                            iterable_validator=validators.instance_of(list),
                        )
                case "set":
                    if (inner := make_validator(m.group(2))) is not None:
                        return validators.deep_iterable(
                            member_validator=inner,
                            iterable_validator=validators.instance_of(set),
                        )
                case "tuple":
                    tuple_parts = m.group(2).split(", ")
                    # Only tuples of variable length with a single type
                    if (
                        len(tuple_parts) == 2
                        and tuple_parts[1] == "..."
                        and (inner := make_validator(tuple_parts[0])) is not None
                    ):
                        return validators.deep_iterable(
                            member_validator=inner,
                            iterable_validator=validators.instance_of(tuple),
                        )
            # unsupported
            return None

        # Handle known types
        if (type_ := known_types.get(field_type)) is not None:
            return validators.instance_of(type_)
        elif field_type == "AnthologyIDTuple":
            return validate_anthology_id_tuple

        # unsupported
        return None

    results = []
    for field in fields:
        # Don't modify field if validator already defined
        if field.validator is not None:
            results.append(field)
            continue

        if (validator := make_validator(field.type)) is not None:
            results.append(field.evolve(validator=validator))
        else:
            results.append(field)

    return results


def int_to_str(value: Any) -> Any:
    """Convert an int to str, and leave unchanged otherwise.

    Intended to be used as a converter for [attrs.field][].
    """
    if isinstance(value, int):
        return str(value)
    return value


def date_to_str(value: ConvertableIntoDate) -> Any:
    """Convert a [date][datetime.date] or [datetime][datetime.datetime] object to str (in ISO format), and leave unchanged otherwise.

    Intended to be used as a converter for [attrs.field][].
    """
    if isinstance(value, datetime.date):
        return value.isoformat()
    elif isinstance(value, datetime.datetime):
        return value.date().isoformat()
    return value


def into_namespec_tuple(
    value: Iterable[NameSpecification],
) -> tuple[NameSpecification, ...]:
    return tuple(value)


def into_str_tuple(value: Iterable[str]) -> tuple[str, ...]:
    if isinstance(value, str):
        raise TypeError("Expected Iterable[str], got str; this is most likely a mistake")
    return tuple(value)


def attach_parent(
    item: Paper | Volume | Talk,
    attr: attrs.Attribute[Any],
    value: list[NameSpecification],
) -> list[NameSpecification]:
    """Attach parent object to a [NameSpecification][acl_anthology.people.name.NameSpecification].

    Intended to be called from `on_setattr` of an [attrs.field][].
    """
    for namespec in value:
        namespec.parent = item
    return value


def repr_item_ids(ids: Sequence[AnthologyIDTuple] | set[AnthologyIDTuple]) -> str:
    """Produce a repr for sequences of AnthologyIDTuples.

    Will show a 'slice' of the first few IDs along with the sequence's length.

    Intended to be set on the `repr` attribute of suitable attribute fields.
    """
    if not ids:
        return repr(ids)
    MAX_LEN = 5
    if isinstance(ids, set):
        shown_ids = [repr(build_id_from_tuple(id_)) for id_ in tuple(ids)[:MAX_LEN]]
        lb, rb = "{", "}"
    else:
        shown_ids = [repr(build_id_from_tuple(id_)) for id_ in ids[:MAX_LEN]]
        if isinstance(ids, tuple):
            lb, rb = "(", ")"
        else:
            lb, rb = "[", "]"
    if len(ids) > MAX_LEN:
        shown_ids.append("...")
    return f"<{type(ids).__name__}[AnthologyIDTuple] with {len(ids)} item{'' if len(ids) == 1 else 's'} {lb}{', '.join(shown_ids)}{rb}>"


def _convert_fields_for_repr(
    self: attrs.AttrsInstance,
) -> Generator[tuple[attrs.Attribute[Any], Optional[str], object]]:
    for f in attrs.fields(type(self)):
        if not f.repr:
            continue
        if f.name == "id" and hasattr(self, "full_id"):
            yield (f, "full_id", getattr(self, "full_id"))
            continue

        if type(f.default) is attrs.Factory:
            default = (
                f.default.factory(self) if f.default.takes_self else f.default.factory()
            )
        else:
            default = f.metadata.get("repr_omit_if", f.default)

        if f.name[0] == "_" and hasattr(self, f.name[1:]):
            # For underscore attributes, omit if their underscore version matches the default
            if getattr(self, f.name) == default:
                continue
            # If not, replace them with their non-underscore versions
            name = f.name[1:]
        else:
            name = f.name

        value = getattr(self, name)
        if value == default:
            continue

        if f.metadata.get("repr_omits_field_name"):
            yield (f, None, value)
        else:
            yield (f, name, value)


def _repr(self: attrs.AttrsInstance) -> str:
    parts = []
    for f, name, value in _convert_fields_for_repr(self):
        value_r = f.repr(value) if callable(f.repr) else repr(value)
        if name is None:
            parts.append(value_r)
        else:
            parts.append(f"{name}={value_r}")
    return f"{type(self).__name__}({', '.join(parts)})"


class _PreformattedRepr:
    def __init__(self, value: str) -> None:
        self.value = value

    def __repr__(self) -> str:
        return self.value


def _rich_repr(self: attrs.AttrsInstance) -> rich.repr.Result:
    from ..text import MarkupText

    for f, name, value in _convert_fields_for_repr(self):
        if callable(f.repr):
            # If we don't use the _PreformattedRepr hack, Rich renders the repr
            # as a string, which is not what we want
            value = _PreformattedRepr(f.repr(value))
        elif isinstance(value, MarkupText):
            if not value.contains_markup:
                value = value._content

        if name is None:
            yield value
        else:
            yield name, value


def attach_custom_repr(cls: type[C]) -> type[C]:
    """Attach custom `__repr__` and `__rich_repr__` functions to an [attrs class][attrs.define].

    These functions will respect the `repr` attributes on individual [attrs fields][attrs.field], but additionally:
    - Skip fields whose values are equal to their default values, or the `repr_omit_if` attribute, if set.
    - Omit field names if the `repr_omits_field_name` metadata attribute is set.
    - Print `full_id` instead of `id` for objects that have it (e.g., papers or volumes).
    """
    # NOTE: mypy doesn't support dunder method reassignment, and also doesn't
    # know whether C has a __rich_repr__ attribute yet or not (and we don’t
    # actually care), hence a few type: ignores are necessary here

    cls.__repr__ = _repr  # type: ignore[method-assign,assignment]
    cls.__rich_repr__ = _rich_repr  # type: ignore[attr-defined]
    return cls
