# SPDX-License-Identifier: ISC

# Copyright (c) 2021, Timothée Mazzucotelli and contributors

# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.

# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

# This module contains utilities to merge stubs data and concrete data.

from __future__ import annotations

from contextlib import suppress
from typing import TYPE_CHECKING

from griffe._internal.exceptions import AliasResolutionError, CyclicAliasError
from griffe._internal.expressions import Expr
from griffe._internal.logger import logger

if TYPE_CHECKING:
    from collections.abc import Sequence

    from griffe._internal.models import Attribute, Class, Function, Module, Object, Parameter, TypeAlias


def _merge_module_stubs(module: Module, stubs: Module, *, psd: bool = False) -> None:
    _merge_stubs_docstring(module, stubs, psd=psd)
    _merge_stubs_overloads(module, stubs)
    _merge_stubs_members(module, stubs, psd=psd)


def _merge_class_stubs(class_: Class, stubs: Class, *, psd: bool = False) -> None:
    _merge_stubs_docstring(class_, stubs, psd=psd)
    _merge_stubs_overloads(class_, stubs)
    _merge_stubs_type_parameters(class_, stubs)
    _merge_stubs_members(class_, stubs, psd=psd)


def _merge_function_stubs(function: Function, stubs: Function, *, psd: bool = False) -> None:
    _merge_stubs_docstring(function, stubs, psd=psd)
    parameters: dict[str, Parameter] = {}
    for parameter in function.parameters:
        parameters.setdefault(parameter.name, parameter)
    for parameter in stubs.parameters:
        with suppress(KeyError):
            parameters[parameter.name].annotation = parameter.annotation
    function.returns = stubs.returns
    _merge_stubs_type_parameters(function, stubs)


def _merge_attribute_stubs(attribute: Attribute, stubs: Attribute, *, psd: bool = False) -> None:
    _merge_stubs_docstring(attribute, stubs, psd=psd)
    attribute.annotation = stubs.annotation
    if stubs.value not in (None, "..."):
        attribute.value = stubs.value


def _merge_type_alias_stubs(type_alias: TypeAlias, stubs: TypeAlias, *, psd: bool = False) -> None:
    _merge_stubs_docstring(type_alias, stubs, psd=psd)
    _merge_stubs_type_parameters(type_alias, stubs)


def _merge_stubs_docstring(obj: Object, stubs: Object, *, psd: bool = False) -> None:
    if (psd or not obj.docstring) and stubs.docstring:
        obj.docstring = stubs.docstring


def _merge_stubs_type_parameters(obj: Class | Function | TypeAlias, stubs: Class | Function | TypeAlias) -> None:
    if not obj.type_parameters and stubs.type_parameters:
        obj.type_parameters = stubs.type_parameters


def _merge_stubs_overloads(obj: Module | Class, stubs: Module | Class) -> None:
    for function_name, overloads in list(stubs.overloads.items()):
        if overloads:
            with suppress(KeyError):
                if (func := obj.get_member(function_name)).is_function:
                    _merge_overload_annotations(func, overloads)
                else:
                    logger.debug(
                        "Cannot merge overload annotations into (non-function) %s %r",
                        func.kind.value.lower(),
                        func.path,
                    )
        del stubs.overloads[function_name]


def _merge_annotations(annotations: Sequence[Expr]) -> Expr | None:
    if len(annotations) == 1:
        return annotations[0]
    if annotations:
        return Expr._to_binop(annotations, op="|")
    return None


def _merge_overload_annotations(function: Function, overloads: list[Function]) -> None:
    function.overloads = overloads
    overload_parameters: list[dict[str, Parameter]] = []
    for overload in overloads:
        parameters: dict[str, Parameter] = {}
        for parameter in overload.parameters:
            parameters.setdefault(parameter.name, parameter)
        overload_parameters.append(parameters)
    for parameter in function.parameters:
        if parameter.annotation is None:
            seen = set()
            annotations = []
            for parameters in overload_parameters:
                with suppress(KeyError):
                    annotation = parameters[parameter.name].annotation
                    str_annotation = str(annotation)
                    if isinstance(annotation, Expr) and str_annotation not in seen:
                        annotations.append(annotation)
                        seen.add(str_annotation)
            parameter.annotation = _merge_annotations(annotations)
    if function.returns is None:
        seen = set()
        return_annotations = []
        for overload in overloads:
            str_annotation = str(overload.returns)
            if isinstance(overload.returns, Expr) and str_annotation not in seen:
                return_annotations.append(overload.returns)
                seen.add(str_annotation)
        function.returns = _merge_annotations(return_annotations)


def _merge_stubs_members(obj: Module | Class, stubs: Module | Class, *, psd: bool = False) -> None:
    # Merge imports to later know if objects coming from the stubs were imported.
    obj.imports.update(stubs.imports)

    # Override exports to later know if objects coming from the stubs were exported.
    if stubs.exports is not None:
        obj.exports = stubs.exports

    for member_name, stub_member in stubs.members.items():
        if member_name in obj.members:
            # We don't merge imported stub objects that already exist in the concrete module.
            # Stub objects must be defined where they are exposed in the concrete package,
            # not be imported from other stub modules.
            if stub_member.is_alias:
                continue
            obj_member = obj.get_member(member_name)
            with suppress(AliasResolutionError, CyclicAliasError):
                # An object's canonical location can differ from its equivalent stub location.
                # Devs usually declare stubs at the public location of the corresponding object,
                # not the canonical one. Therefore, we must allow merging stubs into the target of an alias,
                # as long as the stub and target are of the same kind.
                if obj_member.kind is not stub_member.kind:
                    # If the stub and the target are not of the same kind, prefer the
                    # stub over the target.
                    logger.debug(
                        "Source object `%s` will be overwritten by stub object.",
                        obj_member.path,
                    )
                    obj.set_member(stub_member.name, stub_member)
                elif obj_member.is_module:
                    _merge_module_stubs(obj_member, stub_member, psd=psd)  # ty:ignore[invalid-argument-type]
                elif obj_member.is_class:
                    _merge_class_stubs(obj_member, stub_member, psd=psd)  # ty:ignore[invalid-argument-type]
                elif obj_member.is_function:
                    _merge_function_stubs(obj_member, stub_member, psd=psd)  # ty:ignore[invalid-argument-type]
                elif obj_member.is_attribute:
                    _merge_attribute_stubs(obj_member, stub_member, psd=psd)  # ty:ignore[invalid-argument-type]
                elif obj_member.is_type_alias:
                    _merge_type_alias_stubs(obj_member, stub_member, psd=psd)  # ty:ignore[invalid-argument-type]
        else:
            stub_member.runtime = False
            obj.set_member(member_name, stub_member)


def merge_stubs(mod1: Module, mod2: Module, *, prefer_stubs_docs: bool = False) -> Module:
    """Merge stubs into a module.

    Parameters:
        mod1: A regular module or stubs module.
        mod2: A regular module or stubs module.
        prefer_stubs_docs: Whether to give precedence to stubs docstrings
            rather than source docstrings. When both are present, the stubs one
            will override the source one.

    Raises:
        ValueError: When both modules are regular modules (no stubs is passed).

    Returns:
        The regular module.
    """
    logger.debug("Trying to merge %s and %s", mod1.filepath, mod2.filepath)
    if mod1.filepath.suffix == ".pyi":  # ty:ignore[unresolved-attribute]
        stubs = mod1
        module = mod2
    elif mod2.filepath.suffix == ".pyi":  # ty:ignore[unresolved-attribute]
        stubs = mod2
        module = mod1
    else:
        raise ValueError("cannot merge regular (non-stubs) modules together")
    _merge_module_stubs(module, stubs, psd=prefer_stubs_docs)
    return module
