from __future__ import annotations

import datetime
import logging
import os
from collections.abc import Callable, Mapping
from weakref import proxy, ProxyType
from abc import abstractmethod, ABC
# noinspection PyProtectedMember
from os import PathLike
from pathlib import Path
from tempfile import TemporaryDirectory
from traceback import format_exception
from typing import TypedDict, runtime_checkable, Protocol, Literal, Optional, Sequence, Self, Final, ClassVar, overload, TypeVar
from zipfile import ZipFile

import arcpy
import attrs
import pandas as pd
# noinspection PyUnresolvedReferences
from arcgis.features import GeoSeriesAccessor, GeoAccessor
from osgeo import ogr

from .accessor import NGUID
from .config_dataclasses import NG911FeatureClass, NG911Field
from .datachanges import StandardGeodatabaseCreation
from .iterablenamespace import FrozenList
from .misc import NGUIDAssignMethod as NAM, _AttrsInstanceWithAllowedFields, validate_field_exists, arc_field_details, indent, indent_each
from .session import config, cf, cfc
from .topology import topology_config, TopologyRule


__all__ = ["NG911Session", "NG911SessionWithProduct", "NGUIDBuilder", "NGUIDFromNGUIDBuilder", "NGUIDFromLocalIDBuilder", "SequentialNGUIDBuilder", "_NG911DataFrameAttrs", "GPMessenger", "SessionMessenger", "FallbackGPMessenger", "DummyEditor"]


_logger = logging.getLogger(__name__)

_rds: Final[str] = config.gdb_info.required_dataset_name  # "[r]equired [d]ata[s]et"
_rdst: Final[str] = topology_config.required_dataset_topology_name  # "[r]equired [d]ata[s]et [t]opology"
_ods: Final[str] = config.gdb_info.optional_dataset_name  # "[o]ptional [d]ata[s]et"
_odst: Final[str] = topology_config.optional_dataset_topology_name  # "[o]ptional [d]ata[s]et [t]opology"


class _NG911DataFrameAttrs(TypedDict):
    """
    Represents the keys of the ``attrs`` attribute of ``DataFrame``\ s
    returned by :meth:`NG911Session.load_df`.
    """
    path: Path
    feature_class: NG911FeatureClass
    fields: tuple[NG911Field, ...]
    nguid_name: str
    oid_name: str | None
    shape_name: str
    geodatabase_path: Path


class DummyEditor:
    """Class to be instantiated as the value of :attr:`NG911Session.editor` for
    instances of that class not using an edit session. This will prevent method
    calls from failing as opposed to simply setting that attribute to
    ``None``."""

    def __init__(self, session: Optional[NG911Session] = None):
        self._session: ProxyType[NG911Session] | None
        if session:
            self._session = proxy(session)
        else:
            self._session = None

    def __enter__(self):
        pass

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type:
            raise exc_val

    @property
    def isEditing(self):
        return False

    @property
    def workspacePath(self):
        return self._session.gdb

    def abortOperation(self):
        pass

    def redoOperation(self):
        pass

    def startEditing(self, with_undo=True, multiuser_mode=True):
        pass

    def startOperation(self):
        pass

    def stopEditing(self, save_changes=True):
        pass

    def stopOperation(self):
        pass

    def undoOperation(self):
        pass



@runtime_checkable
class GPMessenger(Protocol):
    """Protocol declaring the methods of the ``messages`` parameter of
    geoprocessing tool class ``execute`` methods."""

    @abstractmethod
    def addMessage(self, message: str) -> None: ...

    @abstractmethod
    def addWarningMessage(self, message: str) -> None: ...

    @abstractmethod
    def addErrorMessage(self, message: str) -> None: ...

    @abstractmethod
    def addIDMessage(self, message_type: Literal["ERROR", "INFORMATIVE", "WARNING"], message_ID: int, add_argument1: Optional[str | int | float] = None, add_argument2: Optional[str | int | float] = None) -> None: ...

    @abstractmethod
    def addGPMessages(self) -> None: ...


_T_Exc = TypeVar("_T_Exc", bound=BaseException)


class SessionMessenger:
    def __init__(self, tool_messenger: Optional[GPMessenger] = None):
        self._messenger: GPMessenger = tool_messenger or FallbackGPMessenger()

    def addMessage(self, message: str, *, level: int = logging.INFO, exc: Optional[BaseException] = None) -> None:
        _logger.log(level, message, exc_info=exc, stacklevel=2)
        self._messenger.addMessage(message)

    def addWarningMessage(self, message: str, *, level: int = logging.WARNING, exc: Optional[BaseException] = None) -> None:
        _logger.log(level, message, exc_info=exc, stacklevel=2)
        self._messenger.addWarningMessage(message)

    @overload
    def addErrorMessage(self, message: str, *, level: int = logging.ERROR) -> None: ...

    @overload
    def addErrorMessage(self, message: str, *, level: int = logging.ERROR, exc: _T_Exc) -> _T_Exc: ...

    def addErrorMessage(self, message: str, *, level: int = logging.ERROR, exc: Optional[_T_Exc] = None) -> _T_Exc | None:
        _logger.log(level, message, exc_info=exc, stacklevel=2)
        self._messenger.addErrorMessage(message)
        return exc

    def addIDMessage(self, message_type: Literal["ERROR", "INFORMATIVE", "WARNING"], message_ID: int, add_argument1: Optional[str | int | float] = None, add_argument2: Optional[str | int | float] = None) -> None:
        return self._messenger.addIDMessage(message_type, message_ID, add_argument1, add_argument2)

    def addGPMessages(self) -> None:
        return self._messenger.addGPMessages()


class NG911Session(arcpy.EnvManager, os.PathLike[str]):
    _gdb: Path
    """Path to the NG911 geodatabase."""

    _is_active: bool
    """Whether the environment settings (including workspace) are in effect."""

    _respect_submit: bool
    """Whether to use ``SUBMIT='Y'`` in where clauses by default."""

    _use_edit_session: bool
    """Whether to utilize an ``arcpy.da.Editor`` instance."""

    _init_timestamp: datetime.datetime
    """Set to the current time at initialization."""

    _enter_timestamp: datetime.datetime | None
    """Set to ``None`` at initialization, to the current time when :meth:`__enter__` is called, and back to ``None`` when :meth:`__exit__` is called. Used in initilizing :class:`~.validator.ValidationErrorMessage`\ s."""

    _temporary_items: list[str]
    """Keeps track of temporary feature classes and layers to delete upon :meth:`__exit__` or :meth:`__del__` being called."""

    messenger: SessionMessenger
    """The geoprocessing messenger object typically passed to the ``execute``
    method of a geoprocessing tool (or an instance of
    :class:`FallbackGPMessenger`)."""

    editor: arcpy.da.Editor | DummyEditor
    """An instance of ``arcpy.da.Editor`` for managing edits when
    :attr:`_use_edit_session` is ``True``. Otherwise, a :class:`DummyEditor`
    instance is created, and calling methods on it will have no effect."""

    def __init__(self, workspace: PathLike[str] | str, respect_submit: bool, use_edit_session: bool = False, messenger: Optional[GPMessenger] = None, **env_kwargs):
        """
        Initializes an ``NG911Session`` instance. This class is a context
        manager and a subclass of ``arcpy.EnvManager``, and it is intended to
        be used in a ``with`` statement where *workspace* is the path to the
        geodatabase to be accessed. Furthermore, the ``maintainCurveSegments``
        geoprocessing environment is set to True by default.

        :param workspace: Path to the geodatabase
        :type workspace: PathLike[str] | str
        :param respect_submit: Whether to only analyze features where the
            ``SUBMIT`` attribute is ``"Y"``
        :type respect_submit: bool
        :param use_edit_session: Whether to automatically open and close an
            ArcPy edit session, default ``False``
        :type use_edit_session: bool
        :param messenger: ArcPy message-handling object; this is the
            ``messages`` argument passed to the ``execute`` method of a
            script-based geoprocessing tool. If ``None`` (the default), this is
            set to a fresh instance of :class:`FallbackGPMessenger`.
        :type messenger: GPMessenger
        :param env_kwargs: Additional arguments to be passed to the superclass
            (``arcpy.EnvManager``) initializer
        :type env_kwargs: Any
        """
        self._temporary_items = []
        self._is_active = False
        self._enter_timestamp = None
        self._init_timestamp = datetime.datetime.now()
        self.messenger = SessionMessenger(messenger or FallbackGPMessenger())

        self._gdb = Path(workspace)
        self._respect_submit = respect_submit
        self._use_edit_session = use_edit_session
        self.editor = arcpy.da.Editor(self.gdb) if use_edit_session else DummyEditor(self)
        if arcpy.Describe(self.gdb).dataType != "Workspace":
            raise ValueError(f"'{self._gdb}' is not a workspace.")
        super().__init__(workspace=self.gdb, maintainCurveSegments=True, **env_kwargs)
        _logger.info(f"Initialized {self.__class__.__name__} in workspace '{self.gdb}'.")

    def __enter__(self) -> Self:
        """Sets :attr:`_enter_timestamp` to the current time and sets
        :attr:`_is_active` to ``True``. By calling the superclass's
        ``__enter__`` method, changes the ``arcpy.env.workspace`` to
        :attr:`gdb`."""
        self._enter_timestamp = datetime.datetime.now()
        _logger.debug(f"Entering {self.__class__.__name__} in workspace '{self.gdb}'.")
        super().__enter__()
        self._is_active = True
        # if self._use_edit_session:
        #     self.editor.startEditing()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        _logger.debug(f"Exiting {self.__class__.__name__} in workspace '{self.gdb}'.")
        self._is_active = False
        # Clear temporary feature classes and layers
        self._delete_temporary_items()
        # should_save_edits: bool | None = (
        #     None if not self.editor.isEditing  # Irrelevant if not editing
        #     else False if exc_type  # DON'T save edits if error
        #     else True  # Otherwise, DO save edits
        # )
        if exc_type:
            message: str = "".join([
                f"Uncaught {exc_type.__name__} within {self.__class__.__name__}:\n",
                f"\t{exc_type.__name__}: {exc_val}\n",
                *format_exception(exc_type, exc_val, exc_tb)
            ])
            self.messenger.addWarningMessage(message, level=logging.ERROR, exc=exc_val)
            if self.editor.isEditing:
                self.editor.stopEditing(False)
                self.messenger.addWarningMessage("Due to the above issue, edits WERE NOT saved.")
        elif self.editor.isEditing:  # Implies active edit session AND no exception raised
            self.editor.stopEditing(True)
            _logger.info("Saved edits.")
        self._enter_timestamp = None
        return super().__exit__(exc_type, exc_val, exc_tb)

    def __del__(self) -> None:
        self._delete_temporary_items()

    @property
    def gdb_path(self) -> Path:
        """Returns a ``pathlib.Path`` instance representing the geodatabase
        directory."""
        return self._gdb

    @property
    def gdb(self) -> str:
        """
        This is essentially a shortcut for ``arcpy.env.workspace`` that also
        checks to ensure that the ``NG911Session`` instance is currently
        active (i.e. is being accessed within the ``with`` statement or is
        being accessed *between* calls to ``__enter__()`` and ``__exit__()``).

        :return: Path to the current workspace
        :rtype: str
        """
        return self.__fspath__()

    def __fspath__(self) -> str:
        return self._gdb.__fspath__()

    def __str__(self) -> str:
        return f"<{self.__class__.__name__} in {self.__fspath__()}>"

    def __repr__(self) -> str:
        if self.__class__ is __class__:
            kwargs = self._environments.copy()
            kwargs_repr = ", ".join(f"{k}={repr(v)}" for k, v in kwargs.items() if k != "workspace")
            if kwargs_repr:
                return f"{self.__class__.__name__}({repr(self._gdb)}, {self._respect_submit}, {kwargs_repr})"
            else:
                return f"{self.__class__.__name__}({repr(self._gdb)}, {self._respect_submit})"
        else:
            return super().__repr__()

    @property
    def respect_submit(self) -> bool:
        """Returns whether operations with an instance of this class are to
        only apply to those features where the ``SUBMIT`` attribute is equal to
        ``"Y"``."""
        return self._respect_submit

    @property
    def required_ds_exists(self) -> bool:
        """Returns whether the geodatabase contains a feature dataset with the
        prescribed name of the :term:`required feature dataset`."""
        return bool(arcpy.ListDatasets(_rds, "Feature"))

    @property
    def optional_ds_exists(self) -> bool:
        """Returns whether the geodatabase contains a feature dataset with the
        prescribed name of the :term:`optional feature dataset`."""
        return bool(arcpy.ListDatasets(_ods, "Feature"))

    @property
    def required_ds_topology_exists(self) -> bool:
        """Returns whether the :term:`required feature dataset` contains a
        topology with the expected name. Does not indicate whether it contains
        any rules or members."""
        with self.required_ds_env_manager:
            return bool(arcpy.ListDatasets(_rdst, "Topology"))

    @property
    def optional_ds_topology_exists(self) -> bool:
        """Returns whether the :term:`optional feature dataset` contains a
        topology with the expected name. Does not indicate whether it contains
        any rules or members."""
        with self.optional_ds_env_manager:
            return bool(arcpy.ListDatasets(_odst, "Topology"))

    @property
    def required_ds_env_manager(self) -> arcpy.EnvManager:
        """Shortcut to get an instance of ``arcpy.EnvManager`` with the
        workspace set to the :term:`required feature dataset` of the Validator
        instance's geodatabase and all other environment settings copied from
        the Validator instance."""
        envs = self._environments.copy()
        envs["workspace"] = str(self._gdb / config.gdb_info.required_dataset_name)
        return arcpy.EnvManager(**envs)

    @property
    def optional_ds_env_manager(self) -> arcpy.EnvManager:
        """Shortcut to get an instance of ``arcpy.EnvManager`` with the
        workspace set to the :term:`optional feature dataset` of the Validator
        instance's geodatabase and all other environment settings copied from
        the Validator instance."""
        envs = self._environments.copy()
        envs["workspace"] = str(self._gdb / config.gdb_info.optional_dataset_name)
        return arcpy.EnvManager(**envs)

    @classmethod
    def create_standard_gdb(cls, output_directory: Path | str, name: str, spatial_reference: arcpy.SpatialReference, messenger: Optional[GPMessenger] = None) -> Self:
        output_directory = Path(output_directory)
        if not output_directory.exists():
            raise NotADirectoryError(f"Output directory '{output_directory}' does not exist.")
        output_path = (output_directory / name).with_suffix(".gdb")
        if output_path.exists():
            raise FileExistsError(f"'{output_path}' already exists.")

        sr_name: str = getattr(spatial_reference, "name", "<unnamed>")
        sr_code: int | str = getattr(spatial_reference, "factoryCode", "<no factoryCode>")
        _logger.info(f"Creating standard geodatabase at '{output_directory / name}' with spatial reference '{sr_name}' ({sr_code}).")

        arcpy.management.CreateFileGDB(str(output_directory), name)
        session: Self = cls(output_path, False, False, messenger)
        with session:
            session.messenger.addMessage(f"Preparing new NG911 geodatabase at '{output_path}'.")
            session._add_standard_feature_datasets(spatial_reference)
            session._add_standard_domains()
            session._delete_temporary_items()
        return session

    def _add_standard_feature_datasets(self, spatial_reference: arcpy.SpatialReference, required: bool = True, optional: bool = True) -> tuple[bool, bool]:
        """
        Creates the :term:`required feature dataset` and/or the
        :term:`optional feature dataset` in the geodatabase. Returns a
        ``tuple`` of ``bool``\ s indicating whether the required and optional
        datasets, respectively, were created.

        This method is intended to be called by :meth:`create_standard_gdb`.
        """
        if not any((required, optional)):
            _logger.info("No feature datasets selected for creation.")
            return False, False

        created_required = False
        if required:
            _logger.info(f"Creating '{_rds}' in '{self.gdb}'.")
            try:
                arcpy.management.CreateFeatureDataset(self.gdb, _rds, spatial_reference)
                created_required = True
            except Exception as exc:
                self.messenger.addWarningMessage(f"Failed to create required dataset '{_rds}' in '{self.gdb}'.", level=logging.CRITICAL, exc=exc)

        created_optional = False
        if optional:
            _logger.info(f"Creating '{_ods}' in '{self.gdb}'.")
            try:
                arcpy.management.CreateFeatureDataset(self.gdb, _ods, spatial_reference)
                created_optional = True
            except Exception as exc:
                self.messenger.addWarningMessage(f"Failed to create optional dataset '{_ods}' in '{self.gdb}'.", level=logging.CRITICAL, exc=exc)

        if not any((created_required, created_optional)):
            self.messenger.addErrorMessage(f"Did not create any feature datasets.")

        return created_required, created_optional

    def _add_standard_domains(self) -> int:
        """
        Attempts to add all domains prescribed by the Standard and set their
        *split policies* to ``DUPLICATE``. Returns the number of domains
        successfully added and configured.

        This method is intended to be called by :meth:`create_standard_gdb`.
        """
        successfully_added: int = 0
        for domain in config.domains.values():
            try:
                domain_table_df = pd.DataFrame({
                    "Code": pd.Series(domain.entries.keys()),
                    "Description": pd.Series(domain.entries.values())
                })
                domain_table_df.spatial.to_table(domain_table := self._temp_fc_name(domain.name))
                arcpy.management.TableToDomain(domain_table, "Code", "Description", self.gdb, domain.name, domain.description)
                arcpy.management.AlterDomain(self.gdb, domain.name, split_policy="DUPLICATE")
            except Exception as exc:
                self.messenger.addWarningMessage(f"Failed adding/configuring domain '{domain.name}' in '{self.gdb}'.", level=logging.ERROR, exc=exc)
                continue
            else:
                successfully_added += 1
        return successfully_added

    def _temp_fc_name(self, base_name: str) -> str:
        """Creates and keeps track of a unique name for a temporary feature
        class **to be stored in memory**. The feature class is deleted when the
        context manager exits."""
        name: str = arcpy.CreateUniqueName(base_name=base_name, workspace="memory")
        self._temporary_items.append(name)
        return name


    def _temp_layer_name(self, base_name: str) -> str:
        """Creates and keeps track of a unique name for a temporary feature
        layer. The layer is deleted when the context manager exits."""
        timestamp: float = (self._enter_timestamp or self._init_timestamp).timestamp()
        name: str = f"{base_name}_{timestamp:.0f}"
        self._temporary_items.append(name)
        return name

    def _delete_temporary_item(self, item: str) -> bool:
        if item not in self._temporary_items:
            self.messenger.addWarningMessage(f"No item called '{item}' in _temporary_items. This is a bug. Please report to the developers. Contents:\n{indent_each(self._temporary_items)}", level=logging.ERROR)
            return False
        try:
            self._temporary_items.remove(item)
            arcpy.management.Delete(item)
        except Exception as exc:
            self.messenger.addWarningMessage(f"Failed to delete temporary item: '{item}'.", level=logging.ERROR, exc=exc)
            return False
        else:
            return True

    def _delete_temporary_items(self) -> int:
        temp_item_count: int = len(self._temporary_items)
        deleted_count: int = sum(
            1 if self._delete_temporary_item(item)
            else 0
            for item in self._temporary_items
        )
        # for _ in range(temp_item_count):
        #     item = self._temporary_items.pop(0)
        #     deleted_count += (1 if self._delete_temporary_item(item) else 0)
        _logger.debug(f"Deleted {deleted_count} temporary item(s).")
        return deleted_count

    def count(self, feature_class: NG911FeatureClass, where_clause: Optional[str] = None) -> int:
        """Counts the number of features in a feature class using OGR, with an
        optional where clause. This is much faster than using ArcPy or Pandas."""
        ogr.UseExceptions()
        driver: ogr.Driver = ogr.GetDriverByName("OpenFileGDB")
        ds: ogr.DataSource = driver.Open(self.gdb)
        lyr: ogr.Layer = ds.GetLayerByName(feature_class.name)
        if where_clause:
            lyr.SetAttributeFilter(where_clause)
        return lyr.GetFeatureCount()

    def load_df(self, feature_class: NG911FeatureClass, fields: Optional[Sequence[NG911Field | Literal["OID@"] | str]] = None, respect_submit: Optional[bool] = None, **kwargs) -> pd.DataFrame:
        """
        Loads and returns a Spatially-Enabled ``DataFrame`` containing the data
        of *feature_class*.

        This method specifically sets the index of the ``DataFrame`` to the
        NGUID column, and it does so **without dropping the column** if the
        NGUID field is in *fields*. This provides for compatibility with
        :meth:`.validation.FeatureAttributeErrorMessage.from_df`.

        The resulting ``DataFrame``\ 's ``attrs`` dict will be an instance of
        :class:`_NG911DataFrameAttrs`.

        .. note::

            The NGUID field will be loaded regardless of whether it was
            included in *fields*, but it will only be included as a regular
            column if it was included in *fields*.

        .. seealso::

            :class:`_NG911DataFrameAttrs`

        :param feature_class: The feature class to load
        :type feature_class: NG911FeatureClass
        :param fields: The fields to include, as either :class:`NG911Field`
            objects or field names; the string ``OID@`` is also allowed **and
            will be replaced** with the name of the ``OBJECTID`` field
        :type fields: Optional[Sequence[NG911Field | Literal["OID@"] | str]]
        :param respect_submit: Whether to only return features where the
            :ng911field:`submit` attribute is ``"Y"``; defaults to
            :attr:`_respect_submit`
        :type respect_submit: Optional[bool]
        :param kwargs: Additional keyword arguments to pass to
            ``pandas.DataFrame.spatial.from_featureclass()``
        :type kwargs: Any
        :return: The feature class data in a ``pandas.DataFrame``
        :rtype: pandas.DataFrame
        """
        if not self._is_active:
            raise RuntimeError("Session is not active; either __enter__() has not been called, or __exit__() has already been called.")

        fields: Optional[list[NG911Field | Literal["OID@"] | str]] = [*fields] if fields is not None else None

        if respect_submit is None:
            respect_submit = self._respect_submit

        drop_nguid: bool = False
        """Whether to drop the NGUID column when setting it as the index. Only
        gets set to ``True`` if *fields* is provided and does not include the
        NGUID column."""

        fc_path: Path = self.gdb_path / feature_class.dataset / feature_class.name
        describe_data: dict = arcpy.da.Describe(str(fc_path))
        oid_name = describe_data["OIDFieldName"]
        if fields is not None and "OID@" in fields:
            fields[fields.index("OID@")] = oid_name

        if fields:
            field_names: list[str] = [f.name if isinstance(f, NG911Field) else f for f in fields]
            fields: list[NG911Field] = [config.get_field_by_name(f) for f in field_names if f in [_f.name for _f in config.fields.values()]]
            if feature_class.unique_id.name not in field_names:
                field_names.insert(0, feature_class.unique_id.name)
                drop_nguid = True
        else:
            field_names: None = None
            # If no fields were explicitly provided, try to get something to put in result.attrs["fields"]
            fields: list[NG911Field] = [config.get_field_by_name(f.name) for f in arcpy.ListFields(self.str_path_to(feature_class)) if f in [_f.name for _f in config.fields.values()]]

        wc: str | None = f"{config.fields.submit.name} = 'Y'" if respect_submit else None
        if wc_arg := kwargs.pop("where_clause", None):
            # If a where_clause argument was already provided, combine it with ``wc`` if necessary
            wc = f"({wc}) AND ({wc_arg})" if wc else wc_arg

        result: pd.DataFrame = pd.DataFrame.spatial.from_featureclass(
            str(fc_path),
            where_clause=wc,
            fields=field_names,
            **kwargs
        ).set_index(feature_class.unique_id.name, drop=drop_nguid)

        df_attrs: _NG911DataFrameAttrs = {
            "path": fc_path,
            "feature_class": feature_class,
            "fields": tuple(fields),
            "nguid_name": feature_class.unique_id.name,
            "oid_name": oid_name if oid_name in result.columns else None,
            "shape_name": describe_data["shapeFieldName"],
            "geodatabase_path": self.gdb_path
        }
        result.attrs = df_attrs
        return result

    def path_to(self, item: NG911FeatureClass | str) -> Path:
        """
        Returns the absolute path to *item* in the geodatabase, where *item*
        is any of the following:

        - An NG911FeatureClass instance
        - A valid NG911FeatureClass role
        - The name of the required or optional feature dataset
        - The name of the required or optional dataset topology

        :param item: The geodatabase item for which a path will be returned
        :type item: NG911FeatureClass | str
        :return: Path to *item*
        :rtype: pathlib.Path
        """
        if isinstance(item, NG911FeatureClass):
            # item is an NG911FeatureClass
            return self._gdb.joinpath(item.dataset, item.name)
        elif isinstance(item, str):
            if item in config.feature_classes.keys():
                # item is a role
                return self.path_to(config.feature_classes[item])
            elif item in {*[fc.dataset for fc in config.feature_classes.values()], _rds, _ods}:
                # item is a feature dataset name
                return self._gdb.joinpath(item)
            elif item in {_rdst, _odst}:
                # item is a topology name
                ds: str = {_rdst: _rds, _odst: _ods}[item]
                return self._gdb.joinpath(ds, item)
            else:
                raise ValueError(f"Argument 'item' must be an NG911FeatureClass instance, feature class role, feature dataset name, or topology name; got '{item}'.")
        else:
            raise TypeError(f"Expected NG911FeatureClass or str, got '{type(item)}' ('{item}').")

    def str_path_to(self, item: NG911FeatureClass | str) -> str:
        """
        Returns the absolute path to *item* in the geodatabase as a ``str``.
        See :meth:`path_to`.

        :param item: The geodatabase item for which a path will be returned
        :type item: NG911FeatureClass | str
        :return: Path to *item* as a ``str``
        :rtype: str
        """
        return str(self.path_to(item))

    def _copy_to_zip(self, destination: Path | str, overwrite: bool = False, *, staged_gdb_path: Optional[Path] = None) -> Path:
        """
        Creates a .zip file with the contents of the geodatabase. Files with a
        name ending in ".lock" are excluded.

        .. WARNING::
           Unline :meth:`export_to_zip`, this method does NOT respect the
           SUBMIT attribute!

        :param destination: Path to zip file to be created; must end in
            ".gdb.zip"
        :type destination: pathlib.Path
        :param overwrite: Whether to overwrite any existing file at
            ``destination``, default False
        :type overwrite: bool
        :param staged_gdb_path: If given, uses this argument as the path to the
            geodatabase to export instead of ``self.gdb_path``, default None
        :type staged_gdb_path: Optional[pathlib.Path]
        :return: Absolute path to the output file
        :rtype: pathlib.Path
        """
        destination: Path = Path(destination)
        mode: Literal["w", "x"] = "w" if overwrite else "x"
        if destination.is_dir():
            raise IsADirectoryError("Argument 'destination' must be a path to a file, not a directory.")
        elif not destination.name.endswith(".gdb.zip"):
            raise ValueError("Argument 'destination' must end in '.gdb.zip'.")

        with ZipFile(destination, mode) as z:
            path: Path
            zipped_folder: Path = Path(destination.stem)
            z.mkdir(destination.stem)
            for path in (staged_gdb_path or self.gdb_path).iterdir():
                if path.is_dir():
                    raise RuntimeError(fr"Unexpectedly encountered a directory ('{path.name}') inside geodatabase directory.")
                elif not path.name.lower().endswith(".lock"):
                    z.write(path, zipped_folder / path.name)

        return destination.absolute()

    def to_shapefiles(self, destination: Path | str, respect_submit: Optional[bool] = None, overwrite: bool = False) -> list[Path]:
        """
        Exports all NG911 feature classes to individual shapefiles.

        :param destination: Path to the output directory
        :type destination: pathlib.Path | str
        :param respect_submit: Whether to only export those features where the
            ``SUBMIT`` attribute is `'Y'`, defaults to ``self._respect_submit``
        :type respect_submit: Optional[bool]
        :param overwrite: Whether to overwrite files in the output directory if
            needed, default False
        :type overwrite: bool
        :return: Paths to the output shapefiles (only the .shp files)
        :rtype: list[pathlib.Path]
        """
        destination: Path = Path(destination)
        respect_submit: bool = self._respect_submit if respect_submit is None else respect_submit
        fc_list: list[str] = []

        with self.required_ds_env_manager:
            fc_list += [str(self.path_to(config.get_feature_class_by_name(fc_name))) for fc_name in arcpy.ListFeatureClasses() if fc_name in config.required_feature_class_names]
        with self.optional_ds_env_manager:
            fc_list += [str(self.path_to(config.get_feature_class_by_name(fc_name))) for fc_name in arcpy.ListFeatureClasses() if fc_name in config.optional_feature_class_names]

        if not destination.is_dir():
            raise ValueError(f"'{destination}' must be a directory.")
        output_dir: Path = destination / self.gdb_path.stem
        if output_dir.is_dir():
            raise ValueError(f"Cannot create directory '{output_dir}' because it already exists.")
        os.mkdir(output_dir)
        self.messenger.addMessage(f"Created directory '{output_dir}'.")

        envs: dict = self._environments.copy()
        envs["overwriteOutput"] = overwrite
        output_list: list[Path] = []
        wc: str | None = f"{config.fields.submit.name} = 'Y'" if respect_submit else None
        with arcpy.EnvManager(**envs):
            for fc_path in fc_list:
                try:
                    result: arcpy.Result = arcpy.conversion.ExportFeatures(
                        in_features=fc_path,
                        # out_features=str(output_dir / Path(fc_path).name) + ".shp",
                        out_features=str(output_dir / Path(fc_path).name),
                        where_clause=wc,
                        # use_field_alias_as_name="NOT_USE_ALIAS"
                    )
                    out_path: str = result.getOutput(0)
                except Exception as exc:
                    exc_text = "".join(format_exception(exc))
                    self.messenger.addWarningMessage(f"Failed to export '{fc_path}':\n{exc_text}", level=logging.ERROR, exc=exc)
                else:
                    self.messenger.addMessage(f"Exported shapefile '{out_path}'.")
                    output_list.append(Path(out_path))

        return output_list

    def export_to_zip(self, destination: Path | str, respect_submit: Optional[bool] = None, overwrite: bool = False, add_topology: bool = True) -> Path:
        """
        Exports NG911 feature classes to a temporary geodatabase, zips it, and
        returns the absolute path to the zipped geodatabase.

        .. NOTE::
           Unline :meth:`_copy_to_zip`, this method *does* respect the SUBMIT
           attribute!

        :param destination: Path to zip file to be created; must end in
            ".gdb.zip"
        :type destination: pathlib.Path
        :param respect_submit: Whether to only export features where the SUBMIT
            attribute is 'Y'; if not specified, uses ``self._respect_submit``
        :type respect_submit: Optional[bool]
        :param overwrite: Whether to overwrite any existing file at
            ``destination``, default False
        :type overwrite: bool
        :param add_topology: Whether to add and validate topology rules,
            default True
        :type add_topology: bool
        :return: Absolute path to the output file
        :rtype: pathlib.Path
        """
        destination: Path = Path(destination)
        respect_submit: bool = self._respect_submit if respect_submit is None else respect_submit
        if destination.is_dir():
            raise IsADirectoryError("Argument 'destination' must be a path to a file, not a directory.")
        if not destination.name.endswith(".gdb.zip"):
            raise ValueError("Argument 'destination' must end in '.gdb.zip'.")
        if destination.exists() and not overwrite:
            raise FileExistsError(f"'{destination}' already exists and 'overwrite' is False.")

        # Create a temporary directory in which to build the output GDB
        with TemporaryDirectory() as staging_dir:
            staging_dir_path = Path(staging_dir)
            staging_gdb_path = staging_dir_path / destination.stem  # .stem removes ".zip" but leaves ".gdb"
            self.messenger.addMessage(f"Export geodatabase will be staged at '{staging_gdb_path}'.")
            sr: arcpy.SpatialReference = arcpy.Describe(config.gdb_info.required_dataset_name).spatialReference

            # Create output GDB and populate it
            with self.create_standard_gdb(
                staging_dir_path,
                staging_gdb_path.stem,
                sr,
                self.messenger
            ) as export_session:

                # Copy over all standard feature classes from input GDB to output GDB
                for fc in config.feature_classes.values():
                    in_features = self.str_path_to(fc)
                    if not arcpy.Exists(in_features):
                        self.messenger.addWarningMessage(f"'{in_features}' does not exist; skipping.")
                        continue
                    out_features = export_session.str_path_to(fc)
                    wc = f"{cf.submit:n} = 'Y'" if respect_submit else None
                    arcpy.conversion.ExportFeatures(in_features, out_features, wc, False)
                    _logger.debug(f"'{out_features}' exported to staging geodatabase.")
                self.messenger.addMessage("Finished copying feature classes.")

                # Add topology, if applicable
                if add_topology:
                    self.messenger.addMessage("Adding and configuring topology dataset(s).")
                    export_session.add_and_configure_topologies(True)
                    export_session.validate_topologies()
                    self.messenger.addMessage("Finished building topology dataset(s) in staging geodatabase.")

            # Remove locks
            _logger.info(f"Compacting staging geodatabase at '{staging_gdb_path}'.")
            arcpy.management.Compact(str(staging_gdb_path))
            self.messenger.addMessage("Compacted staging geodatabase.")

            # Zip the output GDB and write the zip file to destination
            try:
                out_path = self._copy_to_zip(destination, overwrite, staged_gdb_path=staging_gdb_path)
            except Exception as exc:
                raise self.messenger.addErrorMessage("\n".join(format_exception(exc)), level=logging.CRITICAL, exc=exc)

        return out_path

    def _configure_topology(self, ds_env_manager: arcpy.EnvManager, topology_name: str, rules: Sequence[TopologyRule]) -> tuple[int, int] | None:
        """
        Given an ``arcpy.EnvManager``, topology name, and sequence of rules,
        adds those rules and their members to the topology *topology_name*.

        .. note::

           Parameter *ds_env_manager* is intended to be set to either
           :attr:`required_ds_env_manager` or :attr:`optional_ds_env_manager`.

        :param ds_env_manager: An environment manager with the workspace set to
            the target feature dataset
        :type ds_env_manager: arcpy.EnvManager
        :param topology_name: Name of the topology to configure
        :type topology_name: str
        :param rules: Rules to add to the topology
        :type rules: Sequence[TopologyRule]
        :return: Tuple of numbers of rules and feature classes, respectively,
            added to the topology; ``(0, 0)`` if there is no topology
        :rtype: tuple[int, int]
        """
        added_rules_count: int = 0
        added_members_count: int = 0
        with ds_env_manager:
            if not arcpy.Exists(topology_name):
                return 0, 0
            _logger.debug(f"Configuring topology in '{arcpy.env.workspace}'.")
            existing_member_names: list[str] = sorted(arcpy.da.Describe(topology_name)["featureClassNames"])
            # describe_obj = arcpy.Describe(topology_name)
            current_members: set[NG911FeatureClass] = {config.get_feature_class_by_name(fc_name) for fc_name in existing_member_names}
            _logger.debug(f"Before making changes, '{topology_name}' contains these members: {', '.join(existing_member_names)}")
            for rule in rules:
                _logger.debug(f"Adding rule: {rule.name1} / {rule.rule} / {rule.name2}")
                for member in rule.members:
                    if member in current_members:
                        _logger.debug(f"Member '{member:n}' is already in topology; skipping.")
                        continue
                    _logger.debug(f"Adding member '{member:n}' to topology '{topology_name}'.")
                    # try:
                    arcpy.management.AddFeatureClassToTopology(topology_name, self.str_path_to(member))
                    # except arcpy.ExecuteError as exc:
                    #     _logger.warning(f"ArcPy reports that '{member:n}' is already a member of the topology. Attempting to remove and re-add.", exc_info=exc)
                    #     arcpy.management.RemoveFeatureClassFromTopology(topology_name, member.name)
                    #     _logger.debug(f"Removed '{member:n}'; re-adding...")
                    #     arcpy.management.AddFeatureClassToTopology(topology_name, member.name)
                    #     _logger.debug(f"Removed and re-added '{member:n}' to topology '{topology_name}'.")
                    current_members.add(member)
                    added_members_count += 1
                    _logger.debug(f"After adding member '{member:n}' to topology '{topology_name}', ArcPy reports that the topology contains these members: {sorted(arcpy.da.Describe(topology_name)['featureClassNames'])}")
                rule.add_rule_to_topology(topology_name)
                added_rules_count += 1
                _logger.debug(f"Added rule '{rule.rule}' to topology '{topology_name}'. Added {added_members_count} member(s) and {added_rules_count} rule(s) so far.")
        _logger.info(f"Topology configuration complete. Added {added_rules_count} rule(s) and {added_members_count} member(s) to topology '{topology_name}'.")
        return added_rules_count, added_members_count

    def _add_required_ds_topology(self, replace: bool = False) -> bool:
        """
        If :data:`~.topology.topology_config` specifies at least one rule and at least one
        member for a topology in the :term:`required feature dataset`, this method creates a
        topology for that dataset if one does not already exist. It does
        **not** add any rules or members to the new topology.

        The new topology will have the name specified by
        :attr:`topology_config.required_dataset_topology_name <.topology.topology_config.required_dataset_topology_name>`.

        :param replace: Whether to delete and recreate the topology if one
            already exists. Has no effect if one does not already exist or if
            :data:`~.topology.topology_config` specifies no rules or no members for the
            new topology. Default False.
        :type replace: bool
        :return: Whether a new topology was created
        :rtype: bool
        """
        if topology_config.required_dataset_rules and topology_config.required_dataset_members:
            with self.required_ds_env_manager:
                if replace and arcpy.Exists(_rdst):
                    arcpy.management.Delete(_rdst)
                    self.messenger.addMessage(f"Deleted old topology '{_rdst}'.")
                self.messenger.addMessage(f"Adding topology called '{_rdst}' to dataset '{_rds}'.")
                ds_path = self.path_to(_rds)
                arcpy.management.CreateTopology(str(ds_path), _rdst)
            return True
        else:
            return False

    def _add_optional_ds_topology(self, replace: bool = False) -> bool:
        """
        If :data:`~.topology.topology_config` specifies at least one rule and at least one
        member for a topology in the :term:`optional feature dataset`, this method creates a
        topology for that dataset if one does not already exist. It does
        **not** add any rules or members to the new topology.

        The new topology will have the name specified by
        :attr:`topology_config.optional_dataset_topology_name <.topology.topology_config.optional_dataset_topology_name>`.

        :param replace: Whether to delete and recreate the topology if one
            already exists. Has no effect if one does not already exist or if
            :data:`~.topology.topology_config` specifies no rules or no members for the
            new topology. Default False.
        :type replace: bool
        :return: Whether a new topology was created
        :rtype: bool
        """
        if topology_config.optional_dataset_rules and topology_config.optional_dataset_members:
            with self.optional_ds_env_manager:
                if replace and arcpy.Exists(_odst):
                    arcpy.management.Delete(_odst)
                    self.messenger.addMessage(f"Deleted old topology '{_odst}'.")
                ds_path = self.path_to(_ods)
                arcpy.management.CreateTopology(str(ds_path), _odst)
            return True
        else:
            return False

    def add_and_configure_topologies(self, replace: bool = False) -> tuple[bool, bool]:
        """
        Adds topologies and their respective rules and members to the required
        and optional feature datasets.

        :param replace: Whether to delete and recreate each topology that
            already exists. Has no effect for one does not already exist or if
            ``topology_config`` specifies no rules or no members for that
            topology. Default False.
        :type replace: bool
        :return: Whether the required and optional topologies, respectively,
            were modified
        :rtype: tuple[bool, bool]
        """
        added_rdst: bool = self._add_required_ds_topology(replace)
        if added_rdst:
            self.messenger.addMessage(f"Added topology '{_rds}/{_rdst}'.")
        rdst_changes: tuple[int, int] = self._configure_topology(self.required_ds_env_manager, topology_config.required_dataset_topology_name, topology_config.required_dataset_rules)

        added_odst: bool = self._add_optional_ds_topology(replace)
        if added_odst:
            self.messenger.addMessage(f"Added topology '{_ods}/{_odst}'.")
        odst_changes: tuple[int, int] = self._configure_topology(self.optional_ds_env_manager, topology_config.optional_dataset_topology_name, topology_config.optional_dataset_rules)

        return any((added_rdst, *rdst_changes)), any((added_odst, *odst_changes))

    def validate_topologies(self) -> tuple[bool, bool]:
        """
        Validates the required and/or optional dataset topologies if each
        exists.

        :return: Whether the required and optional dataset topologies,
            respectively, were validated
        :rtype: tuple[bool, bool]
        """
        if self.required_ds_topology_exists:
            arcpy.management.ValidateTopology(fr"{_rds}\{_rdst}")
            rdst_validated = True
        else:
            rdst_validated = False
        if self.optional_ds_topology_exists:
            arcpy.management.ValidateTopology(fr"{_ods}\{_odst}")
            odst_validated = True
        else:
            odst_validated = False
        return rdst_validated, odst_validated

    def create_standard_feature_class(self, feature_class: NG911FeatureClass, overwrite: bool = False) -> Path:
        """
        Creates a standard feature class based on the specifications in
        ``config.yml``.

        :param feature_class: The feature class to create
        :type feature_class: NG911FeatureClass
        :param overwrite: Whether to overwrite an existing feature class, if
            present; default False
        :return: Path to the newly-created feature class
        :rtype: pathlib.Path
        """

        # Delete existing feature class if appropriate
        fc_path: str = self.str_path_to(feature_class)
        if arcpy.Exists(fc_path):
            if overwrite:
                arcpy.management.Delete(fc_path)
                _logger.info(f"Deleted existing feature class at '{fc_path}'.")
            else:
                raise FileExistsError(f"Parameter 'overwrite' is set to {overwrite}, but a feature class already exists at '{fc_path}'.")

        # Create standard feature class
        try:
            arcpy.management.CreateFeatureclass(
                out_path=self.str_path_to(feature_class.dataset),
                out_name=feature_class.name,
                geometry_type=feature_class.geometry_type,
                has_z="ENABLED",
                has_m="ENABLED"
            )
        except Exception as exc:
            raise self.messenger.addErrorMessage(f"Failed to create standard feature class: '{feature_class.name}'.", level=logging.CRITICAL, exc=exc)
        else:
            _logger.info(f"Created standard feature class: '{feature_class.name}'.")

        # Add standard fields
        try:
            arcpy.management.AddFields(
                in_table=self.str_path_to(feature_class),
                field_description=feature_class.field_value_table_rows
            )
        except Exception as exc:
            raise self.messenger.addErrorMessage(f"Failed to add standard fields to feature class: '{feature_class.name}'.", level=logging.CRITICAL, exc=exc)
        else:
            _logger.info(f"Added {len(feature_class.fields)} standard field(s) to '{feature_class.name}'.")

        return self.path_to(feature_class)

    def append_to_feature_class(self, feature_class: NG911FeatureClass, source: Path, source_to_standard_mapping: Optional[dict[str, str]] = None) -> int:
        """
        Appends rows from *source* to the standard feature class
        *feature_class*. An optional field mapping dictionary may be provided
        with source field names as keys and standard field
        :attr:`NG911Field.name <names>` (not :attr:`NG911Field.role <roles>`)
        as values.
        
        :param feature_class: The feature class to append to
        :param source: NG911FeatureClass
        :param source_to_standard_mapping:
        :return:
        """
        if not arcpy.Exists(self.str_path_to(feature_class)):
            _logger.info(f"Feature class {feature_class:n} does not exist; it will be created.")
            self.create_standard_feature_class(feature_class)
        if source_to_standard_mapping is None:
            _logger.debug(f"Field mapping from '{source}' to standard feature class '{feature_class:n}' was not provided. Assuming field names in source are compliant.")
            source_to_standard_mapping = {f.name: f.name for f in feature_class.fields.values()}
        mappings = arcpy.FieldMappings()
        for source_name, standard_name in source_to_standard_mapping.items():
            mapping = arcpy.FieldMap()
            mapping.addInputField(str(source), source_name)
            mapping.outputField = config.get_field_by_name(standard_name).arcpy_field
            mappings.addFieldMap(mapping)
        result = arcpy.management.Append(
            str(source),
            self.str_path_to(feature_class),
            "NO_TEST",
            mappings
        )
        return result.getOutput(1)  # Number of rows appended

    def import_esn_polygons(self,
                            source_fc: PathLike[str] | str,
                            field_map: Mapping[NG911Field | str, arcpy.Field | str | None],
                            assign_sequential_nguids: bool = False,
                            esz_nguid_method: NAM = NAM.NULL,
                            convert_nguids: bool = False,
                            esb_to_display_name: bool = False,
                            overwrite: bool = False
    ) -> tuple[Path | None, Path | None, Path | None, Path | None]:
        """
        Given a source feature class with ESN polygons, derives the feature
        classes :ng911fc:`esb_ems_boundary`, :ng911fc:`esb_fire_boundary`,
        :ng911fc:`esb_law_boundary`, and :ng911fc:`esz_boundary`

        :param source_fc: Path to ESN polygon feature class
        :param field_map: Mapping of **standard** fields to **source** fields
        :param assign_sequential_nguids: Whether to assign sequential NGUID
            attributes to the output ESB (but not ESZ) features; default False
        :param esz_nguid_method: Method to use to assign NGUIDs to the output
            ESZ features; default None
        :param convert_nguids: Whether to convert existing NGUIDs from the
            format used in Standards v2.2 to that used in v3; only applies
            to the output :ng911fc:`esz_boundary` feature class and only
            allowed if *esz_nguid_method* is :attr:`NAM.NGUID`;
            default False
        :param esb_to_display_name: Whether to copy ESB attributes
            (:ng911field:`ems`/:ng911field:`fire`/:ng911field:`law`) to the
            :ng911field:`dsplayname` field, default False
        :param overwrite: Whether to overwrite existing ESB and ESZ feature
            classes, default False
        :return: Paths to derived EMS, fire, law enforcement, and ESZ polygon
            feature classes, respectively (replaced with ``None`` for feature
            classes where errors were encountered during processing)
        :rtype: tuple[Path | None, Path | None, Path | None, Path | None]
        """

        #####################################################
        #### Validate arguments and do other preparation ####
        #####################################################

        source_fc: str = source_fc.__fspath__() if isinstance(source_fc, PathLike) else source_fc
        source_fc_fields: dict[str, arcpy.Field] = {f.name: f for f in arcpy.ListFields(source_fc)}
        # # If the source field objects actually turn out to be of type 'geoprocessing describe field object', ensure that they are converted to arcpy.Field objects; calling convertArcObjectToPythonObject() on an object that is already an instance of arcpy.Field doesn't appear to be a problem
        # source_fc_fields: dict[str, arcpy.Field] = {f.name: convertArcObjectToPythonObject(f) for f in arcpy.ListFields(source_fc)}

        # Ensure type of field_map is dict[NG911Field, arcpy.Field]
        try:
            field_map: dict[NG911Field, arcpy.Field] = {
                standard_field
                    if isinstance(standard_field, NG911Field)
                    else config.get_field_by_name(standard_field):
                source_field
                    if isinstance(source_field, arcpy.Field)
                    else source_fc_fields[source_field]  # type: ignore
                for standard_field, source_field
                in field_map.items()
                if source_field
            }
        except KeyError as exc:
            raise ValueError(f"Source feature class does not contain a field named '{exc.args[0]}'.") from exc

        # Alias variables for fields; "_f" to indicate "[f]ield"
        nguid_esz_f: NG911Field = config.feature_classes.esz_boundary.unique_id
        esn_f: NG911Field = config.fields.esn
        esz_f: NG911Field = config.fields.esz
        ems_f: NG911Field = config.fields.ems
        fire_f: NG911Field = config.fields.fire
        law_f: NG911Field = config.fields.law
        localid_f: NG911Field = config.fields.local_id
        agencyid_f: NG911Field = config.fields.agency_id

        # If a necessary field is missing, prepare an error message and raise exception
        required_map_fields: set[NG911Field] = {esz_f, ems_f, fire_f, law_f}
        fields_missing_from_map: set[NG911Field] = required_map_fields - set(field_map.keys())
        if fields_missing_from_map:
            missing_fields_str = ', '.join(f"'{x}'" for x in sorted(f.name for f in fields_missing_from_map))
            raise ValueError(f"The following fields must be present in field_map: {missing_fields_str}.")

        # Require an agency ID field if assign_sequential_nguids is True and/or esz_nguid_method is set
        if (assign_sequential_nguids or esz_nguid_method) and agencyid_f not in field_map.keys():
            raise ValueError(f"If 'assign_sequential_nguids' is True and/or 'esz_nguid_method' is provided, a field must be provided for '{agencyid_f.name}'.")

        # Require that esz_nguid_method is NGUID if convert_nguids is True
        if convert_nguids and esz_nguid_method != NAM.NGUID:
            raise ValueError("'convert_nguids' may only be True if 'esz_nguid_method' is 'NGUID'.")

        # Require a local ID field if esz_nguid_method is LOCAL
        if esz_nguid_method == NAM.LOCAL and localid_f not in field_map.keys():
            raise ValueError(f"If 'esz_nguid_method' is 'LOCAL', a field must be provided for '{localid_f.name}'.")

        # Require an NGUID field if esz_nguid_method is COPY
        if esz_nguid_method == NAM.COPY and nguid_esz_f not in field_map.keys():
            raise ValueError(f"If 'esz_nguid_method' is 'COPY', a field must be provided for '{nguid_esz_f.name}'.")

        # Validate source_fc
        source_describe: dict = arcpy.da.Describe(source_fc)
        source_oid: str | None
        source_shape: str | None
        source_geometry_type: str | None
        if not (source_oid := source_describe.get("OIDFieldName")):
            raise ValueError(f"Source ESN feature class must have an OBJECTID (or equivalent) field.")
        if not (source_shape := source_describe.get("shapeFieldName")):
            raise ValueError(f"Source ESN feature class must have a SHAPE (or equivalent) field.")
        if (source_geometry_type := source_describe.get("shapeType")) != "Polygon":
            raise ValueError(f"Source ESN feature class must have 'Polygon' geometry (got '{source_geometry_type}').")

        # Alias variables for feature classes
        esz = config.feature_classes.esz_boundary
        ems = config.feature_classes.esb_ems_boundary
        fire = config.feature_classes.esb_fire_boundary
        law = config.feature_classes.esb_law_boundary

        # Check for existing ESB and ESZ feature classes if overwrite is False
        if not overwrite:
            existing_fcs: list[str] = [fc.name for fc in (esz, ems, fire, law) if arcpy.Exists(self.str_path_to(fc))]
            if existing_fcs:
                raise FileExistsError(f"Parameter 'overwrite' is set to {overwrite}, but the following feature class(es) already exist: {', '.join(existing_fcs)}")

        result_paths: dict[NG911FeatureClass, Path] = {}

        #########################################################################################
        #### Create each standard ESB feature class, dissolve source, map fields, and append ####
        #########################################################################################
        for output_fc, standard_esb_field in zip(
                (ems, fire, law),
                (ems_f, fire_f, law_f)
        ):
            _logger.info(f"Generating feature class '{output_fc.name}'.")
            source_esb_field_input: arcpy.Field = field_map[standard_esb_field]
            # if not isinstance(source_esb_field_input, arcpy.Field):
            #     raise ValueError(f"The fields {', '.join(f.name for f in (ems, fire, law, esz))} may not be set to '{USE_ESB_FIELD_KEYWORD}'.")

            output_standard_fields: set[NG911Field] = {*output_fc.fields.values()}
            source_dissolve_fields: list[arcpy.Field] = []
            for standard_field in {*output_standard_fields, standard_esb_field}:
                if standard_field in {esn_f, esz_f, localid_f, output_fc.unique_id}:
                    continue
                source_field: arcpy.Field | None = field_map.get(standard_field)
                if isinstance(source_field, arcpy.Field):
                    source_dissolve_fields.append(source_field)
            source_dissolve_field_names: list[str] = [f.name for f in source_dissolve_fields]
            out_temp_fc_name: str = arcpy.CreateUniqueName(f"ESZ_Dissolve_{output_fc.name}", "memory")

            # Create blank standard feature class
            out_fc_path: str = str(self.create_standard_feature_class(output_fc, overwrite))

            try:
                # Dissolve source
                _logger.debug(f"Dissolve fields: {', '.join(source_dissolve_field_names)}")
                arcpy.management.Dissolve(
                    in_features=source_fc,
                    out_feature_class=out_temp_fc_name,
                    dissolve_field=source_dissolve_field_names,
                    multi_part=False
                )
                _logger.info(f"Dissolved features; output to '{out_temp_fc_name}'.")

                # Create field map
                fm_log_message_pieces: list[str] = []
                fms = arcpy.FieldMappings()
                fms.addTable(out_fc_path)
                output_arc_fields: dict[str, arcpy.Field] = {f.name: f for f in arcpy.ListFields(out_fc_path) if not f.required}
                for standard_field in output_standard_fields:
                    source_field: arcpy.Field | None = field_map.get(standard_field)
                    if esb_to_display_name and standard_field == config.fields.dsplayname:
                        source_field = source_esb_field_input
                    elif not source_field:
                        continue
                    fm = arcpy.FieldMap()
                    _logger.debug(f"Building field map:\n\tStandard field: {standard_field}\n\tSource field: {arc_field_details(source_field)}")
                    fm.addInputField(out_fc_path, standard_field.name)
                    fm.addInputField(out_temp_fc_name, source_field.name)
                    fm.outputField = output_arc_fields[standard_field.name]
                    fms.addFieldMap(fm)
                    fm_log_message_pieces.append(f"{source_field.name} -> {standard_field.name}")
                _logger.debug(f"Populating '{output_fc.name}' using field mappings: {fms.exportToString()}")

                # Map and append dissolved features to standard feature class
                append_result: arcpy.Result = arcpy.management.Append(
                    inputs=out_temp_fc_name,
                    target=out_fc_path,
                    schema_type="NO_TEST",
                    field_mapping=fms
                )
            except Exception as exc:
                self.messenger.addWarningMessage(f"Dissolve and append process failed for '{output_fc.name}'.", level=logging.ERROR, exc=exc)
                arcpy.management.Delete(out_fc_path)
            else:
                self.messenger.addMessage(f"Created and populated {output_fc.name}.")
                result_paths[output_fc] = Path(append_result.getOutput(0))
            finally:
                arcpy.management.Delete(out_temp_fc_name)

        ###############################################
        #### Create and populate ESZ feature class ####
        ###############################################

        # Create feature class
        try:
            out_fc_path: str = str(self.create_standard_feature_class(esz, overwrite))
            self.messenger.addMessage(f"Created {esz:n}.")

            # Build field map
            fms = arcpy.FieldMappings()
            fms.addTable(out_fc_path)
            output_arc_fields: dict[str, arcpy.Field] = {f.name: f for f in arcpy.ListFields(out_fc_path) if not f.required}
            for standard_field in esz.fields.values():
                source_field: arcpy.Field | None = field_map.get(standard_field)
                if (not source_field) or (esz_nguid_method == NAM.NULL and standard_field == nguid_esz_f):
                    # If no source field was provided, or if the current field is NGUID but the NULL method was selected, omit from field mappings
                    continue
                current_field_message_detail: str = f"\n\tstandard_field.name = '{source_field.name}'\n\tsource_field.name = '{source_field.name}'"
                fm = arcpy.FieldMap()
                try:
                    fm.addInputField(out_fc_path, standard_field.name)
                except Exception as exc:
                    _logger.critical(f"Failed to add standard input field to field map:{current_field_message_detail}", exc_info=exc)
                    raise exc
                try:
                    fm.addInputField(source_fc, source_field.name)
                except Exception as exc:
                    _logger.critical(f"Failed to add source input field to field map:{current_field_message_detail}", exc_info=exc)
                    raise exc
                try:
                    fm.outputField = output_arc_fields[standard_field.name]
                except Exception as exc:
                    _logger.critical("Failed to add output field to field map:{current_field_message_detail}", exc_info=exc)
                    raise exc
                try:
                    fms.addFieldMap(fm)
                except Exception as exc:
                    _logger.critical(f"Failed to add field map to field mappings:{current_field_message_detail}", exc_info=exc)
                    raise exc
            _logger.debug(f"Populating '{esz.name}' using field mappings: {fms.exportToString()}")

            # Append source features to standard feature class
            append_result: arcpy.Result = arcpy.management.Append(
                inputs=source_fc,
                target=out_fc_path,
                schema_type="NO_TEST",
                field_mapping=fms
            )
        except Exception as exc:
            self.messenger.addErrorMessage(f"Failed to create and populate '{esz.name}'.", level=logging.ERROR, exc=exc)
            arcpy.management.Delete(self.str_path_to(esz))
        else:
            self.messenger.addMessage(f"Created and populated {esz.name}.")
            result_paths[esz] = Path(append_result.getOutput(0))

        #######################
        #### Assign NGUIDs ####
        #######################
        if assign_sequential_nguids:
            for fc in (ems, fire, law):
                if fc not in result_paths:
                    # Indicates that the generation of fc failed
                    _logger.warning(f"Not assigning NGUIDs to {fc.name} because it wasn't created.")
                    continue
                self.assign_nguids(fc, NAM.SEQUENTIAL)
                _logger.info(f"Assigned NGUIDs to {fc.name}.")
        else:
            _logger.info("Not assigning NGUIDs to ESB feature classes.")

        match (esz_nguid_method, convert_nguids, esz in result_paths):
            case (None, _, _):
                pass
            case (_, _, False):
                self.messenger.addWarningMessage(f"Not assigning NGUIDs to {esz.name} because it wasn't created.")
            case (NAM.NGUID, _, True):
                self.assign_nguids(esz, NAM.NGUID, convert_format=convert_nguids, overwrite=True)
            case (_, True, True):
                self.messenger.addWarningMessage(f"NGUID format conversion cannot be used with method '{esz_nguid_method}'; not assigning NGUIDS to {esz.name}.")
            case (NAM.LOCAL | NAM.SEQUENTIAL, False, True):
                self.assign_nguids(esz, NAM.LOCAL, overwrite=True)
            case (NAM.COPY | NAM.NULL, False, True):
                pass  # No action needed
            case (_, True, _):
                self.messenger.addWarningMessage(f"Invalid NGUID method '{esz_nguid_method}'; not assigning NGUIDS to {esz.name}.")
            case _:
                self.messenger.addWarningMessage(f"Unhandled combination of 'esz_nguid_method', 'convert_nguids', 'esz in result_paths': '{esz_nguid_method}', '{convert_nguids}', '{esz in result_paths}'. This is a bug. Please report to the developers. NGUIDs will not be assigned to {esz.name}; please use the Assign NGUID Field tool as a workaround.", level=logging.CRITICAL)

        ###########################################
        #### Return paths to results (or None) ####
        ###########################################
        return result_paths.get(ems), result_paths.get(fire), result_paths.get(law), result_paths.get(esz)

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.NGUID],
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      convert_format: bool = False,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.NGUID],
                      *,
                      convert_format: bool = False,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.LOCAL],
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      overwrite: bool = False,
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.LOCAL],
                      *,
                      overwrite: bool = False,
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.SEQUENTIAL],
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    @overload
    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: Literal[NAM.SEQUENTIAL],
                      *,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int: ...

    def assign_nguids(self,
                      feature_class: NG911FeatureClass,
                      assign_method: NAM,
                      nguid_field_name: Optional[str] = None,
                      agency_id_field_name: Optional[str] = None,
                      local_id_field_name: Optional[str] = None,
                      feature_class_name: Optional[str] = None,
                      *,
                      convert_format: bool = False,
                      overwrite: bool = False,
                      update_local_id_field: bool = True
    ) -> int:
        """
        Assigns new NGUIDs to *feature_class* (or, if supplied,
        *feature_class_name*) in this geodatabase using *assign_method*. If
        *assign_method* is ``NGUID``, this method can optionally convert
        existing NGUIDs from the format used in Standards v2.2 to the format
        used in Standards v3.

        Valid arguments for *assign_method* are:

        - ``NGUID``: Updates (and optionally converts) existing NGUID, changing
          the agency ID if the value of that field is inconsistent with the
          existing NGUID
        - ``LOCAL``: Computes NGUID based on the Local_ID field
        - ``SEQUENTIAL``: Computes NGUID based on a counter, starting from 1

        :param feature_class: NG911 standard feature class for which NGUIDs
            will be computed
        :param assign_method: Method to use to compute new NGUIDs
        :param nguid_field_name: Name of the NGUID field; should only be
            provided if *feature_class* is nonstandard; default None
        :param agency_id_field_name: Name of the agency ID field; should only
            be provided if *feature_class* is nonstandard; default None
        :param local_id_field_name: Name of the Local_ID field; should only be
            provided if *feature_class* is nonstandard; default None
        :param feature_class_name: Name of the target feature class in this
            geodatabase if the name is nonstandard; default None
        :param convert_format: Whether to convert existing NGUIDs from the
            format used in Standards v2.2 to that used in v3; only allowed if
            *assign_method* is :attr:`NAM.NGUID`; default False
        :param overwrite: If True, compute and overwrite NGUIDs for all
            features; if False, compute and set NGUIDs only for features where
            the NGUID attribute is null or a blank string; default False
        :param update_local_id_field: Whether update the local ID field if it
            would otherwise be out of sync with the NGUID; **ignored** for
            *assign_method* ``LOCAL``; default True
        :return: Number of features with new NGUIDs assigned
        """

        nguid_field_name: str = nguid_field_name or feature_class.unique_id.name
        agency_id_field_name: str = agency_id_field_name or config.fields.agency_id.name
        local_id_field_name: str = local_id_field_name or config.fields.local_id.name
        target: Self | Path = self.gdb_path / feature_class_name if feature_class_name else self

        if convert_format and assign_method != NAM.NGUID:
            raise ValueError("'convert_format' may only be True when using 'assign_method' of 'NGUID'.")
        # if update_local_id_field and assign_method == NAM.LOCAL:
        #     self.messenger.addWarningMessage("'update_local_id_field is ignored when using 'assign_method' of 'LOCAL'.")

        builder: NGUIDBuilder
        # noinspection PyUnreachableCode
        match assign_method:
            case NAM.NGUID:
                builder = NGUIDFromNGUIDBuilder(target, feature_class, overwrite, nguid_field_name, agency_id_field_name, convert_format, update_local_id_field, messenger=self.messenger)
            case NAM.LOCAL:
                builder = NGUIDFromLocalIDBuilder(target, feature_class, overwrite, nguid_field_name, agency_id_field_name, local_id_field_name, messenger=self.messenger)
            case NAM.SEQUENTIAL:
                builder = SequentialNGUIDBuilder(target, feature_class, overwrite, nguid_field_name, agency_id_field_name, update_local_id_field, local_id_field_name, messenger=self.messenger)
            case NAM.COPY | NAM.NULL:
                return 0  # Do nothing
            case _:
                raise ValueError(f"Unknown assign_method '{assign_method}'.")

        return builder.assign()


class NG911SessionWithProduct(NG911Session):
    """
    Subclass of :class:`NG911Session` to be used as a base class for further
    subclasses that generate output outside the :term:`NG911 geodatabase`.

    When subclassed, a string argument for *destination_prefix* is expected.
    """

    destination_prefix: ClassVar[str]

    def __init_subclass__(cls, destination_prefix: str, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.destination_prefix: str = destination_prefix

    def __init__(self,
                 workspace: PathLike[str] | str,
                 respect_submit: bool,
                 use_edit_session: bool = False,
                 messenger: Optional[GPMessenger] = None,
                 destination: Optional[PathLike[str] | str] = None,
                 **env_kwargs):
        super().__init__(workspace, respect_submit, use_edit_session, messenger, **env_kwargs)

        destination = Path(destination) if destination is not None else self.gdb_path.parent / self._create_destination_name()
        if destination.exists():
            raise FileExistsError(destination)
        if not destination.parent.exists():
            raise FileNotFoundError(destination)
        if not destination.parent.is_dir():
            raise NotADirectoryError(destination)

        self._destination: Path = destination
        """Path to the geodatabase to which analysis output will be written."""

    @property
    def destination(self) -> str:
        return str(self._destination)

    @property
    def destination_path(self) -> Path:
        return self._destination

    def _create_destination_name(self) -> str:
        timestamp_string: str = self._init_timestamp.strftime("%Y%m%d_%H%M%S")
        return f"{self.destination_prefix}_{timestamp_string}.gdb"


@attrs.frozen
class NGUIDBuilder(ABC, _AttrsInstanceWithAllowedFields):
    """
    Abstract base class for NGUID builder classes. There should be one concrete
    subclass for each value of :class:`~NAM` (except for
    :attr:`~NAM.COPY` and :attr:`~NAM.NULL`).
    """
    assign_method: ClassVar[NAM]

    _target: NG911Session | PathLike[str] | str = attrs.field()
    # _target_str: str = attrs.field(init=False, default=attrs.Factory(lambda self: self._target.__fspath__() if isinstance(self._target, PathLike) else str(self._target), True))
    feature_class: NG911FeatureClass = attrs.field(validator=attrs.validators.instance_of(NG911FeatureClass))
    __ng911_allowed_fields__: FrozenList[str] = attrs.field(init=False, default=attrs.Factory(lambda self: FrozenList([f.name for f in arcpy.ListFields(self.target_fc)]), True))
    overwrite: bool = attrs.field(default=False, validator=attrs.validators.instance_of(bool))
    nguid_field_name: str = attrs.field(default=attrs.Factory(lambda self: self.feature_class.unique_id.name, True), validator=validate_field_exists)
    agency_id_field_name: str = attrs.field(default=attrs.Factory(lambda: config.fields.agency_id.name), validator=validate_field_exists)
    convert_format: bool = attrs.field(default=False, validator=attrs.validators.instance_of(bool))

    messenger: GPMessenger = attrs.field(
        kw_only=True,
        default=attrs.Factory(lambda self: self.session.messenger if self.session else FallbackGPMessenger(), True)
    )
    editor: arcpy.da.Editor | DummyEditor = attrs.field(
        kw_only=True,
        default=attrs.Factory(lambda self: self.session.editor if self.session else DummyEditor(), True)
    )
    # session: NG911Session | None = attrs.field(
    #     init=False,
    #     default=attrs.Factory(lambda self: self.target if isinstance(self.target, NG911Session) else None, True)
    # )
    # target: Path = attrs.field(
    #     init=False,
    #     default=attrs.Factory(lambda self: self.session.path_to(self.feature_class) if self.session else Path(self._target), True)
    # )
    # parse_nguid: Callable[[str], NGUID] = attrs.field(
    #     init=False,
    #     default=attrs.Factory(lambda self: NGUID.from_v2_string if self.convert_format else NGUID.from_string, True)
    # )

    @property
    @abstractmethod
    def cursor_fields(self) -> tuple[str, str] | tuple[str, str, str]:
        """Returns the fields used by the ``UpdateCursor``."""
        ...

    @property
    def session(self) -> NG911Session | None:
        """Returns the :class:`NG911Session` passed during initialization if
        one was passed, otherwise returns ``None``."""
        return self._target if isinstance(self._target, NG911Session) else None

    @property
    def target_fc(self) -> Path:
        """Returns the path to the feature class to be updated."""
        return self.session.path_to(self.feature_class) if self.session else Path(self._target)

    @property
    def target_fc_str(self) -> str:
        """Returns the path to the feature class to be updated as a string."""
        return self.target_fc.__fspath__()

    @property
    def parse_nguid(self) -> Callable[[str], NGUID]:
        return NGUID.from_v2_string if self.convert_format else NGUID.from_string

    @property
    def _where_clause(self) -> str | None:
        """Returns either a where-clause or ``None`` based on the builder's
        :attr:`overwrite` and :attr:`nguid_field_name`. The result can be
        passed to ArcPy functions that take a where-clause."""
        return None if self.overwrite else f"{self.nguid_field_name} IS NULL OR {self.nguid_field_name} = ''"

    @property
    def _update_cursor(self) -> arcpy.da.UpdateCursor:
        return arcpy.da.UpdateCursor(self.target_fc_str, self.cursor_fields, self._where_clause)

    @abstractmethod
    def assign(self) -> int:
        """
        Assigns NGUID attributes to the target feature class.

        :return: The number of features updated
        :rtype: int
        """
        ...


def _validate_local_id_field_if_needed(instance: "NGUIDFromNGUIDBuilder | SequentialNGUIDBuilder", attribute: "attrs.Attribute[Optional[str]]", value: str | None) -> None:
    """
    ``attrs``-compatible validator intended for use specifically with
    subclasses of :class:`NGUIDBuilder` that can write to a local ID field.
    Intended to ensure one of the following:

    - Local ID should **not** be modified and a local ID field name is **not**
      provided
    - Local ID **should** be modified, a local ID field name **is** provided,
      and that field **exists**
    """
    if not isinstance(value, str | None):
        # If type is wrong, raise an error
        raise TypeError("Expected 'value' to be of type str or None.")
    elif not instance.update_local_id_field and value is None:
        # If field is NOT needed and NOT provided, return without an error
        return
    elif not instance.update_local_id_field and value is not None:
        # If field is NOT needed but IS provided, raise an error
        raise TypeError("If 'update_local_id_field' is False, then 'local_id_field_name' must not be specified.")
    elif instance.update_local_id_field and not value:
        # If field IS needed but is NOT provided, raise an error
        # If value is a string, raise a ValueError, otherwise, raise a TypeError
        raise (ValueError if isinstance(value, str) else TypeError)("If 'update_local_id_field' is True, then 'local_id_field_name' must be specified.")
    elif instance.update_local_id_field:
        # If field IS needed and IS provided (implied if an error wasn't raised above), ensure that the field indeed exists
        validate_field_exists(instance, attribute, value)
    else:
        # This shouldn't happen, but if it does, make it clear that it was unexpected
        _logger.fatal("\n\t".join([
            "Case not accounted for in _validate_local_id_field_if_needed.",
            f"instance: {type(instance).__qualname__} = {instance!r}",
            f"attribute: {type(attribute).__qualname__} = {attribute!r}",
            f"value: {type(value).__qualname__} = {value!r}"
        ]))
        raise RuntimeError(
            "Case not accounted for. This is a bug; please submit a crash report to the developers.\n"
            f"Instance: {instance}\n"
            f"Attribute: {attribute}\n"
            f"Value: {value}"
        )


@attrs.frozen
class NGUIDFromNGUIDBuilder(NGUIDBuilder):

    assign_method: ClassVar[NAM] = NAM.NGUID

    update_local_id_field: bool = attrs.field(default=True, validator=attrs.validators.instance_of(bool))
    local_id_field_name: Optional[str] = attrs.field(
        default=attrs.Factory(
            lambda self: config.fields.local_id.name if self.update_local_id_field else None,
            True
        ),
        validator=_validate_local_id_field_if_needed
    )

    @property
    def cursor_fields(self) -> tuple[str, str] | tuple[str, str, str]:
        if self.update_local_id_field:
            return self.nguid_field_name, self.agency_id_field_name, self.local_id_field_name
        else:
            return self.nguid_field_name, self.agency_id_field_name

    def assign(self) -> int:
        records_updated: int = 0
        failures: dict[type[Exception], int] = {}
        self.editor.startEditing()
        with self._update_cursor as uc:
            for row in uc:
                nguid, agency_id = row[:2]
                try:
                    old_nguid_object = self.parse_nguid(nguid)
                    new_nguid_object = NGUID(self.feature_class.name, old_nguid_object.local_id, agency_id)
                    new_nguid = str(new_nguid_object)
                    if new_nguid == nguid:
                        continue
                    elif self.update_local_id_field:
                        uc.updateRow((new_nguid, agency_id, new_nguid_object.local_id))
                    else:
                        uc.updateRow((new_nguid, agency_id))
                except Exception as exc:
                    e_type = type(exc)
                    if e_type not in failures:
                        failures[e_type] = 1
                    else:
                        failures[e_type] += 1
                    if failures[e_type] == 1:
                        # Only show the first exception of each type in the GP messages to help the user fix the problem(s) without flooding the messages
                        self.messenger.addWarningMessage(f"Error building NGUID from existing NGUID '{nguid}' and agency ID '{agency_id}':\n{''.join(format_exception(exc))}")
                    elif failures[e_type] <= 3:
                        # If there are more than 3 exceptions of any given type, don't flood the log file
                        _logger.warning(f"Error building NGUID from existing NGUID '{nguid}' and agency ID '{agency_id}'.", exc_info=exc)
                else:
                    records_updated += 1
        self.editor.stopEditing(True)

        if failures:
            self.messenger.addWarningMessage(f"NGUID computation failed for {sum(failures.values())} record(s). See messages above for information and/or log file for further details.")

        self.messenger.addMessage(f"Updated {records_updated} NGUID(s) in the feature class at '{self.target_fc_str}'.")
        return records_updated


@attrs.frozen
class NGUIDFromLocalIDBuilder(NGUIDBuilder):

    assign_method: ClassVar[NAM] = NAM.LOCAL

    local_id_field_name: str = attrs.field(
        default=attrs.Factory(lambda: config.fields.local_id.name),
        validator=validate_field_exists
    )

    convert_format: Literal[False] = attrs.field(init=False, default=False, validator=attrs.validators.in_({False}))

    @property
    def cursor_fields(self) -> tuple[str, str, str]:
        return self.nguid_field_name, self.agency_id_field_name, self.local_id_field_name

    def assign(self) -> int:
        records_updated: int = 0
        missing_local_id: int = 0
        failures: dict[type[Exception], int] = {}
        self.editor.startEditing()
        with self._update_cursor as uc:
            for nguid, agency_id, local_id in uc:
                if not local_id:
                    missing_local_id += 1
                    continue
                try:
                    new_nguid = str(NGUID(self.feature_class.name, local_id, agency_id))
                    if new_nguid == nguid:
                        continue
                    uc.updateRow((new_nguid, agency_id, local_id))
                except Exception as exc:
                    e_type = type(exc)
                    if e_type not in failures:
                        failures[e_type] = 1
                    else:
                        failures[e_type] += 1
                    if failures[e_type] == 1:
                        # Only show the first exception of each type in the GP messages to help the user fix the problem(s) without flooding the messages
                        self.messenger.addWarningMessage(f"Error building NGUID from agency ID '{agency_id}' and local ID '{local_id}':\n{''.join(format_exception(exc))}")
                    elif failures[e_type] <= 3:
                        # If there are more than 3 exceptions of any given type, don't flood the log file
                        _logger.warning(f"Error building NGUID from agency ID '{agency_id}' and local ID '{local_id}'.", exc_info=exc)
                else:
                    records_updated += 1
        self.editor.stopEditing(True)

        if failures:
            self.messenger.addWarningMessage(f"NGUID computation failed for {sum(failures.values())} record(s). See messages above for information and/or log file for further details.")

        self.messenger.addMessage(f"Updated {records_updated} NGUID(s) in the feature class at '{self.target_fc_str}'.")
        return records_updated




@attrs.frozen
class SequentialNGUIDBuilder(NGUIDBuilder):
    assign_method: ClassVar[NAM] = NAM.SEQUENTIAL

    update_local_id_field: bool = attrs.field(default=True, validator=attrs.validators.instance_of(bool))
    local_id_field_name: Optional[str] = attrs.field(
        default=attrs.Factory(
            lambda self: config.fields.local_id.name if self.update_local_id_field else None,
            True
        ),
        validator=_validate_local_id_field_if_needed
    )

    convert_format: Literal[False] = attrs.field(init=False, default=False, validator=attrs.validators.in_({False}))

    @property
    def cursor_fields(self) -> tuple[str, str] | tuple[str, str, str]:
        if self.update_local_id_field:
            return self.nguid_field_name, self.agency_id_field_name, self.local_id_field_name
        else:
            return self.nguid_field_name, self.agency_id_field_name

    def assign(self) -> int:
        skip_sequence_numbers: set[int] = set()
        sequence_number: int = 1
        records_updated: int = 0

        if not self.overwrite:
            # If leaving some NGUIDs intact, see if any of them have integers
            # as the local ID part; make sure we skip those numbers to maintain
            # NGUID uniqueness
            with arcpy.da.SearchCursor(self.target_fc_str, self.nguid_field_name) as sc:
                for nguid, in sc:
                    try:
                        nguid_object: NGUID = self.parse_nguid(nguid)
                        local_id_integer: int = int(nguid_object.local_id)
                    except:
                        continue
                    else:
                        skip_sequence_numbers.add(local_id_integer)

        self.editor.startEditing()
        with self._update_cursor as uc:
            for row in uc:
                agency_id = row[1]
                while sequence_number in skip_sequence_numbers:
                    sequence_number += 1
                new_nguid_object = NGUID(self.feature_class.name, str(sequence_number), agency_id)
                if self.update_local_id_field:
                    uc.updateRow((str(new_nguid_object), agency_id, new_nguid_object.local_id))
                else:
                    uc.updateRow((str(new_nguid_object), agency_id))
                records_updated += 1
                sequence_number += 1
        self.editor.stopEditing(True)

        self.messenger.addMessage(f"Updated {records_updated} NGUID(s) in the feature class at '{self.target_fc_str}'.")

        return records_updated


class FallbackGPMessenger(GPMessenger):
    """Provides fallback functionality when no geoprocessing messenger is
    available."""

    def addMessage(self, message: str) -> None:
        print(message)

    def addWarningMessage(self, message: str) -> None:
        print(f"WARNING: {message}")

    def addErrorMessage(self, message: str) -> None:
        print(f"ERROR: {message}")

    def addIDMessage(self, message_type: Literal["ERROR", "INFORMATIVE", "WARNING"], message_ID: int, add_argument1: Optional[str | int | float] = None, add_argument2: Optional[str | int | float] = None) -> None:
        id_message = arcpy.GetIDMessage(message_ID)
        message_args = tuple(x for x in (add_argument1, add_argument2) if x)
        message = id_message % message_args
        # noinspection PyUnreachableCode
        match message_type:
            case "INFORMATIVE":
                self.addMessage(message)
            case "WARNING":
                self.addWarningMessage(message)
            case "ERROR":
                self.addErrorMessage(message)
            case _:
                raise ValueError("Invalid 'message_type'.")

    def addGPMessages(self) -> None:
        pass
