"""
:authors: Riley Baird (OK), Emma Baker (OK)
:created: August 02, 2024

Miscellaneous helper functions, classes, constants, etc., that support various
parts of the Toolkit.
"""
from __future__ import annotations

import logging
import re
from collections.abc import Collection, Sequence, Iterable, Callable
from difflib import get_close_matches
from enum import StrEnum
from functools import cache
from pathlib import Path
from typing import Optional, overload, get_args, Literal, Any, TypeAlias, Self, cast, TYPE_CHECKING, TypeVar, Protocol, Mapping, TypedDict, Required, NotRequired, Annotated, get_origin, ParamSpec

import arcpy
import attrs
import pandas as pd
# noinspection PyUnresolvedReferences
from arcgis.features import GeoAccessor, GeoSeriesAccessor
from arcpy import ArcSDESQLExecute, Extent, Field, FieldInfo, FieldMap, FieldMappings, Index, Parameter, Point, RandomNumberGenerator, RecordSet, SpatialReference
# noinspection PyProtectedMember
from pandas._libs.missing import NAType
from pandas.core.dtypes.common import is_string_dtype

# Must be imported after arcpy
from datetime import datetime, date, time

from .iterablenamespace import FrozenDict

if TYPE_CHECKING:
    from .config_dataclasses import NG911Field


_logger = logging.getLogger(__name__)

GPParameterValue: TypeAlias = ArcSDESQLExecute | Extent | Field | FieldInfo | FieldMap | FieldMappings | Index | Point | RandomNumberGenerator | RecordSet | SpatialReference | bool | float | int | str | datetime | None

# required_dataset_name = config.gdb_info.required_dataset_name
# optional_dataset_name = config.gdb_info.optional_dataset_name


class LoadDataFrame:
    def __init__(self, fc_path):
        self.fc_path = fc_path
        self.df = pd.DataFrame.spatial.from_featureclass(fc_path)

    def fix_df_fields(self, fields) -> pd.DataFrame:
        for col_field in list(self.df.columns):
            # drop fields not in fields list
            if col_field in fields or 'shape' in col_field.lower():
                # skip desired fields or shape field
                continue
            self.df.drop(col_field, axis = 1, inplace=True)
        return self.df


class Parity(StrEnum):
    ODD = "ODD"
    EVEN = "EVEN"
    BOTH = "BOTH"
    ZERO = "ZERO"

    @overload
    @classmethod
    def from_modulo_result(cls, value: int | NoneOrNA, use_na: Literal[False] = False) -> Parity | None: ...

    @overload
    @classmethod
    def from_modulo_result(cls, value: int | NoneOrNA, use_na: Literal[True]) -> Parity | NAType: ...

    @classmethod
    def from_modulo_result(cls, value: int | NoneOrNA, use_na: bool = False) -> Parity | None | NAType:
        if value == 0:
            return cls.EVEN  # type: ignore
        elif value == 1:
            return cls.ODD  # type: ignore
        elif use_na:
            return pd.NA
        else:
            return None

    @classmethod
    def from_modulo_result_series(cls, series: pd.Series) -> pd.Series:
        return series.map({1: Parity.ODD, 0: Parity.EVEN}).astype(parity_dtype)


parity_dtype = pd.CategoricalDtype(pd.Index([*Parity], dtype="string"))
"""An instance of :class:`pandas.CategoricalDtype` based on the members of
:class:`Parity`"""

NonZeroParity: TypeAlias = Literal[Parity.ODD, Parity.EVEN, Parity.BOTH]
OddEvenParity: TypeAlias = Literal[Parity.ODD, Parity.EVEN]
SingleParity: TypeAlias = Literal[Parity.ODD, Parity.EVEN, Parity.ZERO]


class NGUIDAssignMethod(StrEnum):
    """
    Methods of NGUID assignment.
    """
    NGUID = "NGUID"
    """Method in which the local ID is extracted from an existing NGUID, which
    is then used with a provided agency ID to build a complete NGUID. Requires
    populated :ng911field:`agency_id` and NGUID fields. Conversion from v2.2
    NGUIDs is supported with this method."""

    LOCAL = "LOCAL"
    """Method in which the local ID and agency ID are provided and used to
    build a complete NGUID. Requires populated :ng911field:`local_id` and
    :ng911field:`agency_id` fields."""

    SEQUENTIAL = "SEQUENTIAL"
    """Method in which sequential numbers, starting with 1, are assigned as the
    local IDs and used with a provided agency ID to build a complete NGUID.
    Requires a populated :ng911field:`agency_id` field."""

    COPY = "COPY"
    """Method that simply copies existing NGUID attributes to an NGUID field.
    Intended for field mapping operations."""

    NULL = "NULL"
    """NGUID attributes are set to null."""


def check_user_fc_for_fields(fc_path: Optional[str], parameter_field_table: list[list[str]] | list[str], error_list: list[str], std_idx: int = 0, user_idx: int = 1) -> tuple[list[str], list[list[str]]]:
    #                                                                       ^^^ correct ^^^   ^ wrong ^
    # The type hint indicated above is not accurate, but it's there because the
    # type-checker will complain based on what it expects from the ``values``
    # attribute of an ``arcpy.Parameter`` instance
    cast(list[list[str]], parameter_field_table)

    new_field_value_list: list[list[str]] = []
    field_filter_list: list[str] = [error_list[0]]

    if fc_path:
        try:
            field_filter_list = [field.name for field in arcpy.Describe(fc_path).fields]
        except:
            field_filter_list = [error_list[1]]

    value_list: list[str]
    for value_list in parameter_field_table:
        append_list: list[str] = value_list
        std_field_name = value_list[std_idx]

        if len(field_filter_list) == 1 and field_filter_list[0] in error_list[:2]:
            user_field = field_filter_list[0]
        else:
            if error_list[2] not in field_filter_list:
                field_filter_list = [error_list[2]] + field_filter_list
            try:
                field_to_map_list = [field_name for field_name in field_filter_list if field_name.upper() == std_field_name.upper()]
                if len(field_to_map_list) == 1:
                    user_field = field_to_map_list[0]
                else:
                    user_field = error_list[2]
            except:
                user_field = error_list[2]

        append_list[std_idx] = std_field_name
        append_list[user_idx] = user_field
        new_field_value_list.append(append_list)

    return field_filter_list, new_field_value_list


def get_field_info_for_fc_creation(fc_fields) -> (list[list[Optional[str | int]]]):
    field_description = []
    for fc_field in fc_fields:
        # field = config.get_field_by_name(fc_field)
        field_name = fc_field.name
        field_type = fc_field.type
        field_length = fc_field.length
        field_domain = fc_field.domain.name if fc_field.domain else None
        default_value = None
        current_field_list = [field_name, field_type, field_name, field_length, default_value, field_domain]
        field_description.append(current_field_list)
    return field_description


class MixedZeroParityError(Exception):
    """
    Exception for use in the following situations involving inconsistent
    parity:

    * Given an address range, one bound is zero, but the other is non-zero.
    * Given a parity and an address range, the parity is zero, but one or both
      range bounds are non-zero.
    * Given a parity and an address range, the parity is not zero, but one or
      both range bounds are zero.
    """
    pass


@overload
def calculate_parity(data: int) -> SingleParity: ...

@overload
def calculate_parity(data: Sequence[int], summarize: Literal[False]) -> list[SingleParity]: ...

@overload
def calculate_parity(data: Collection[int], summarize: Literal[True]) -> Parity: ...

def calculate_parity(data, summarize=False):
    if isinstance(data, Collection):
        if not all(isinstance(item, int) for item in data):
            raise TypeError("A collection passed to calculate_parity() must only contain instances of int.")
        if set(data) == {0}:
            return Parity.ZERO if summarize else [Parity.ZERO] * len(data)

        parities = [calculate_parity(number) for number in data]

        if not summarize:
            return parities
        else:
            parities = set(parities)

        # At this point, summarize must be True
        if parities == {Parity.ODD}:
            return Parity.ODD
        elif parities == {Parity.EVEN}:
            return Parity.EVEN
        elif parities == {Parity.ODD, Parity.EVEN}:
            return Parity.BOTH
        elif parities == {Parity.ZERO}:
            return Parity.ZERO
        elif Parity.ZERO in parities:
            raise MixedZeroParityError("Mixed zero and non-zero values passed to calculate_parity().")
        else:
            raise RuntimeError(f"Could not determine parity of {data}.")

    elif data == 0:
        return Parity.ZERO
    elif data & 1 == 1:
        return Parity.ODD
    elif data & 1 == 0:
        return Parity.EVEN
    else:
        raise RuntimeError(f"Could not determine parity of {data}.")

@overload
def unwrap(target: Parameter) -> GPParameterValue | list[GPParameterValue]: ...

@overload
def unwrap(target: Sequence[Parameter]) -> list[GPParameterValue | list[GPParameterValue]]: ...

def unwrap(target):
    """
    Extracts the value(s) from ``arcpy.Parameter`` object(s). This function can
    be called with either a single ``arcpy.Parameter`` or a sequence thereof as
    the argument for *target*.

    If *target* is a single ``arcpy.Parameter``, a single value

    :param target: Parameter object or Sequence thereof
    :type target: Union[Parameter, Sequence[Parameter]]
    :return: List of usable objects representing the inputs
    :rtype: Union[GPParameterValue, list[GPParameterValue]]
    """
    try:
        if isinstance(target, GPParameterValue):
            # If target is an unwrapped type, return it
            return target
        elif type(target).__qualname__ == "geoprocessing parameter object":
            from arcpy.arcobjects.arcobjectconversion import convertArcObjectToPythonObject
            return unwrap(convertArcObjectToPythonObject(target))
        elif hasattr(target, "__class__") and isinstance(target, Sequence) and not isinstance(target, str) and all(map(lambda x: isinstance(x, Parameter) or type(x).__qualname__ == "geoprocessing parameter object", target)):
            # Filter out Parameter objects becuase checking if a Parameter is an instance of Sequence results in an error
            # If target is a Sequence of Parameter objects, run this function on target's contents.
            # Strings are Sequences; filter them out
            return [*map(unwrap, target)]
        elif isinstance(target, Parameter) or hasattr(target, "isEmpty"):
            # If target appears to be a Parameter object or a Value object, search
            # for attributes 'values' and 'value'. If neither exist, fall back to
            # simply returning target.
            if hasattr(target, "values"):
                return unwrap(target.values)
            elif hasattr(target, "value"):
                return unwrap(target.value)
            else:
                arcpy.AddWarning(f"Encountered error extracting value from a parameter. Converting to string. Value: '{str(target)}'")
                return str(target)
        else:
            return target
    except Exception as ex:
        raise ex

def unwrap_to_dict(targets: Sequence[arcpy.Parameter]) -> dict[str, GPParameterValue | list[GPParameterValue]]:
    return {p.name: unwrap(p) for p in targets}

def params_to_dict(targets: Sequence[arcpy.Parameter]) -> dict[str, arcpy.Parameter]:
    return {p.name: p for p in targets}

def na_eq(val1: Any, val2: Any):
    """
    Compares any two values, testing for equality. Unline the ``==`` operator,
    however, this function will return ``True`` if both *val1* and *val2* are
    considered ``NA`` by ``pandas``, such as ``pandas.NA`` and ``numpy.nan``.

    :param val1: Any object
    :type val1: Any
    :param val2: Any object
    :type val2: Any
    :return: Whether *val1* and *val2* are equal or both ``NA``
    :rtype: bool
    """
    if pd.isna(val1) and pd.isna(val2):
        return True
    elif pd.isna(val1) or pd.isna(val2):
        return False
    else:
        return val1 == val2

def na_in_list(val1: Any, in1: Any):
    # if pd.isna(val1) and pd.isna(in1):
    #     return True
    if pd.isna(val1) or any(pd.isna(in1)):
        return False
    else:
        return val1 in in1


def na_in_set(in1: Any, in2: Any):
    if pd.isna(in1) and pd.isna(in2):
        return True
    elif pd.isna(in1) or pd.isna(in2):
        return False
    else:
        return in1 & in2

_T_Field = TypeVar("_T_Field", str, "NG911Field")


class _AttrsInstanceWithAllowedFields(Protocol):
    __attrs_attrs__: tuple
    __ng911_allowed_fields__: Sequence["NG911Field | str"]


class _AttrsInstanceWithFeatureClassPath(Protocol):
    __attrs_attrs__: tuple
    __ng911_feature_class_path__: Path


def validate_field_exists(instance: _AttrsInstanceWithAllowedFields | _AttrsInstanceWithFeatureClassPath, attribute: "attrs.Attribute[_T_Field]", value: _T_Field) -> None:
    """
    A function compatible with the ``validator`` argument of ``attrs.Field`` in
    cases where a field name or :class:`~NG911Field` instance is passed as the
    value of the ``attrs.Field``. A ``ValueError`` is raised if *value* is not
    among the allowed/existing fields.

    :param instance: An ``attrs`` object with either an
        ``__ng911_allowed_fields__`` attribute or an
        ``__ng911_feature_class_path__`` attribute
    :param attribute: The ``attrs.Attribute`` object
    :param value: Value of the attribute
    """
    from .config_dataclasses import NG911Field
    if not attrs.has(instance.__class__):
        raise TypeError(f"'instance' is not an instance of an attrs-decorated class.")
    if hasattr(instance, "__ng911_allowed_fields__"):
        allowed_values: set[str] = {f.name if isinstance(f, NG911Field) else f for f in instance.__ng911_allowed_fields__}
    elif hasattr(instance, "__ng911_feature_class_path__"):
        allowed_values: set[str] = {f.name for f in arcpy.ListFields(instance.__ng911_feature_class_path__)}
    else:
        raise AttributeError("'instance' must have an attribute called either '__ng911_allowed_fields__' or '__ng911_feature_class_path__'.")
    if value not in allowed_values:
        raise ValueError(f"Field '{value}' does not exist in feature class or is not allowed to be used here.")

FeatureAttributeValue: TypeAlias = str | int | float | datetime | date | time | arcpy.Geometry | None
RowTuple: TypeAlias = tuple[FeatureAttributeValue, ...]
ArcFieldTypeStr: TypeAlias = Literal["Geometry", "SmallInteger", "Integer", "BigInteger", "Single", "Double", "String", "Date", "DateOnly", "TimeOnly", "TimestampOffset", "Guid", "Raster"]
ArcFieldTypeKeyword: TypeAlias = Literal["SHORT", "LONG", "BIGINTEGER", "FLOAT", "DOUBLE", "TEXT", "DATE", "DATEHIGHPRECISION", "DATEONLY", "TIMEONLY", "TIMESTAMPOFFSET", "BLOB", "GUID", "RASTER"]

FIELD_DATA_TYPES: FrozenDict[ArcFieldTypeKeyword, ArcFieldTypeStr] = FrozenDict({
    "SHORT": "SmallInteger",
    "LONG": "Integer",
    "BIGINTEGER": "BigInteger",
    "FLOAT": "Single",
    "DOUBLE": "Double",
    "TEXT": "String",
    "DATE": "Date",
    "DATEHIGHPRECISION": "Date",
    "DATEONLY": "DateOnly",
    "TIMEONLY": "TimeOnly",
    "TIMESTAMPOFFSET": "TimestampOffset",
    "BLOB": "Blob",
    "GUID": "Guid",
    "RASTER": "Raster"
})
"""Mapping of field data type keywords where keys are those used in field creation and values are the corresponding keywords found in ``arcpy.Field.type`` (and ``config.yml``)."""

FIELD_TYPE_KEYWORDS: FrozenDict[ArcFieldTypeStr, ArcFieldTypeKeyword] = FrozenDict({
    "SmallInteger": "SHORT",
    "Integer": "LONG",
    "BigInteger": "BIGINTEGER",
    "Single": "FLOAT",
    "Double": "DOUBLE",
    "String": "TEXT",
    # "Date": "DATE",
    "Date": "DATEHIGHPRECISION",
    "DateOnly": "DATEONLY",
    "TimeOnly": "TIMEONLY",
    "TimestampOffset": "TIMESTAMPOFFSET",
    "Blob": "BLOB",
    "Guid": "GUID",
    "Raster": "RASTER"
})
"""Mapping of field data type keywords where keys are those found in ``arcpy.Field.type`` (and ``config.yml``) and values are the corresponding keywords used in field creation. In this mapping, ``Date`` is mapped to ``DATEHIGHPRECISION``, not ``DATE``."""

DOMAIN_TYPES: FrozenDict[str, str] = FrozenDict({
    "CODED": "CodedValue",
    "RANGE": "Range"
})
"""Mapping of domain type keywords where keys are those used in domain creation and values are the corresponding keywords found in ``arcpy.da.Domain.domainType``."""

ValidationCategory: TypeAlias = Literal["Geodatabase", "General Feature Class", "Address Point", "Road Centerline"]
"""The categories under which validation routines are grouped."""

Severity: TypeAlias = Literal["Notice", "Warning", "Error"]
"""The three severity levels of :class:`ValidationErrorMessage` instances."""

GDBErrorCode: TypeAlias = Literal[  # SEVERITY:CATEGORY:DESCRIPTION
    "ERROR:PYTHON:EXCEPTION",  # For use when a routine encounters an error
    "ERROR:GDB:MISSING_REQUIRED_DATASET",
    "ERROR:GDB:MISSING_REQUIRED_FEATURE_CLASS",
    "ERROR:GDB:EXTRA_ITEM",
    "ERROR:GDB:MISSING_DOMAIN",
    "ERROR:GDB:EXTRA_DOMAIN",
    "ERROR:GDB:INCORRECT_DOMAIN_TYPE",
    "ERROR:GDB:DOMAIN_MISSING_CODE",
    "ERROR:GDB:DOMAIN_EXTRA_CODE",
    "ERROR:GDB:DOMAIN_CODE_VALUE_MISMATCH",
    "ERROR:GDB:INCORRECT_DOMAIN_DESCRIPTION",
    "ERROR:DATASET:INCORRECT_SPATIAL_REFERENCE",
    "ERROR:DATASET:MISSING_TOPOLOGY",
    "ERROR:DATASET:INCORRECT_TOPOLOGY",
    "ERROR:DATASET:TOPOLOGY_VIOLATION",
    "ERROR:FEATURE_CLASS:MISSING_REQUIRED_FIELD",
    "ERROR:FEATURE_CLASS:EXTRA_FIELD",
    "ERROR:FEATURE_CLASS:INCORRECT_SPATIAL_REFERENCE",
    "ERROR:FEATURE_CLASS:INCORRECT_GEOMETRY_TYPE",
    "ERROR:FEATURE_CLASS:INCORRECT_FEATURE_TYPE",
    "ERROR:FEATURE_CLASS:EMPTY",
    "ERROR:FEATURE_CLASS:EMPTY_SUBMISSION",
    "ERROR:FIELD:INCORRECT_FIELD_TYPE",
    "ERROR:FIELD:INCORRECT_FIELD_LENGTH",
    "ERROR:FIELD:INCORRECT_FIELD_DOMAIN",
    "NOTICE:GDB:MISSING_OPTIONAL_DATASET",
    "NOTICE:GDB:MISSING_OPTIONAL_FEATURE_CLASS"
]
"""The specific error codes for use with :class:`GDBErrorMessage`. These values should be succinct indicators of the type of error that a Toolkit user could look up in an error glossary for more information than can reasonably be provided in the error table."""

FeatureAttributeErrorCode: TypeAlias = Literal[  # SEVERITY:CHECK:DESCRIPTION
    "ERROR:DOMAIN:INVALID_VALUE",
    "ERROR:GENERAL:INVALID_VALUE",
    "ERROR:GENERAL:MANDATORY_IS_NULL",
    "ERROR:GENERAL:MANDATORY_IS_BLANK",
    "ERROR:GENERAL:NOT_UPPERCASE",
    "ERROR:GENERAL:UNIQUENESS",
    "WARNING:GENERAL:LEADING_TRAILING_SPACE",
    "ERROR:NGUID:FORMAT",
    "ERROR:NGUID:V2_FORMAT",  # For use with NGUIDFormatError
    "ERROR:NGUID:AGENCY",
    "ERROR:NGUID:LAYER",
    "ERROR:NGUID:DUPLICATE",
    "ERROR:ADDRESS:DUPLICATE",
    "ERROR:ADDRESS_RANGE:OVERLAP",
    "ERROR:ADDRESS_RANGE:DECREASING",
    "ERROR:ROAD_ESN:DEVIATION",
    "ERROR:ROAD_ESN:CROSSING",
    "ERROR:ROAD_ESN:OUT_OF_BOUNDS",
    "ERROR:PARITY:EXPECTED_ZERO",
    "ERROR:PARITY:EXPECTED_NONZERO",
    "ERROR:PARITY:MISMATCH",
    "ERROR:PARITY:INVALID",
    "ERROR:PARITY:NULL",
    "ERROR:LEGACY:MISMATCH",
    "ERROR:GEOCODE:UNKNOWN_MATCH",
    "ERROR:GEOCODE:WRONG_SIDE",
    "ERROR:GEOCODE:BOTH_SIDES",
    "ERROR:GEOCODE:WRONG_COMMUNITY",
    "ERROR:GEOCODE:OUT_OF_RANGE",
    "ERROR:GEOCODE:NAME_MISMATCH",
    "WARNING:GEOCODE:NO_MATCH",
    "ERROR:CONSISTENCY:ADDRESS_ESN",
    "ERROR:CONSISTENCY:ROAD_ESN",
    "ERROR:CONSISTENCY:COMMUNITY",
    "ERROR:MSAG:NO_ROAD_MATCH",
    "WARNING:CONSISTENCY:ROAD_LEVEL",
    "ERROR:GEOMETRY:TOPOLOGY",
    "WARNING:GEOMETRY:CUTBACK",
    "WARNING:GEOMETRY:SHORT_SEGMENT",
    "NOTICE:CONSISTENCY:ROAD_ESN",
]
"""The specific error codes for use with :class:`FeatureAttributeErrorMessage`. These values should be succinct indicators of the type of error that a Toolkit user could look up in an error glossary for more information than can reasonably be provided in the error table."""


def all_values_selected(parameter: arcpy.Parameter) -> bool:
    """Returns whether a multivalue ``arcpy.Parameter`` has all values in its
    filter list selected."""
    if not hasattr(parameter, "values"):
        raise AttributeError("Provided Parameter does not have attribute 'values'.")
    if not parameter.filter.list:
        raise ValueError("Provided Parameter does not have any values in filter list.")
    values = parameter.values or []  # In case parameter.values is None
    return set(values) == set(parameter.filter.list)

def arc_field_details(field: arcpy.Field) -> str:
    attributes = ("name", "type", "length", "domain")
    attribute_detail = ", ".join(
        f"{a}='{v}'" if isinstance(v := getattr(field, a), str)
        else f"{a}={v}"
        for a in attributes
    )
    if isinstance(field, arcpy.Field):
        return f"<arcpy.Field: {attribute_detail}>"
    else:
        _logger.warning("An object was passed to arc_field_details() that is not an instance of arcpy.Field.", stack_info=True)
        return f"<{str(type(field))}: {attribute_detail}>"

@cache
def get_project() -> arcpy.mp.ArcGISProject | None:
    """If this function is being called from within an ArcGIS Pro session, an
    ``ArcGISProject`` instance representing the currently-open project is
    returned. If this function is not being called from within an ArcGIS Pro
    session, ``None`` is returned. The result is cached."""
    try:
        p = arcpy.mp.ArcGISProject("CURRENT")
        _logger.debug(f"ArcGISProject instance obtained.")
        return p
    except:
        _logger.debug(f"No ArcGISProject instance obtained.")
        return None


class AttributeConversionError(ValueError):
    def __init__(self, original_value: FeatureAttributeValue, target_field: "NG911Field", *args):
        self.original_value: FeatureAttributeValue = original_value
        self.target_field: "NG911Field" = target_field
        super().__init__(*args)

_T_FAV = TypeVar("_T_FAV", bound=FeatureAttributeValue, covariant=True)
def convert_attribute_value(value: _T_FAV, target_field: "NG911Field") -> FeatureAttributeValue:
    """
    Given an attribute value from a feature, attempt to convert it to the
    appropriate type indicated by *target_field*.

    :param value: Attribute value to convert
    :param target_field: Field that *value* should be valid for
    :return: Equivalent value of appropriate type
    :raises AttributeConversionError: If *value* is not valid for *target_field*
    """
    fail = lambda message: AttributeConversionError(value, target_field, message)
    original_value: _T_FAV = value
    error: AttributeConversionError | None = None
    match target_field.type:
        # Process the input value as appropriate for the data type of the standard field
        case "SmallInteger":
            value = int(value)
            if not (-2 ** 15 <= value < 2 ** 15):
                error = fail(f"Numeric value {value} out of range for field type '{target_field.type}'.")
        case "Integer":
            value = int(value)
            if not (-2 ** 31 <= value < 2 ** 31):
                error = fail(f"Numeric value {value} out of range for field type '{target_field.type}'.")
        case "BigInteger":
            value = int(value)
            if not (-2 ** 53 <= value <= 2 ** 53):  # Both <= comparisons and exponents of 53 are intentional
                raise fail(f"Numeric value {value} out of range for field type '{target_field.type}'.")
        case "Single" | "Double":
            value = float(value)
        case "String":
            value = str(value)
            if len(value) > target_field.length:
                error =  fail(f"Value '{value}' too long for field {target_field.name}.")
        case "Date" | "TimestampOffset":
            if not isinstance(value, datetime):
                error =  fail(f"Value '{value}' is not a datetime object.")
        case "DateOnly":
            if isinstance(value, date):
                pass
            elif isinstance(value, datetime):
                value = value.date()
            else:
                error =  fail(f"Could not convert value '{value}' to a date.")
        case "TimeOnly":
            if not isinstance(value, time):
                error =  fail(f"Value '{value}' is not a time object.")
    if error:
        error.add_note(f"Destination Field: {target_field.name}")
        error.add_note(f"Original Value: {original_value}")
        error.add_note(f"Original Type: {type(original_value)}")
        raise error
    return value

def strjoin(strings: Iterable[str], joiner: str, *, q=False, qq=False) -> str:
    if {" ", "n", "t", ",", ";"} - set(joiner):
        raise ValueError("Only spaces, commas, semicolons, and the letters 'n' and 't' are allowed in joiner.")
    if q and qq:
        raise ValueError("Cannot combine 'q' and 'qq'.")
    elif q:
        strings = quote(strings)
    elif qq:
        strings = qquote(strings)
    joiner = joiner.replace("n", "\n").replace("t", "\t")
    return joiner.join(strings)

def wrap_string(string: str, *wrappers: str) -> str:
    """Surrounds *string* with *wrappers*. If one argument is provided for
    *wrappers*, it will be placed both before and after *string*. If two
    arguments are provided, the first will be prepended and the second will be
    appended to *string*."""
    if len(wrappers) == 1:
        left = wrappers[0]
        right = wrappers[0]
    elif len(wrappers) == 2:
        left, right = wrappers
    else:
        raise ValueError(f"Either 1 or 2 arguments should be provided for 'wrappers'; got {len(wrappers)}.")
    return f"{left}{string}{right}"

def wrap_strings(strings: Iterable[str], *wrappers: str) -> list[str]:
    """Surrounds each member of *strings* with *wrappers*. If one argument is
    provided for *wrappers*, it will be placed both before and after each
    member of *strings*. If two arguments are provided, the first will be
    prepended and the second will be appended to each member of *strings*."""
    return [wrap_string(string, *wrappers) for string in strings]

def quote(strings: Iterable[str]) -> list[str]:
    """Surrounds each string in *strings* with single quotes."""
    return [f"'{x}'" for x in strings]

def qquote(strings: Iterable[str]) -> list[str]:
    """Surrounds each string in *strings* with double quotes."""
    return [f'"{x}"' for x in strings]

_T_indent = TypeVar("_T_indent", str, Iterable[str])

def indent(lines: str, prefix: Literal["\t"] | int = "\t") -> str:
    """Given a single multiline string, returns it with each line prefixed with
    a tab character. Given an iterable of strings, returns a list of the input
    strings, each prefixed with a tab character."""
    pre_str: str
    if isinstance(prefix, int):
        pre_str = " " * prefix
    elif prefix == "\t":
        pre_str = "\t"
    else:
        raise ValueError(rf"Argument for 'prefix' must be either '\t' or an integer indicating the number of spaces; got '{prefix}'.")

    line_list: list[str] = lines.splitlines()
    return "\n".join(line_list)

def indent_each(lines: Sequence[str], prefix: Literal["\t"] | int = "\t") -> list[str]:
    if isinstance(lines, str):
        raise TypeError("While an instance of str is technically also an instance of Sequence[str], this function is not intended for single strings. Use indent() instead.")
    return [indent(line, prefix) for line in lines]

# def indent(lines: str | Sequence[str], prefix: Literal["\t"] | int = "\t") -> str | list[str]:
#     """Given a single multiline string, returns it with each line prefixed with
#     a tab character. Given an iterable of strings, returns a list of the input
#     strings, each prefixed with a tab character."""
#     pre_str: str
#     if isinstance(prefix, int):
#         pre_str = " " * prefix
#     elif prefix == "\t":
#         pre_str = "\t"
#     else:
#         raise ValueError(rf"Argument for 'prefix' must be either '\t' or an integer indicating the number of spaces; got '{prefix}'.")
#
#     if isinstance(lines, str):
#         # Input was a single multiline string
#         line_list: list[str] = lines.splitlines()
#         return "\n".join(line_list)
#     else:
#         # Input was an iterable of strings
#         return [f"{pre_str}{line}" for line in lines]

def get_closest_match_insensitive(word: str, possibilities: Sequence[str], cutoff: float = 0.6) -> str | None:
    """Returns the string from *possibilities* most similar to *word*, or
    ``None`` if ``difflib.get_close_matches`` doesn't return any results given
    *cutoff*. Unlike ``difflib.get_close_matches`` alone, however, the function
    is case-insensitive."""
    word_upper: str = word.upper()
    possibilities_upper: dict[str, str] = {x.upper(): x for x in possibilities}
    result: str | None = matches[0] if (matches := get_close_matches(word_upper, possibilities_upper, 1, cutoff)) else None
    return possibilities_upper[result] if result else None


# _T_Protocol_co = TypeVar("_T_Protocol_co", bound=type[Protocol], covariant=True)
# def confirm_attributes(typ: type[_T_Protocol_co]) -> type[_T_Protocol_co]:
#     """Decorator that ensures instances of *typ* have the attributes
#     specified in any ``Protocol``\ s in the type's ``__bases__``."""
#     attributes = [a for t in typ.__bases__ if getattr(t, "_is_protocol", False) for a in t.__annotations__.keys()]
#     __original_init__ = typ.__init__
#     @wraps(typ.__init__)
#     def __init__(self, /, *args, **kwargs):
#         self.__original_init__(*args, **kwargs)
#         if x := [y for y in [1, 2, 3] if y > 3]:
#             print(x)
#         if missing_attributes := [x for x in attributes if not hasattr(self, x)]:
#             print(missing_attributes)
#             raise AttributeError(f"Instance is missing attributes: {', '.join(missing_attributes)}.")
#     # setattr(typ, "__original_init__", __original_init__)
#     typ.__init__ = __init__
#     return typ

def is_typed_dict(obj: Mapping, typ: type[TypedDict]):
    """
    Given a ``Mapping`` instance and a ``TypedDict`` subclass, return whether
    *obj* is an instance of *typ*.

    .. warning::

        This function will raise a ``TypeError`` if *typ* declares a dict key
        whose type is a parameterized generic, e.g., ``list[str]``. This
        limitation does ***not*** apply to ``typing.Required``,
        ``typing.NotRequired``, or ``typing.Annotated``, but it *does* apply to
        their parameters, e.g., ``Required[int]`` is valid, but
        ``Required[list[int]]`` is not.

    :param obj: A ``Mapping`` instance whose items should be checked against
        a ``TypedDict`` definition
    :type obj: ``Mapping``
    :param typ: Any ``TypedDict`` subclass
    :type typ: type[TypedDict]
    :return: Whether the items in *obj* conform to *typ*
    """
    allowed: frozenset[str] = frozenset(typ.__required_keys__) | frozenset(typ.__optional_keys__)
    present: frozenset[str] = frozenset(obj.keys())
    if present - allowed:
        return False
    for key in allowed:
        required: bool = key in typ.__required_keys__
        if key not in obj:
            if required:
                return False
            else:
                continue
        key_type: type = typ.__annotations__[key]
        # noinspection PyTypedDict
        if get_origin(key_type) in {Annotated, NotRequired, Required}:
            key_type = get_args(key_type)[0]
        if not isinstance(obj[key], key_type):
            return False
    return True

def ensure_no_blank_strings(df: pd.DataFrame, error_message_start: Optional[str] = None) -> None:
    """Raises a ``ValueError`` if any values in *df* are blank strings
    or consist only of whitespace that indicates that they should instead be
    null. All columns in *df* **must** have the :class:`pandas.Series.str`
    accessor available."""
    if not df.apply(is_string_dtype).all():
        raise ValueError("Not all columns in data frame are string columns.")
    blank_counts: pd.Series = df.apply(lambda col: col.str.strip()).eq("").sum()
    if blank_counts.any():
        error_message_start = "" if not error_message_start else error_message_start if error_message_start.endswith((" ", "\n", "\t")) else error_message_start + " "
        column_reports = [f"{col} has {count} blank or whitespace-only value(s)." for col, count in blank_counts[blank_counts.gt(0)].items()]
        error_message = f"{error_message_start}{' '.join(column_reports)} These should be set to null instead."
        raise ValueError(error_message)

_P = ParamSpec("_P")
_R = TypeVar("_R")
def if_licensed(tool: Callable[_P, _R]) -> Callable[_P, tuple[bool, _R | None]]:
    """
    Wraps an ``arcpy`` geoprocessing function in a try/except block that,
    suppresses ERROR 000824 (indicating an unlicensed tool) and returns
    ``None`` in place of the tool's expected return value.

    The callable returned by this function **returns two values**. The first, a
    ``bool``, will be ``True`` unless ERROR 000824 is encountered; ``False``
    indicates that the tool is not licensed. The second is the result of
    wrapped geoprocessing function (or ``None`` if the tool is not licensed).
    """
    def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R | None:
        try:
            return tool(*args, **kwargs)
        except arcpy.ExecuteError as exc:
            if re.search(r"\bERROR 0{0,3}824\b", str(exc)):
                print("Not licensed.")
                return None
            else:
                raise exc
    return wrapper


def cursor_apply(table: Path | str, fields: Sequence[str], function: Callable[_P, _R], **cursor_kwargs) -> list[_R]:
    """
    Applies *function* row-wise to records in a table or feature class using
    ``arcpy.da.SearchCursor`` and returns the results as a list.

    Note that each record is unpacked when *function* is called, so *function*
    must take one argument for each field given in *fields*.

    :param table: Table or feature class to load
    :param fields: Fields whose values will be passed to *function*
    :param function: Function to apply to each row
    :param cursor_kwargs: Additional arguments to pass to
        ``arcpy.da.SearchCursor``
    :return: List of outputs from calling *function* on each row
    """
    with arcpy.da.SearchCursor(str(table), fields, **cursor_kwargs) as sc:
        return [function(*row) for row in sc]

def series_from_cursor(table: Path | str, index_field: str, value_field: str, name: Optional[str] = None, **cursor_kwargs) -> pd.Series:
    """Returns a :class:`pandas.Series`, where the index comes from
    *index_field* and the value from *value_field*."""
    with arcpy.da.SearchCursor(str(table), [index_field, value_field], **cursor_kwargs) as sc:
        return pd.Series({k: v for k, v in sc}, name=(name or value_field))


NoneOrNA: TypeAlias = None | NAType


def na_to_none(val):
    """Returns ``None`` if *val* is any value that ``pandas`` considers to be
    NA/null, otherwise returns *val*. This function can be used as an ``attrs``
    converter."""
    return None if pd.isna(val) else val
