"""Class metadata and synthetic ``__init__`` stub extraction.

Detects dataclasses, attrs, and NamedTuple classes and generates
synthetic ``__init__`` stubs for declarative classes that lack one.
"""

from __future__ import annotations

import ast

from ._ast_helpers import (
    bool_literal,
    collect_type_names_from_annotation,
    expr_matches_name,
    get_expr_name,
    get_node_source,
    resolve_decorator_name,
)

ATTRS_NAMESPACES = frozenset({"attrs", "attr"})
ATTRS_DECORATOR_NAMES = frozenset(
    {"define", "mutable", "frozen", "s", "attrs"}
)

MIN_DOTTED_NAME_PARTS = 2

MAX_RAW_PROJECT_CLASS_BODY_ITEMS = 8
MAX_RAW_PROJECT_CLASS_LINES = 40


def get_class_start_line(class_node: ast.ClassDef) -> int:
    """Return the first line of *class_node* (including decorators)."""
    if class_node.decorator_list:
        return min(d.lineno for d in class_node.decorator_list)
    return class_node.lineno


def class_has_explicit_init(class_node: ast.ClassDef) -> bool:
    """Check whether *class_node* has an explicit ``__init__``."""
    for item in class_node.body:
        if (
            isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
            and item.name == "__init__"
        ):
            return True
    return False


def is_classvar_annotation(
    annotation: ast.expr,
    import_aliases: dict[str, str],
) -> bool:
    """Check whether *annotation* is ``ClassVar[...]``."""
    annotation_root = (
        annotation.value
        if isinstance(annotation, ast.Subscript)
        else annotation
    )
    return expr_matches_name(annotation_root, import_aliases, "ClassVar")


def resolve_instance_class_name(  # noqa: C901
    name: str,
    module_tree: ast.Module,
) -> str | None:
    """Resolve a module-level assignment to its class constructor name."""
    for node in module_tree.body:
        if isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Name) and target.id == name:
                    value = node.value
                    if isinstance(value, ast.Call):
                        func = value.func
                        if isinstance(func, ast.Name):
                            return func.id
                        if isinstance(func, ast.Attribute) and isinstance(
                            func.value, ast.Name
                        ):
                            return func.value.id
        elif (
            isinstance(node, ast.AnnAssign)
            and isinstance(node.target, ast.Name)
            and node.target.id == name
        ):
            ann = node.annotation
            if isinstance(ann, ast.Name):
                return ann.id
            if isinstance(ann, ast.Subscript) and isinstance(
                ann.value, ast.Name
            ):
                return ann.value.id
    return None


def build_import_from_map(tree: ast.Module) -> dict[str, str]:
    """Map local import name -> module name (``from X import Y``)."""
    import_map: dict[str, str] = {}
    for node in ast.walk(tree):
        if isinstance(node, ast.ImportFrom) and node.module:
            for alias in node.names:
                import_map[alias.asname or alias.name] = node.module
    return import_map


def is_namedtuple_class(
    class_node: ast.ClassDef,
    import_aliases: dict[str, str],
) -> bool:
    """Check whether *class_node* inherits from ``NamedTuple``."""
    for base in class_node.bases:
        if expr_matches_name(base, import_aliases, "NamedTuple"):
            return True
    return False


def get_dataclass_config(
    class_node: ast.ClassDef,
    import_aliases: dict[str, str],
) -> tuple[bool, bool, bool]:
    """Return ``(is_dataclass, init_enabled, kw_only)``."""
    for decorator in class_node.decorator_list:
        if not expr_matches_name(decorator, import_aliases, "dataclass"):
            continue
        init_enabled = True
        kw_only = False
        if isinstance(decorator, ast.Call):
            for keyword in decorator.keywords:
                literal_value = bool_literal(keyword.value)
                if literal_value is None:
                    continue
                if keyword.arg == "init":
                    init_enabled = literal_value
                elif keyword.arg == "kw_only":
                    kw_only = literal_value
        return True, init_enabled, kw_only
    return False, False, False


def get_attrs_config(
    class_node: ast.ClassDef,
    import_aliases: dict[str, str],
) -> tuple[bool, bool, bool]:
    """Return ``(is_attrs, init_enabled, kw_only)``."""
    for decorator in class_node.decorator_list:
        name = get_expr_name(decorator)
        if name is None:
            continue
        resolved = resolve_decorator_name(name, import_aliases)
        parts = resolved.split(".")
        if (
            len(parts) < MIN_DOTTED_NAME_PARTS
            or parts[-2] not in ATTRS_NAMESPACES
            or parts[-1] not in ATTRS_DECORATOR_NAMES
        ):
            continue
        init_enabled = True
        kw_only = False
        if isinstance(decorator, ast.Call):
            for keyword in decorator.keywords:
                literal_value = bool_literal(keyword.value)
                if literal_value is None:
                    continue
                if keyword.arg == "init":
                    init_enabled = literal_value
                elif keyword.arg == "kw_only":
                    kw_only = literal_value
        return True, init_enabled, kw_only
    return False, False, False


def has_non_property_method_decorator(
    fn_node: ast.FunctionDef | ast.AsyncFunctionDef,
    import_aliases: dict[str, str],
) -> bool:
    """Check whether *fn_node* has decorators other than property."""
    for decorator in fn_node.decorator_list:
        if expr_matches_name(decorator, import_aliases, "property"):
            continue
        decorator_name = get_expr_name(decorator)
        if decorator_name and decorator_name.endswith((".setter", ".deleter")):
            continue
        return True
    return False


def should_use_raw_project_class_context(  # noqa: PLR0911
    class_node: ast.ClassDef,
    import_aliases: dict[str, str],
) -> bool:
    """Decide whether to emit full class source vs. init stub only."""
    if class_node.decorator_list:
        return True

    if is_namedtuple_class(class_node, import_aliases):
        return True
    is_dc, _, _ = get_dataclass_config(class_node, import_aliases)
    if is_dc:
        return True

    start_line = get_class_start_line(class_node)
    assert class_node.end_lineno is not None  # noqa: S101
    class_line_count = class_node.end_lineno - start_line + 1
    is_small = (
        class_line_count <= MAX_RAW_PROJECT_CLASS_LINES
        and len(class_node.body) <= MAX_RAW_PROJECT_CLASS_BODY_ITEMS
    )

    for item in class_node.body:
        if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if item.name == "__init__" and is_small:
                return True
            if has_non_property_method_decorator(item, import_aliases):
                return True
        elif isinstance(item, (ast.Assign, ast.AnnAssign)) and isinstance(
            item.value, ast.Call
        ):
            return True

    return False


def collect_synthetic_constructor_type_names(  # noqa: C901
    class_node: ast.ClassDef,
    import_aliases: dict[str, str],
) -> set[str]:
    """Collect type names from fields of a declarative class."""
    is_dc, dc_init_enabled, _ = get_dataclass_config(
        class_node, import_aliases
    )
    is_at, at_init_enabled, _ = get_attrs_config(class_node, import_aliases)
    if (
        not is_namedtuple_class(class_node, import_aliases)
        and not is_dc
        and not is_at
    ):
        return set()
    if is_dc and not dc_init_enabled:
        return set()
    if is_at and not at_init_enabled:
        return set()

    names = set[str]()
    for item in class_node.body:
        if (
            not isinstance(item, ast.AnnAssign)
            or not isinstance(item.target, ast.Name)
            or item.annotation is None
        ):
            continue
        if is_classvar_annotation(item.annotation, import_aliases):
            continue

        include_in_init = True
        if isinstance(item.value, ast.Call) and expr_matches_name(
            item.value.func, import_aliases, "field"
        ):
            for keyword in item.value.keywords:
                if keyword.arg != "init":
                    continue
                literal_value = bool_literal(keyword.value)
                if literal_value is not None:
                    include_in_init = literal_value
                break

        if include_in_init:
            names |= collect_type_names_from_annotation(item.annotation)

    return names


def extract_synthetic_init_parameters(  # noqa: C901, PLR0912
    class_node: ast.ClassDef,
    module_source: str,
    import_aliases: dict[str, str],
    *,
    kw_only_by_default: bool,
) -> list[tuple[str, str, str | None, bool]]:
    """Extract ``(name, annotation, default, kw_only)`` for each field."""
    parameters: list[tuple[str, str, str | None, bool]] = []
    for item in class_node.body:
        if not isinstance(item, ast.AnnAssign) or not isinstance(
            item.target, ast.Name
        ):
            continue
        if is_classvar_annotation(item.annotation, import_aliases):
            continue

        include_in_init = True
        kw_only = kw_only_by_default
        default_value: str | None = None
        if item.value is not None:
            if isinstance(item.value, ast.Call) and expr_matches_name(
                item.value.func, import_aliases, "field"
            ):
                for keyword in item.value.keywords:
                    if keyword.arg == "init":
                        literal_value = bool_literal(keyword.value)
                        if literal_value is not None:
                            include_in_init = literal_value
                    elif keyword.arg == "kw_only":
                        literal_value = bool_literal(keyword.value)
                        if literal_value is not None:
                            kw_only = literal_value
                    elif keyword.arg == "default":
                        default_value = get_node_source(
                            keyword.value, module_source
                        )
                    elif keyword.arg in {
                        "default_factory",
                        "factory",
                    }:
                        default_value = "..."
            else:
                default_value = get_node_source(item.value, module_source)

        if not include_in_init:
            continue

        parameters.append(
            (
                item.target.id,
                get_node_source(item.annotation, module_source, "Any"),
                default_value,
                kw_only,
            )
        )
    return parameters


def build_synthetic_init_stub(
    class_node: ast.ClassDef,
    module_source: str,
    import_aliases: dict[str, str],
) -> str | None:
    """Build a synthetic ``__init__`` stub for a declarative class."""
    is_nt = is_namedtuple_class(class_node, import_aliases)
    is_dc, dc_init_enabled, dc_kw_only = get_dataclass_config(
        class_node, import_aliases
    )
    is_at, at_init_enabled, at_kw_only = get_attrs_config(
        class_node, import_aliases
    )
    if not is_nt and not is_dc and not is_at:
        return None
    if is_dc and not dc_init_enabled:
        return None
    if is_at and not at_init_enabled:
        return None

    kw_only_by_default = dc_kw_only or at_kw_only
    parameters = extract_synthetic_init_parameters(
        class_node,
        module_source,
        import_aliases,
        kw_only_by_default=kw_only_by_default,
    )
    if not parameters:
        return None

    signature_parts = ["self"]
    inserted_kw_only_marker = False
    for param_name, annotation_source, default_value, kw_only in parameters:
        if kw_only and not inserted_kw_only_marker:
            signature_parts.append("*")
            inserted_kw_only_marker = True
        part = f"{param_name}: {annotation_source}"
        if default_value is not None:
            part += f" = {default_value}"
        signature_parts.append(part)

    signature = ", ".join(signature_parts)
    return f"    def __init__({signature}):\n        ..."
