"""
Script counterpart to ``legacy.yml``.
"""


from functools import lru_cache
import logging
from typing import Optional, Sequence, Literal, Self

import arcpy
import attrs
import pandas as pd
import yaml

from .iterablenamespace import FrozenDict, FrozenList
from .config_dataclasses import NG911Field, NG911FeatureClass
from .session import config


_logger = logging.getLogger(__name__)


def convert_legacy_fields(gdb: str, feature_classes: Sequence[NG911FeatureClass], legacy_fields: Sequence[NG911Field], convert_to: Literal["LEGACY", "NEXTGEN"], update_nulls_only: bool = True) -> int:
    """
    Performs field conversions related to legacy abbreviations.

    :param gdb: Geodatabase path
    :param feature_classes: Feature classes containing the supplied
        *legacy_fields* to include in the conversion
    :param legacy_fields: The legacy fields involved in the conversion (the
        corresponding next-gen fields will be determined automatically)
    :param convert_to: Direction to convert to; use ``"LEGACY"`` to convert
        from next-gen to legacy, or use ``"NEXTGEN"`` to convert from legacy to
        next-gen
    :param update_nulls_only: Whether to only overwrite values that are null,
        default True
    :return: Number of feature attributes updated
    """
    if convert_to not in ("LEGACY", "NEXTGEN"):
        raise ValueError(f"Argument 'convert_to' must be either 'LEGACY' or 'NEXTGEN', not '{convert_to}'.")
    to_legacy: bool = convert_to == "LEGACY"
    to_nextgen: bool = convert_to == "NEXTGEN"
    total_update_count: int = 0
    with arcpy.EnvManager(workspace=gdb):
        for fc in feature_classes:
            for legacy_field in legacy_fields:
                legacy_info = LegacyFieldInfo.for_field(legacy_field)
                ng_field = legacy_info.next_gen
                fields = [fc.unique_id, ng_field, legacy_field]
                nguid, ng, lgcy = [f.name for f in fields]
                if missing_fields := set(fields) - set(fc.fields.values()):
                    field_list_str = ', '.join(f"'{f.name}'" for f in missing_fields)
                    arcpy.AddMessage(f"Field(s) {field_list_str} not applicable for '{fc.name}'; skipping fields...")
                    continue  # to next field pair

                with arcpy.da.UpdateCursor(fc.name, [nguid, ng, lgcy]) as uc:
                    update_count: int = 0
                    for row in uc:
                        nguid_val, ng_val, lgcy_val = row
                        if to_nextgen:
                            if update_nulls_only and ng_val is not None:
                                continue  # to next value
                            elif legacy_info.equal:
                                ng_val = lgcy_val
                            elif (new_val := legacy_info.get_next_gen_value(lgcy_val)) is not None:
                                ng_val = new_val
                            else:
                                arcpy.AddWarning(f"{legacy_field.name} value '{lgcy_val}' has no corresponding next-gen value; skipping value for feature {nguid_val}...")
                                continue  # to next value
                        elif to_legacy:
                            if update_nulls_only and lgcy_val is not None:
                                continue  # to next value
                            elif legacy_info.equal:
                                lgcy_val = ng_val
                            elif (new_val := legacy_info.get_legacy_value(ng_val)) is not None:
                                lgcy_val = new_val
                            else:
                                arcpy.AddWarning(f"{ng_field.name} value '{ng_val}' has no corresponding legacy value; skipping value for feature {nguid_val}...")
                                continue  # to next value
                        uc.updateRow((nguid_val, ng_val, lgcy_val))
                        update_count += 1
                        total_update_count += 1

                updated_field_name = {"LEGACY": legacy_field, "NEXTGEN": ng_field}[convert_to].name
                arcpy.AddMessage(f"{fc.name} - Updated {update_count} value(s) of {updated_field_name}.")
    arcpy.AddMessage(f"Done. Updated {total_update_count} value(s).")
    return total_update_count


@attrs.frozen
class LegacyFieldInfo(object):
    """Contains information and performs conversions relating to corresponding
    pairs of next-gen/legacy fields. **This class should not typically be
    instantiated directly!** Use ``load()`` or ``for_field()`` instead."""

    next_gen: NG911Field = attrs.field(converter=config.fields.__getitem__)
    """NG911 field of a legacy/next-gen field pair."""

    legacy: NG911Field = attrs.field(converter=config.fields.__getitem__)
    """Legacy field of a legacy/next-gen field pair."""

    concatenation: bool
    """Whether the fields in the legacy/next-gen field pair are intended to be a concatenation of other fields."""

    equal: bool
    """Whether the values of the legacy field should always equal those of the next-gen field."""

    _value_map: Optional[FrozenDict[str, str]] = attrs.field(default=None, converter=lambda x: FrozenDict(x) if x else None)
    """Mapping from the next-gen to the corresponding legacy values."""

    @classmethod
    @lru_cache(1)
    def load(cls) -> FrozenList[Self]:
        """Loads the data from ``legacy.yml`` and returns a list of instances
        of this class corresponding to each entry under the ``fields`` key in
        that file."""
        yaml_path: str = fr"{__file__}\..\..\..\legacy.yml"
        with open(yaml_path, "r") as yaml_file:
            yaml_data = yaml.safe_load(yaml_file)
        return FrozenList(cls(**field_data) for field_data in yaml_data["fields"])

    @classmethod
    @lru_cache(1)
    def get_legacy_street_fields(cls) -> FrozenList[NG911Field]:
        """
        Loads the legacy street fields in the order used to compute
        :ng911field:`lgcyfulst`. This is the legacy equivalent of
        :attr:`config.street_fields <ng911ok.lib.session._NG911Config.street_fields>`.

        .. warning::

            If the entries under the ``fields`` key in ``legacy.yml`` are not
            in the correct order, then the sequence returned by this function
            will also be out of order.

        :return: Legacy street field objects.
        :rtype: FrozenList[NG911Field]
        """
        info = cls.load()
        return FrozenList(f.legacy for f in info if not f.concatenation)

    @classmethod
    def for_field(cls, field: Optional[NG911Field] = None, *, role: Optional[str] = None, name: Optional[str] = None) -> Self:
        """Given an argument denoting either a legacy field or a next-gen field
        with a corresponding legacy field, returns an instance of this class
        with data relevant to that field."""
        if len([*filter(bool, (field, role, name))]) != 1:
            raise TypeError("Exactly one of 'field', 'role', or 'name' must be provided.")
        if role:
            field = config.fields[role]
        elif name:
            field = config.get_field_by_name(name)
        for info in cls.load():
            if info.next_gen == field or info.legacy == field:
                return info
        raise ValueError("The argument provided matches neither a legacy field nor a next-gen field with corresponding legacy field.")

    @property
    def value_map(self) -> pd.Series:
        """Returns a ``pandas.Series`` of the instance's value map."""
        return pd.Series(self._value_map, dtype=pd.StringDtype())

    def get_legacy_value(self, next_gen_value: str) -> str | None:
        """Given a next-gen value present in an instance's ``value_map``,
        returns the corresponding legacy value. If there is no match, ``None``
        is returned."""
        if self.equal:
            return next_gen_value
        else:
            return self._value_map.get(next_gen_value)

    def get_next_gen_value(self, legacy_value: str) -> str | None:
        """Given a legacy value present in an instance's ``value_map``, returns
        a corresponding next-gen value. **In cases where a legacy value matches
        multiple next-gen values, only one next-gen value will be returned.**
        If there is no match, or if ``value_map`` is empty, this method returns
        ``None``."""
        if self.equal:
            return legacy_value
        else:
            match_series: pd.Series = self.get_next_gen_values(legacy_value)
            return match_series[0] if len(match_series) else None

    def get_next_gen_values(self, legacy_value: str) -> pd.Series:
        """Given a legacy value present in an instance's ``value_map``, returns
        a ``pandas.Series`` with any corresponding next-gen values. Most, **but
        not all**, legacy values correspond to only one next-gen value. If
        there is no match, an empty ``pandas.Series`` will be returned."""
        if self.equal:
            return pd.Series([legacy_value], dtype=pd.StringDtype())
        else:
            return self.value_map[self.value_map == legacy_value]

    def compare_columns(self, df: pd.DataFrame) -> pd.Series:
        """
        Given a feature class data frame, evaluates whether the values in the
        column with the same name as ``self.legacy.name`` correspond as
        expected to the values in the column named ``self.next_gen.name``.
        Returns a boolean ``Series`` (with the same index as *df*) indicating
        whether the value in the legacy column was indeed the expected value.

        :param df: Feature class data frame
        :type df: pandas.DataFrame
        :return: Boolean validity series
        :rtype: pandas.Series
        """
        expected_lgcy: pd.DataFrame
        if self.value_map:
            expected_lgcy = df[[self.next_gen.name]].applymap(lambda val: self.value_map[val])
        elif self.equal:
            expected_lgcy = df[[self.next_gen.name]].copy()
        else:
            arcpy.AddWarning("LegacyFieldInfo.compare_columns() not fully implemented! See code for details.")
            return df[[self.next_gen.name]].applymap(lambda val: True) # TODO: Finish when functions have been written to compute LgcyFulSt/LgcyFulAdd; right now, just assumes those fields are correct.
        expected_lgcy.rename(columns={self.next_gen.name: "$expected"})
        # _logger.debug(f"expected_lgcy columns: {', '.join(expected_lgcy.columns)}")
        joined: pd.DataFrame = df[[self.next_gen.name, self.legacy.name]].join(expected_lgcy, on=self.next_gen.name)
        return joined[self.legacy.name].eq(joined["$expected"])


if __name__ == "__main__":
    breakpoint()