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

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

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, partial
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

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

from pandas.core.dtypes.common import is_string_dtype

from .iterablenamespace import FrozenDict

if TYPE_CHECKING:
    from .config_dataclasses import NG911Field


_logger = logging.getLogger(__name__)

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

# 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 | None | NAType, use_na: Literal[False] = False) -> Self | None: ...

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

    @classmethod
    def from_modulo_result(cls, value: int | None | NAType, use_na: bool = False) -> Self | None | NAType:
        if value == 0:
            return cls.EVEN
        elif value == 1:
            return cls.ODD
        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"))

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).

    :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, get_args(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",
    "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: _T_indent, prefix: Literal["\t"] | int = "\t") -> _T_indent:
    """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 single_string := isinstance(lines, str):
        lines: list[str] = lines.splitlines()
    lines = [f"{pre_str}{line}" for line in lines]
    return "\n".join(lines) if single_string else 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 ``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