"""
Contains the logic to detect the different changes between two BO4E versions.
"""

import itertools
import logging
import re
from pathlib import Path
from typing import Iterable, Sequence

from bost.schema import AllOf, AnyOf, Array, Object, Reference, SchemaRootType, SchemaType, StrEnum, String, TypeBase

from . import change_schemas, loader

logger = logging.getLogger(__name__)

REGEX_IGNORE_VERSION = re.compile(r"v\d+\.\d+\.\d+(-rc\d+)?")


def _diff_type_base(
    schema_old: TypeBase, schema_new: TypeBase, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two type base schemas and yields the changes.
    """
    if schema_old.title != schema_new.title:
        logger.warning(
            (
                "Title should not change. Renaming is not detectable and the titles are autogenerated.\n"
                "'%s' -> '%s'\n"
                "%s -> %s"
            ),
            schema_old.title,
            schema_new.title,
            old_trace,
            new_trace,
        )
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_TITLE_CHANGED,
            old=schema_old.title,
            new=schema_new.title,
            old_trace=old_trace,
            new_trace=new_trace,
        )
    if REGEX_IGNORE_VERSION.sub(schema_old.description, "{__gh_version__}") != REGEX_IGNORE_VERSION.sub(
        schema_new.description, "{__gh_version__}"
    ):
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_DESCRIPTION_CHANGED,
            old=schema_old.description,
            new=schema_new.description,
            old_trace=old_trace,
            new_trace=new_trace,
        )
    if schema_old.default != schema_new.default and schema_old.title != " Version" and schema_new.title != " Version":
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_DEFAULT_CHANGED,
            old=schema_old.default,
            new=schema_new.default,
            old_trace=old_trace,
            new_trace=new_trace,
        )


def _diff_enum_schemas(
    schema_old: StrEnum, schema_new: StrEnum, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two enum schemas and yields the changes.
    """
    new_enum_values = set(schema_new.enum)
    for old_enum_value in schema_old.enum:
        if old_enum_value not in new_enum_values:
            yield change_schemas.Change(
                type=change_schemas.ChangeType.ENUM_VALUE_REMOVED,
                old=old_enum_value,
                new=None,
                old_trace=old_trace,
                new_trace=new_trace,
            )
        else:
            new_enum_values.remove(old_enum_value)
    for new_enum_value in new_enum_values:
        yield change_schemas.Change(
            type=change_schemas.ChangeType.ENUM_VALUE_ADDED,
            old=None,
            new=new_enum_value,
            old_trace=old_trace,
            new_trace=new_trace,
        )


def _diff_object_schemas(
    schema_old: Object, schema_new: Object, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two object schemas and yields the changes.
    """
    new_properties = set(schema_new.properties.keys())
    for key, value in schema_old.properties.items():
        if key not in schema_new.properties:
            yield change_schemas.Change(
                type=change_schemas.ChangeType.FIELD_REMOVED,
                old=value,
                new=None,
                old_trace=f"{old_trace}.properties['{key}']",
                new_trace=new_trace,
            )
        else:
            new_properties.remove(key)

            # Field exists in both schemas, check for further changes
            yield from _diff_schema_type(
                value,
                schema_new.properties[key],
                f"{old_trace}.properties['{key}']",
                f"{new_trace}.properties['{key}']",
            )
    for key in new_properties:
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_ADDED,
            old=None,
            new=schema_new.properties[key],
            old_trace=old_trace,
            new_trace=f"{new_trace}.properties['{key}']",
        )


def _diff_ref_schemas(
    schema_old: Reference, schema_new: Reference, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two reference schemas and yields a change if the references are different.
    Even if the referenced schema only got renamed or moved, the reference will be treated as different
    because in any client application you would have to update the references.
    """
    if schema_old.ref != schema_new.ref:
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_REFERENCE_CHANGED,
            old=schema_old.ref,
            new=schema_new.ref,
            old_trace=old_trace,
            new_trace=new_trace,
        )


def _diff_array_schemas(
    schema_old: Array, schema_new: Array, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two array schemas and yields the changes.
    """
    yield from _diff_schema_type(schema_old.items, schema_new.items, f"{old_trace}.items", f"{new_trace}.items")


def _diff_any_of_or_all_of_schemas(
    schema_old: AnyOf | AllOf, schema_new: AnyOf | AllOf, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two anyOf or allOf schemas and yields the changes.
    """
    assert type(schema_old) is type(schema_new), "Internal error: This function should only be called for equal types"
    if isinstance(schema_old, AnyOf):
        query_str = "any_of"
    else:
        query_str = "all_of"
    found_new_types = set()
    for old_index, old_type in enumerate(getattr(schema_old, query_str)):
        found_old_in_new = False
        for new_index, new_type in enumerate(getattr(schema_new, query_str)):
            changes = list(
                _diff_schema_type(
                    old_type, new_type, f"{old_trace}.{query_str}[{old_index}]", f"{new_trace}.{query_str}[{new_index}]"
                )
            )
            if not any(change_schemas.filter_non_crit(changes)):
                # The types are equal (except for non-critical changes), yield the non-critical changes
                found_old_in_new = True
                assert new_index not in found_new_types, "Internal error: Duplicate type in anyOf"
                found_new_types.add(new_index)
                yield from changes
                break
        if not found_old_in_new:
            yield change_schemas.Change(
                type=(
                    change_schemas.ChangeType.FIELD_ANY_OF_TYPE_REMOVED
                    if isinstance(schema_old, AnyOf)
                    else change_schemas.ChangeType.FIELD_ALL_OF_TYPE_REMOVED
                ),
                old=old_type,
                new=None,
                old_trace=f"{old_trace}.{query_str}[{old_index}]",
                new_trace=f"{new_trace}.{query_str}",
            )
    not_found_indices = set(range(len(getattr(schema_new, query_str)))) - found_new_types
    for new_index in not_found_indices:
        yield change_schemas.Change(
            type=(
                change_schemas.ChangeType.FIELD_ANY_OF_TYPE_ADDED
                if isinstance(schema_old, AnyOf)
                else change_schemas.ChangeType.FIELD_ALL_OF_TYPE_ADDED
            ),
            old=None,
            new=getattr(schema_new, query_str)[new_index],
            old_trace=old_trace,
            new_trace=f"{new_trace}.{query_str}[{new_index}]",
        )


def _diff_string_schemas(
    schema_old: String, schema_new: String, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two string schemas and yields the changes.
    """
    if schema_old.format != schema_new.format:
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_STRING_FORMAT_CHANGED,
            old=schema_old.format,
            new=schema_new.format,
            old_trace=old_trace,
            new_trace=new_trace,
        )


def _diff_schema_differing_types(
    schema_old: SchemaType, schema_new: SchemaType, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two differing schema types and yields the changes.
    """
    assert type(schema_old) is not type(
        schema_new
    ), "Internal error: This function should only be called for differing types"
    # Types are different. Check if it is "only" a change in cardinality
    if isinstance(schema_old, Object) and isinstance(schema_new, Array):
        sub_changes = list(_diff_schema_type(schema_old, schema_new.items, old_trace, f"{new_trace}.items"))
    elif isinstance(schema_old, Array) and isinstance(schema_new, Object):
        sub_changes = list(_diff_schema_type(schema_old.items, schema_new, f"{old_trace}.items", new_trace))
    else:
        sub_changes = None

    if sub_changes is None or any(change_schemas.filter_non_crit(sub_changes)):
        # Treat the types as equal iff there are no critical changes between the types
        # In if-Block, the types are different
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_TYPE_CHANGED,
            old=schema_old,
            new=schema_new,
            old_trace=old_trace,
            new_trace=new_trace,
        )
    else:
        # If the types are equal (except for non-critical changes), yield the non-critical changes
        # plus a change in cardinality
        yield from sub_changes
        # If the type of one schema is equal to the items type of the other, there is a change in cardinality
        yield change_schemas.Change(
            type=change_schemas.ChangeType.FIELD_CARDINALITY_CHANGED,
            old=schema_old,
            new=schema_new,
            old_trace=old_trace,
            new_trace=new_trace,
        )


def _diff_schema_type(
    schema_old: SchemaType, schema_new: SchemaType, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two schema types and yields the changes.
    """
    yield from _diff_type_base(schema_old, schema_new, old_trace, new_trace)
    if type(schema_old) is not type(schema_new):
        yield from _diff_schema_differing_types(schema_old, schema_new, old_trace, new_trace)
    # Even if the types are equal on this shallow level, we must do some more checks for certain
    # types.
    elif isinstance(schema_new, StrEnum):
        yield from _diff_enum_schemas(schema_old, schema_new, old_trace, new_trace)  # type: ignore[arg-type]
        # mypy isn't able to know that type(schema_new) is type(schema_old) here (and in the following)
    elif isinstance(schema_new, Object):
        yield from _diff_object_schemas(schema_old, schema_new, old_trace, new_trace)  # type: ignore[arg-type]
    elif isinstance(schema_new, Reference):
        yield from _diff_ref_schemas(schema_old, schema_new, old_trace, new_trace)  # type: ignore[arg-type]
    elif isinstance(schema_new, Array):
        yield from _diff_array_schemas(schema_old, schema_new, old_trace, new_trace)  # type: ignore[arg-type]
    elif isinstance(schema_new, (AnyOf, AllOf)):
        yield from _diff_any_of_or_all_of_schemas(
            schema_old,  # type: ignore[arg-type]
            schema_new,
            old_trace,
            new_trace,
        )
    # Any other types are definitely equal at this point


def _diff_root_schemas(
    schema_old: SchemaRootType, schema_new: SchemaRootType, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two root schemas and yields the changes.
    """
    yield from _diff_schema_type(schema_old, schema_new, old_trace, new_trace)


def diff_schemas(
    schemas_old: Path, schemas_new: Path, old_trace: str, new_trace: str
) -> Iterable[change_schemas.Change]:
    """
    This function compares two BO4E versions and yields the changes.
    Note: The paths to the old and the new schemas should correspond to the same root node of the tree structure.
    I.e. the direct subdirectories should be "bo", "com" and "enum".
    """
    old_schema_files = {file.relative_to(schemas_old) for file in schemas_old.rglob("*.json")}
    new_schema_files = {file.relative_to(schemas_new) for file in schemas_new.rglob("*.json")}

    for schema_file in old_schema_files - new_schema_files:
        yield change_schemas.Change(
            type=change_schemas.ChangeType.CLASS_REMOVED,
            old=loader.load_schema_file(schemas_old / schema_file),
            new=None,
            old_trace=f"{old_trace}/{'/'.join(schema_file.with_suffix('').parts)}#",
            new_trace=f"{new_trace}/#",
        )
    for schema_file in new_schema_files - old_schema_files:
        yield change_schemas.Change(
            type=change_schemas.ChangeType.CLASS_ADDED,
            old=None,
            new=loader.load_schema_file(schemas_new / schema_file),
            old_trace=f"{old_trace}/#",
            new_trace=f"{new_trace}/{'/'.join(schema_file.with_suffix('').parts)}#",
        )
    for schema_file in old_schema_files & new_schema_files:
        yield from _diff_root_schemas(
            loader.load_schema_file(schemas_old / schema_file),
            loader.load_schema_file(schemas_new / schema_file),
            f"{old_trace}/{'/'.join(schema_file.with_suffix('').parts)}#",
            f"{new_trace}/{'/'.join(schema_file.with_suffix('').parts)}#",
        )


def compare_bo4e_versions(
    version_old: str, version_new: str, gh_token: str | None = None, from_local: bool = False
) -> Iterable[change_schemas.Change]:
    """
    Compare the old version with the new version.
    If version_new is None use the BO4E version of the checkout working directory by assuming the compiled json
    schemas in /json_schemas.
    """
    dir_old_schemas = loader.pull_or_reuse_bo4e_version(version_old, gh_token)
    dir_new_schemas = loader.pull_or_reuse_bo4e_version(version_new, gh_token, from_local=from_local)
    print(f"Comparing {version_old} with {version_new}")
    yield from diff_schemas(dir_old_schemas, dir_new_schemas, version_old, version_new)


def compare_bo4e_versions_iteratively(
    versions: Sequence[str], cur_version: str | None = None, gh_token: str | None = None
) -> dict[tuple[str, str], Iterable[change_schemas.Change]]:
    """
    Compare the versions iteratively. Each version at index i will be compared to the version at index i+1.
    Additionally, if cur_version is provided, the last version in the list will be compared to the version
    in the checkout working directory. The value of cur_version will be used to set the key in the returned
    dict.
    Note:
        - versions must contain at least one element.
        - versions should be sorted in ascending order.
        - if using cur_version, ensure that the json schemas of the checkout working directory
          were build on beforehand. They should be located in /json_schemas.
    """
    print(f"Comparing versions {versions} with cur_version {cur_version}")
    if len(versions) == 0:
        print("No versions to compare.")
        return {}
    changes = {}
    last_version: str = versions[0]  # This value is never used but makes mypy and pylint happy
    for version_old, version_new in itertools.pairwise(versions):
        last_version = version_new
        changes[version_old, version_new] = compare_bo4e_versions(version_old, version_new, gh_token)
    if cur_version is not None:
        changes[last_version, cur_version] = compare_bo4e_versions(last_version, cur_version, gh_token, from_local=True)
    print("Comparisons finished.")
    return changes
