from __future__ import annotations

import functools
import sys

from .. import _docstrings, _resolver
from .._singleton import MISSING_NONPROP, is_missing
from ._struct_spec import StructConstructorSpec, StructFieldSpec, StructTypeInfo


def attrs_rule(info: StructTypeInfo) -> StructConstructorSpec | None:
    """Rule for handling attrs classes."""
    # attr will already be imported if it's used.
    if "attr" not in sys.modules.keys():  # pragma: no cover
        return None

    try:
        import attr
    except ImportError:
        # This is needed for the mock import test in
        # test_missing_optional_packages.py to pass.
        return None

    if not attr.has(info.type):
        return None

    # We'll use our own type resolution system instead of attr's. This is
    # primarily to improve generics support.
    our_hints = _resolver.get_type_hints_resolve_type_params(
        info.type, include_extras=True
    )

    # attrs renames some constructor parameters relative to the attribute name:
    # a private field `_x` takes init argument `x`, and `attrs.field(alias=...)`
    # sets an explicit init-arg name. We key our fields (CLI flags, docstrings,
    # reading values off a default instance) by the attribute name, but must
    # call the constructor with the init-arg name. Build that mapping. (`.alias`
    # is available in attrs >= 22.2; fall back to the historical leading-
    # underscore-stripping behavior for older versions.)
    init_arg_from_name = {
        f.name: (getattr(f, "alias", None) or f.name.lstrip("_") or f.name)
        for f in attr.fields(info.type)
    }
    needs_alias_remap = any(k != v for k, v in init_arg_from_name.items())

    # Handle attr classes.
    field_list = []
    init_false_field_names: set[str] = set()
    for attr_field in attr.fields(info.type):
        name = attr_field.name

        # Handle init=False fields specially.
        if not attr_field.init:
            # For init=False fields, we can't pass them to the constructor.
            # Only include them if a default instance is provided with a value.
            if not is_missing(info.default) and hasattr(info.default, name):
                # Use value from default instance.
                init_false_field_names.add(name)
                default = getattr(info.default, name)
            else:
                # No default instance value, skip this field entirely.
                continue
        else:
            # Default handling for init=True fields.
            default = attr_field.default
            if not is_missing(info.default):
                assert hasattr(info.default, name)
                default = getattr(info.default, name)
            elif default is attr.NOTHING:
                default = MISSING_NONPROP
            elif isinstance(default, attr.Factory):  # type: ignore
                if default.takes_self:  # type: ignore
                    # Factory with takes_self=True requires the partially-built
                    # instance, which is not available at spec construction time.
                    # Skip this field; attrs will call the factory during construction.
                    continue
                else:
                    default = default.factory()  # type: ignore

        assert attr_field.type is not None, attr_field

        field_list.append(
            StructFieldSpec(
                name=name,
                type=our_hints[name],
                default=default,
                helptext=functools.partial(
                    _docstrings.get_field_docstring, info.type, name, info.markers
                ),
            )
        )

    # Wrap the instantiate function if we have init=False fields to exclude from
    # the call, or fields whose init-arg name differs from the attribute name.
    instantiate = info.type
    if len(init_false_field_names) > 0 or needs_alias_remap:

        def wrapped_instantiate(**kwargs):
            # Remove init=False fields from kwargs and save their values.
            init_false_values = {
                k: kwargs.pop(k) for k in init_false_field_names if k in kwargs
            }

            # Map attribute names to attrs init-argument names.
            call_kwargs = {init_arg_from_name.get(k, k): v for k, v in kwargs.items()}

            # Call the constructor without init=False fields.
            instance = info.type(**call_kwargs)

            # Set the init=False field values on the instance.
            # Use object.__setattr__ to bypass frozen attrs class protection.
            for field_name, value in init_false_values.items():
                object.__setattr__(instance, field_name, value)

            return instance

        instantiate = wrapped_instantiate

    return StructConstructorSpec(
        instantiate=instantiate,
        fields=tuple(field_list),
    )
