"""
Reads in configuration data from ``config.yml``.

:authors: Riley Baird (OK), Emma Baker (OK)
:created: August 20, 2024
:modified:  May 12, 2025
"""
import difflib
import logging
from collections.abc import Sequence, Collection
from pathlib import Path
from typing import Final, Literal

import attrs
import yaml
from typing_extensions import Optional

from .iterablenamespace import FrozenList
from .config_dataclasses import NG911FeatureClass, NG911Field, NG911Domain, FeatureClassInfoNamespace, FieldInfoNamespace
from .fctypes import *


__all__ = ["GDB_ERROR_TABLE_NAME", "FEATURE_ATTRIBUTE_ERROR_TABLE_NAME", "MSAG_ISSUE_TABLE_NAME", "TN_ISSUE_TABLE_NAME", "config", "cd", "cf", "cfc", "RDS", "ODS"]

_logger = logging.getLogger(__name__)

GDB_ERROR_TABLE_NAME: Final[str] = "TemplateCheckResults"
FEATURE_ATTRIBUTE_ERROR_TABLE_NAME: Final[str] = "FieldValuesCheckResults"
MSAG_ISSUE_TABLE_NAME: Final[str] = "MSAG_Issues"
TN_ISSUE_TABLE_NAME: Final[str] = "TN_Issues"

_tool_folder_path: Path = Path(__file__).parent.parent.parent  #os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_config_path: Path = _tool_folder_path / "config.yml"  #os.path.join(_tool_folder_path, "config.yml")

yaml.Node.__match_args__ = ("tag", "value", "start_mark", "end_mark")  # For match-case blocks

def _construct_domain_namespace(loader: yaml.Loader, node: yaml.Node) -> NG911DomainNamespace:
    assert isinstance(node, yaml.SequenceNode)
    sequence = loader.construct_sequence(node, deep=True)
    domains = {x.name: x for x in sequence}
    return NG911DomainNamespace(**domains)

def _construct_field_namespace(loader: yaml.Loader, node: yaml.Node) -> NG911FieldNamespace:
    assert isinstance(node, yaml.SequenceNode)
    sequence = loader.construct_sequence(node, deep=True)
    fields = {x.role: x for x in sequence}
    return NG911FieldNamespace(**fields)

def _construct_feature_class_namespace(loader: yaml.Loader, node: yaml.Node) -> NG911FeatureClassNamespace:
    assert isinstance(node, yaml.SequenceNode)
    sequence = loader.construct_sequence(node, deep=True)
    feature_classes = {x.role: x for x in sequence}
    return NG911FeatureClassNamespace(**feature_classes)

def _construct_feature_class(loader: yaml.Loader, tag: str, node: yaml.Node) -> NG911FeatureClass:
    assert isinstance(node, yaml.MappingNode)
    mapping = loader.construct_mapping(node)
    mapping["fields"] = init_field_namespace(tag, mapping["fields"])
    return NG911FeatureClass(**mapping)


yaml.SafeLoader.add_constructor("!CompleteDomainList", _construct_domain_namespace)
yaml.SafeLoader.add_constructor("!CompleteFieldList", _construct_field_namespace)
yaml.SafeLoader.add_constructor("!FeatureClassList", _construct_feature_class_namespace)

yaml.SafeLoader.add_multi_constructor("!FeatureClass/", _construct_feature_class)


@attrs.frozen(repr=False, str=False)
class _NG911Config(yaml.YAMLObject):
    """Class containing data from ``config.yml``."""
    yaml_tag = "!NG911Config"
    yaml_loader = yaml.SafeLoader

    gdb_info: NG911GeodatabaseInfo
    """Object containing information that applies to the
    :term:`NG911 geodatabase` or to multiple items therein."""

    domains: NG911DomainNamespace
    """Object containing information about the domains specified in the
    Standard. The namespace is designed to facilitate autocompletion."""

    fields: NG911FieldNamespace
    """Object containing information about the fields specified in the
    Standard. The namespace is designed to facilitate autocompletion."""

    feature_classes: NG911FeatureClassNamespace
    """Object containing information about the feature class schemas specified
    in the Standard. The namespace is designed to facilitate autocompletion."""

    @classmethod
    def from_yaml(cls, loader: yaml.SafeLoader, node: yaml.Node):
        assert isinstance(node, yaml.MappingNode)
        return cls(**loader.construct_mapping(node, deep=True))

    # domain_info: list[dict[str, str | CodedValueDict]]
    # """List of dicts with keys 'name', 'description', 'type', 'items'"""

    field_info: dict[str, FieldInfoNamespace] = attrs.field(default=attrs.Factory(
        lambda self: {f.role: f.as_namespace for f in self.fields.values()},
        takes_self=True
    ), init=False)
    """
    Dictionary of all fields with field properties

    .. deprecated:: 3.0.0-alpha.2
       Use :attr:`.fields` instead.
    """

    feature_class_info: dict[str, FeatureClassInfoNamespace] = attrs.field(default=attrs.Factory(
        lambda self: {role: fc.as_namespace for role, fc in self.feature_classes.items()},
        takes_self=True
    ), init=False)
    """
    Dictionary of feature class information: name, geometry_type, fields (list)

    .. deprecated:: 3.0.0-alpha.2
       Use :attr:`.feature_classes` instead.
    """

    required_feature_class_names: FrozenList[str] = attrs.field(default=attrs.Factory(
        lambda self: FrozenList(self.feature_classes[role].name for role in self.feature_classes.keys() if self.feature_classes[role].dataset == self.gdb_info.required_dataset_name),
        takes_self=True
    ), init=False)
    """Returns a list of the **names** (not roles) of feature classes that
    belong in the :term:`required feature dataset`."""

    optional_feature_class_names: FrozenList[str] = attrs.field(default=attrs.Factory(
        lambda self: FrozenList(self.feature_classes[role].name for role in self.feature_classes.keys() if self.feature_classes[role].dataset == self.gdb_info.optional_dataset_name),
        takes_self=True
    ), init=False)
    """Returns a list of the **names** (not roles) of feature classes that
    are allowed in the :term:`optional feature dataset`."""

    field_names: FrozenList[str] = attrs.field(default=attrs.Factory(
        lambda self: FrozenList(f.name for f in self.fields.values()), True
    ), init=False)
    """Returns a list of all of **names** (not roles) of all fields."""

    street_fields: FrozenList[NG911Field] = attrs.field(default=attrs.Factory(
        lambda self: FrozenList(((cf := self.fields).premod, cf.predir, cf.pretype, cf.pretypesep, cf.street, cf.streettype, cf.sufdir, cf.sufmod)),
        takes_self=True
    ), init=False)
    """Returns a list of all the :class:`~.config_dataclasses.NG911Field`
    objects relevant to computing the :ng911field:`fullname` field in
    :ng911fc:`road_centerline` and :ng911fc:`address_point`. Legacy fields are
    not included."""

    street_field_names: FrozenList[str] = attrs.field(default=attrs.Factory(
        lambda self: FrozenList(f.name for f in self.street_fields),
        takes_self=True
    ), init=False)
    """Returns a list of the **names** (not roles) of all the
    :class:`~.config_dataclasses.NG911Field` objects relevant to computing the
    :ng911field:`fullname` field in :ng911fc:`road_centerline` and
    :ng911fc:`address_point`. Legacy fields are not included."""

    def get_domain_by_name(self, name: str, case_sensitive: bool = True) -> NG911Domain:
        """
        Returns the instance of ``NG911Domain`` from ``self.domains`` with a
        :attr:`~.config_dataclasses.NG911Domain.name` attribute equal to
        *name*.

        :param name: ``name`` attribute of the domain
        :type name: str
        :param case_sensitive: Whether the case of ``name`` must match that of
            the domain, default True
        :type case_sensitive: bool
        :return: Domain with the given name
        :rtype: NG911Domain
        """
        if case_sensitive:
            filtered_config_domains = [d for d in self.domains.values() if d.name == name]
        else:
            filtered_config_domains = [d for d in self.domains.values() if d.name.upper() == name.upper()]
        if len(filtered_config_domains) < 1:
            raise ValueError(f"Config has no domain named '{name}'.")
        elif len(filtered_config_domains) > 1:
            raise ValueError(f"Config has multiple domains named '{name}'.")
        else:
            return filtered_config_domains[0]

    def get_field_by_name(self, name: str, case_sensitive: bool = True) -> NG911Field:
        """
        Returns the instance of :class:`~.config_dataclasses.NG911Field` from
        :attr:`self.fields <fields>` with a
        :attr:`~.config_dataclasses.NG911Field.name` attribute equal to *name*.

        This is an alternative to selecting a field by its :attr:`~.config_dataclasses.NG911Field.role` attribute,
        which, given a field with role "role_value", would normally be done as
        ``self.fields.role_value`` or ``self.fields["role_value"]``.

        :param name: ``name`` attribute of the field
        :type name: str
        :param case_sensitive: Whether the case of ``name`` must match that of
            the field, default True
        :type case_sensitive: bool
        :return: Field with the given name
        :rtype: NG911Field
        """
        if "." in name:
            raise ValueError(f"A field ('{name}' was requested with a dot in the name. Is the relevant feature class part of a join? Remove the join, if applicable, and try again.")
        if case_sensitive:
            filtered_config_fields = [f for f in self.fields.values() if f.name == name]
        else:
            filtered_config_fields = [f for f in self.fields.values() if f.name.upper() == name.upper()]
        if len(filtered_config_fields) < 1:
            raise ValueError(f"Config is missing field named '{name}'.")
        elif len(filtered_config_fields) > 1:
            raise ValueError(f"Config has multiple fields named '{name}'.")
        else:
            return filtered_config_fields[0]

    def get_feature_class_by_name(self, name: str, case_sensitive: bool = True) -> NG911FeatureClass:
        """
        Returns the instance of :class:`~.config_dataclasses.NG911FeatureClass`
        from :attr:`self.feature_classes <feature_classes>` with a
        :attr:`~.config_dataclasses.NG911FeatureClass.name` attribute equal to
        *name*.

        This is an alternative to selecting a feature class by its :attr:`~.config_dataclasses.NG911FeatureClass.role`
        attribute, which, given a feature class with role "role_value", would
        normally be done as ``self.feature_classes.role_value`` or
        ``self.feature_classes["role_value"]``.

        :param name: ``name`` attribute of the feature class
        :type name: str
        :param case_sensitive: Whether the case of ``name`` must match that of
            the feature class, default True
        :type case_sensitive: bool
        :return: Feature class with the given name
        :rtype: NG911FeatureClass
        """
        if case_sensitive:
            filtered_config_fcs = [fc for fc in self.feature_classes.values() if fc.name == name]
        else:
            filtered_config_fcs = [fc for fc in self.feature_classes.values() if fc.name.upper() == name.upper()]
        if len(filtered_config_fcs) < 1:
            raise ValueError(f"Config is missing feature class named '{name}'.")
        elif len(filtered_config_fcs) > 1:
            raise ValueError(f"Config has multiple feature classes named '{name}'.")
        else:
            return filtered_config_fcs[0]

    def _match_names(self, target: Literal["DOMAINS", "FIELDS", "FEATURE_CLASSES"], names: Sequence[str], on_mismatch: Literal["DROP", "ERROR", "NONE"] = "DROP") -> list[str | None]:
        haystack: dict[str, str] = {  # Values to look through for matches; dict of <uppercase name>: <actual name>
            x.name.upper(): x.name for x in {
                "DOMAINS": self.domains,
                "FIELDS": self.fields,
                "FEATURE_CLASSES": self.feature_classes
            }[target].values()
        }
        needles: list[str] = [x.upper() for x in names]
        result: list[str | None] = []
        for name in needles:
            if name in haystack:
                result.append(haystack[name])
            else:
                # Suppress an apparent type checker false-positive:
                # noinspection PyUnreachableCode
                match on_mismatch:
                    case "DROP":
                        continue
                    case "ERROR":
                        raise ValueError(f"Could not match name '{name}' to target '{target}'.")
                    case "NONE":
                        result.append(None)
                    case _:
                        raise ValueError(f"Invalid argument for on_mismatch: '{on_mismatch}'.")
        return result

    def match_domain_names(self, names: Sequence[str], on_mismatch: Literal["DROP", "ERROR", "NONE"] = "DROP") -> list[str | None]:
        """
        Given a case-insensitive sequence of domain names, return a list of the
        corresponding correctly-cased domain names.

        Valid values for *on_mismatch* are:

        - ``"DROP"``: Silently skip the name
        - ``"ERROR"``: Raise a ``ValueError``
        - ``"NONE"``: Append ``None`` to the results

        :param names: Case-insensitive names to match
        :type names: Sequence[str]
        :param on_mismatch: Behavior if no match is found; default ``"DROP"``
        :type on_mismatch: Literal["DROP", "ERROR", "NONE"]
        :return: List of correctly-cased domain names (and possibly ``None`` if
            *on_mismatch* is ``"NONE"``)
        :rtype: list[str | None]
        """
        return self._match_names("DOMAINS", names, on_mismatch=on_mismatch)

    def match_field_names(self, names: Sequence[str], on_mismatch: Literal["DROP", "ERROR", "NONE"] = "DROP") -> list[str | None]:
        """
        Given a case-insensitive sequence of field names, return a list of the
        corresponding correctly-cased field names.

        Valid values for *on_mismatch* are:

        - ``"DROP"``: Silently skip the name
        - ``"ERROR"``: Raise a ``ValueError``
        - ``"NONE"``: Append ``None`` to the results

        :param names: Case-insensitive names to match
        :type names: Sequence[str]
        :param on_mismatch: Behavior if no match is found; default ``"DROP"``
        :type on_mismatch: Literal["DROP", "ERROR", "NONE"]
        :return: List of correctly-cased field names (and possibly ``None`` if
            *on_mismatch* is ``"NONE"``)
        :rtype: list[str | None]
        """
        return self._match_names("FIELDS", names, on_mismatch=on_mismatch)

    def match_feature_class_names(self, names: Sequence[str], on_mismatch: Literal["DROP", "ERROR", "NONE"] = "DROP") -> list[str | None]:
        """
        Given a case-insensitive sequence of feature class names, return a list
        of the corresponding correctly-cased feature class names.

        Valid values for *on_mismatch* are:

        - ``"DROP"``: Silently skip the name
        - ``"ERROR"``: Raise a ``ValueError``
        - ``"NONE"``: Append ``None`` to the results

        :param names: Case-insensitive names to match
        :type names: Sequence[str]
        :param on_mismatch: Behavior if no match is found; default ``"DROP"``
        :type on_mismatch: Literal["DROP", "ERROR", "NONE"]
        :return: List of correctly-cased feature class names (and possibly
            ``None`` if *on_mismatch* is ``"NONE"``)
        :rtype: list[str | None]
        """
        return self._match_names("FEATURE_CLASSES", names, on_mismatch=on_mismatch)

    def get_closest_field_name(self, name: str, fields: Optional[Collection[NG911Field | str]] = None) -> str | None:
        """
        Attempts to find the closest field name to *name* out of those in
        *fields*. Case and underscores are ignored during matching. If *fields*
        is ``None``, all field names are considered. If the match is acceptably
        close, the match is returned (with the correct case). Otherwise,
        returns ``None``.

        :param name: The field name to search for
        :type name: str
        :param fields: Optional list of :class:`NG911Field` objects or field
            names to consider; default None
        :type fields: Optional[Collection[NG911Field | str]]
        :return: The closest field name or ``None``
        :rtype: str | None
        """
        modified_name: str = name.replace("_", "").upper()

        possible_names: set[str]
        if fields is None:
            possible_names = {f for f in self.field_names}
        else:
            possible_names = {f.name if isinstance(f, NG911Field) else f for f in fields}
        possibility_map: dict[str, str] = {
            n.replace("_", "").upper(): n
            for n in possible_names
        }  # <Modified name>: <Actual name>

        return (
            possibility_map[results[0]]
            if (results := difflib.get_close_matches(
                modified_name,
                possibility_map.keys(),
                1,
                0.95
            ))
            else None
        )


config: _NG911Config
"""The single instance of :class:`_NG911Config`, containing data from the
configuration file ``config.yml``."""

with open(_config_path, "r") as _f:
    config = yaml.safe_load(_f)
    _logger.debug("Loaded config.yml.")


# Define aliases

RDS: Final[str] = config.gdb_info.required_dataset_name
"""Name of the :term:`required feature dataset`. Acronym for **R**\ equired
**D**\ ata\ **s**\ et. Alias of
:attr:`config.gdb_info.required_dataset_name`."""

ODS: Final[str] = config.gdb_info.optional_dataset_name
"""Name of the :term:`optional feature dataset`. Acronym for **O**\ ptional
**D**\ ata\ **s**\ et. Alias of
:attr:`config.gdb_info.optional_dataset_name`."""

cd: Final[NG911DomainNamespace] = config.domains
"""Domains provided by :term:`config.yml`. Acronym for **C**\ onfig
**D**\ omains. Alias of :attr:`config.domains`."""

cf: Final[NG911FieldNamespace] = config.fields
"""Field configurations provided by :term:`config.yml`. Acronym for
**C**\ onfig **F**\ ields. Alias of :attr:`config.fields`."""

cfc: Final[NG911FeatureClassNamespace] = config.feature_classes
"""Feature class configurations provided by :term:`config.yml`. Acronym for
**C**\ onfig **F**\ eature **C**\ lasses. Alias of
:attr:`config.feature_classes`."""


if __name__ == "__main__":
    breakpoint()