from __future__ import annotations

import logging
import re
from collections.abc import Collection
from enum import StrEnum, auto, IntEnum
from functools import cache, partial
from pathlib import Path
from typing import Self, Union, Sequence, Optional, ClassVar, Literal, Final, overload, TypeAlias, TYPE_CHECKING

import arcpy
import attrs
import numpy as np
import pandas as pd
from numpy.typing import NDArray, ArrayLike
# noinspection PyProtectedMember
from pandas._typing import Scalar#, TakeIndexer, ArrayLike, PositionalIndexer, ScalarIndexer, SequenceIndexer, Dtype, type_t
from pandas.core.accessor import register_dataframe_accessor, register_series_accessor
# from pandas.core.arrays import ExtensionArray
# from pandas.core.arrays.base import ExtensionArrayT, ExtensionArraySupportsAnyAll
# from pandas.core.dtypes.base import register_extension_dtype, ExtensionDtype

from .config_dataclasses import NG911Field, NG911FeatureClass
from .misc import Parity, MixedZeroParityError, calculate_parity, FeatureAttributeErrorCode, strjoin, OddEvenParity, quote, ArcFieldTypeKeyword, indent
from .session import config

if TYPE_CHECKING:
    from .gdbsession import NG911Session


_logger = logging.getLogger(__name__)

_oe_set: Final[frozenset[Parity]] = frozenset((Parity.ODD, Parity.EVEN))
"""A ``frozenset`` of :attr:`Parity.ODD` and :attr:`Parity.EVEN`, intended for
use like ``if parity in _oe_set: ...``."""


class InconsistentParityError(Exception):
    pass


class InvertedAddressRangeError(Exception):
    def __init__(self, from_addr: int, to_addr: int):
        if from_addr <= to_addr:
            raise ValueError(f"Attempted to instantiate InvertedAddressRangeError with increasing range.")
        else:
            super().__init__(f"Address range from {from_addr} to {to_addr} is inverted.")


class Directionality(StrEnum):
    """Represents the directionality of an address range."""

    INCREASING = "Increasing"
    """Represents an address range where the to-address is higher than the from-address."""

    DECREASING = "Decreasing"
    """Represents an address range where the to-address is lower than the from-address."""

    SINGLE = "Single"
    """Represents an address range where the from-address and to-address are equal and non-zero."""

    ZERO = "Zero"
    """Represents an address range where the parity is :attr:`Parity.ZERO`."""


class MergeMethod(IntEnum):
    NONE = 0
    """Ranges cannot be merged."""

    DROP_FIRST = auto()
    """The first range has a parity of :attr:`~Parity.ZERO`, so the second
    range can be returned as-is."""

    DROP_SECOND = auto()
    """The second range has a parity of :attr:`~Parity.ZERO`, so the first
    range can be returned as-is."""

    ZEROS = auto()
    """Both ranges have parities of :attr:`~Parity.ZERO`, so a
    :meth:`AddressRange.zero <zero range>` can be returned."""

    TWO_SIDES = auto()
    """One range is odd, the other is even, and they can be returned as a range
    with :attr:`~Parity.BOTH` parity."""

    ADJACENT = auto()
    """The ranges have the same non-zero parity and can be returned as a range
    with the :attr:`~AddressRange.from_addr` of one input and the
    :attr:`~AddressRange.to_addr` of the other."""

    SPLIT_FIRST = auto()
    """The first range :attr:`~Parity.BOTH` parity and the second is odd or
    even. If the first is split into odd and even, the second can be merged
    with one of the ranges resulting from the split. The consolidation will
    result in two ranges, one odd and one even."""

    SPLIT_SECOND = auto()
    """The second range :attr:`~Parity.BOTH` parity and the first is odd or
    even. If the second is split into odd and even, the first can be merged
    with one of the ranges resulting from the split. The consolidation will
    result in two ranges, one odd and one even."""



@attrs.frozen
class AddressRange:
    from_addr: int = attrs.field(validator=attrs.validators.ge(0))
    to_addr: int = attrs.field(validator=attrs.validators.ge(0))
    parity: Parity = attrs.field(validator=attrs.validators.instance_of(Parity))
    suppress_errors: bool = attrs.field(default=False)

    __match_args__ = ("low", "high", "parity")

    def __attrs_post_init__(self):
        if self.suppress_errors:
            return
        else:
            self._validate()

    @cache
    def _validate(self) -> bool:
        if self.parity == Parity.ZERO and not (self.from_addr == self.to_addr == 0):
            raise MixedZeroParityError(f"Parity was set to ZERO, but from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        if self.parity != Parity.ZERO and 0 in (self.from_addr, self.to_addr):
            raise MixedZeroParityError(f"Parity was NOT set to ZERO, but from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        if self.parity != Parity.BOTH and calculate_parity((self.from_addr, self.to_addr), True) == Parity.BOTH:
            raise InconsistentParityError(f"Parity was set to {self.parity} but should be BOTH; from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        if self.parity in (Parity.ODD, Parity.EVEN) and self.parity != calculate_parity([self.from_addr, self.to_addr], True):
            raise InconsistentParityError(f"Parity was set to {self.parity}, but from_addr = {self.from_addr} and to_addr = {self.to_addr}.")
        # if self.from_addr > self.to_addr:
        #     raise InvertedAddressRangeError(self.from_addr, self.to_addr)
        return True

    @classmethod
    def auto_fix_directionality(cls, from_addr: int, to_addr: int, parity: Parity, suppress_errors: bool = False) -> Self:
        if from_addr > to_addr:
            return cls(to_addr, from_addr, parity, suppress_errors)
        else:
            return cls(from_addr, to_addr, parity, suppress_errors)

    @classmethod
    def from_set(cls, set_: set[int], parity: Optional[Parity] = None) -> Self:
        """
        Initializes and returns an ``AddressRange`` from a ``set`` of integers.
        Parity is automatically determined unless provided.

        :param set_: Set of integers representing addresses within the range
        :type set_: set[int]
        :param parity: If provided but *set_* does not match it, a
            ``ValueError`` is raised
        :type parity: Optional[Parity]
        :return: Resulting ``AddressRange`` instance
        :rtype: AddressRange
        """
        from_addr = min(set_)
        to_addr = max(set_)
        if parity is None:
            parity = calculate_parity(set_, True)

        match parity:
            case Parity.ZERO if len(set_) and set_ != {0}:
                raise ValueError("The given set is inconsistent with 'ZERO' parity.")
            case Parity.ODD | Parity.EVEN if (
                (calculate_parity((from_addr, to_addr), True) != parity)
                or (set_ != set(range(from_addr, to_addr + 1, 2)))
            ):
                raise ValueError("The given set is inconsistent with the given parity and/or does not include all possible values.")
            case Parity.BOTH if set_ != set(range(from_addr, to_addr + 1)):
                raise ValueError("The given set is inconsistent with 'BOTH' parity.")

        return cls(from_addr, to_addr, parity)

    @classmethod
    def zero(cls) -> Self:
        return cls(0, 0, Parity.ZERO)

    @property
    @cache
    def is_valid(self) -> bool:
        try:
            return self._validate()
        except:
            return False

    @property
    @cache
    def low(self) -> int:
        return min(self.from_addr, self.to_addr)

    @property
    @cache
    def high(self) -> int:
        return max(self.from_addr, self.to_addr)

    @property
    @cache
    def directionality(self) -> Directionality:
        if self.from_addr == self.to_addr == 0:
            return Directionality.ZERO
        elif self.from_addr == self.to_addr:
            return Directionality.SINGLE
        elif self.to_addr > self.from_addr:
            return Directionality.INCREASING
        elif self.to_addr < self.from_addr:
            return Directionality.DECREASING
        else:
            raise RuntimeError(strjoin([
                "Could not compute directionality.",
                f"from_addr = {self.from_addr}",
                f"to_addr = {self.to_addr}",
                f"parity = {self.parity}",
                f"suppress_errors = {self.suppress_errors}",
                f"is_valid = {self.is_valid}"
            ], "\n\t"))

    @property
    @cache
    def directionality_is_valid(self) -> bool:
        """Returns ``True`` if the instance's :attr:`directionality` is
        :attr:`~Directionality.INCREASING`, :attr:`~Directionality.SINGLE`, or
        :attr:`~Directionality.ZERO`. Otherwise, returns ``False``."""
        return self.directionality in {
            Directionality.INCREASING,
            Directionality.SINGLE,
            Directionality.ZERO
        }

    @property
    @cache
    def as_increasing(self) -> Self:
        if self.from_addr == self.low and self.to_addr == self.high:
            return self
        else:
            return AddressRange(self.to_addr, self.from_addr, self.parity, self.suppress_errors)

    @cache
    def sides(self, suppress_errors: Optional[bool] = None) -> tuple[Self] | tuple[Self, Self]:
        suppress_errors = suppress_errors or self.suppress_errors
        if self.parity == Parity.BOTH:
            from_odd = self.low if self.low & 1 else self.low + 1
            to_odd = self.high if self.high & 1 else self.high - 1
            odd_range = __class__(from_odd, to_odd, Parity.ODD, suppress_errors) if self.low <= from_odd <= to_odd <= self.high else None
            from_even = self.low if not self.low & 1 else self.low + 1
            to_even = self.high if not self.high & 1 else self.high - 1
            even_range = __class__(from_even, to_even, Parity.EVEN, suppress_errors) if self.low <= from_even <= to_even <= self.high else None
            if odd_range and even_range:
                return odd_range, even_range
            elif r := (odd_range or even_range):
                return (r, )
            else:
                raise RuntimeError(f"Could not split sides of range: {self}")
        else:
            return (self, )

    def to_set(self) -> set[int]:
        """Returns a ``set`` containing all integers of appropriate parity
        between ``from_addr`` and ``to_addr``, inclusive. If parity is zero, an
        empty set is returned."""
        if self.parity == Parity.ZERO:
            return set()
        if self.parity in (Parity.ODD, Parity.EVEN):
            return set(range(self.from_addr, self.to_addr + 1, 2))
        else:
            return set(range(self.from_addr, self.to_addr + 1))

    def to_array(self) -> np.ndarray:
        if self.parity == Parity.ZERO:
            return np.ndarray((0, ), dtype=np.uint32)
        elif self.parity in (Parity.ODD, Parity.EVEN):
            return np.arange(self.from_addr, self.to_addr + 1, 2, dtype=np.uint32)
        else:
            return np.arange(self.from_addr, self.to_addr + 1, dtype=np.uint32)

    def overlaps(self, other: Self) -> bool:
        return bool(self & other)

    def __contains__(self, item: Union[int, Self]) -> bool:
        self._validate()
        if self.parity == Parity.ZERO:
            return False
        elif isinstance(item, int):
            if not self.from_addr <= item <= self.to_addr:
                return False
            elif self.parity in (Parity.ODD, Parity.EVEN):
                return calculate_parity(item) == self.parity
            else:
                return True
        elif isinstance(item, AddressRange):
            item._validate()
            if item.parity == Parity.ZERO:
                return False
            elif Parity.BOTH in (self.parity, item.parity) or self.parity == item.parity:
                return self.from_addr <= item.from_addr and self.to_addr >= item.to_addr
            else:
                return False
        else:
            raise TypeError(f"Expected int or AddressRange, got '{type(item)}'.")

    def __and__(self, other: Self) -> Self:
        self._validate()
        other._validate()
        if self == other:
            return __class__(self.from_addr, self.to_addr, self.parity)
        elif self.parity == Parity.ZERO or other.parity == Parity.ZERO:
            return self.zero()
        elif self.parity != other.parity and Parity.BOTH not in (self.parity, other.parity):
            # One is ODD, the other is EVEN, so no overlap
            return self.zero()
        elif self.from_addr > other.to_addr or self.to_addr < other.from_addr:
            return self.zero()

        lower_bound: int = other.from_addr if self.from_addr <= other.from_addr else self.from_addr
        upper_bound: int = other.to_addr if self.to_addr >= other.to_addr else self.to_addr

        if self.parity == other.parity == Parity.BOTH:
            # Both are BOTH
            return __class__(lower_bound, upper_bound, Parity.BOTH)
        else:
            lower_parity: Parity = calculate_parity(lower_bound)
            upper_parity: Parity = calculate_parity(upper_bound)

        if self.parity == Parity.BOTH:
            # other is ODD or EVEN
            lower_bound += 1 if lower_parity != other.parity else 0
            upper_bound -= 1 if upper_parity != other.parity else 0
            return __class__(lower_bound, upper_bound, other.parity)
        else:
            # self is ODD or EVEN, other is BOTH or same as self
            lower_bound += 1 if lower_parity != self.parity else 0
            upper_bound -= 1 if upper_parity != self.parity else 0
            return __class__(lower_bound, upper_bound, self.parity)

    def _merge_adjacent(self, other: Self) -> Self:
        ...

    def _merge_overlapping(self, other: Self) -> Self:
        ...

    def _merge_two_sides(self, other: Self) -> Self:
        ...

    def _split_and_merge(self, other: Self) -> Self:
        ...

    def _merge_method(self, other: Self) -> MergeMethod:
        parity_set: frozenset[Parity] = frozenset((self.parity, other.parity))
        self_i: AddressRange = self.as_increasing
        other_i: AddressRange = other.as_increasing

        if self_i.parity == other_i.parity == Parity.ZERO:####
            return MergeMethod.ZEROS
        elif other_i.parity == Parity.ZERO:####
            return MergeMethod.DROP_FIRST
        elif self_i.parity == Parity.ZERO:####
            return MergeMethod.DROP_SECOND
        elif (####
            parity_set == _oe_set####
            and abs(self_i.from_addr - other_i.from_addr) == 1####
            and abs(self_i.to_addr - other_i.to_addr) == 1####
        ):####
            return MergeMethod.TWO_SIDES
        elif (####
            (self_i.parity == other_i.parity == Parity.BOTH####
                and (self_i.to_addr + 1 == other_i.from_addr or other_i.to_addr + 1 == self_i.from_addr))####
            or (not (parity_set - _oe_set)####
                and (self_i.to_addr + 2 == other_i.from_addr or other_i.to_addr + 2 == self_i.from_addr))####
        ):####
            return MergeMethod.ADJACENT
        elif (####
            Parity.BOTH in parity_set####
            and bool(parity_set & _oe_set)####
        ):####
            # One is BOTH; one is ODD or EVEN
            return MergeMethod.SPLIT_FIRST if self_i.parity == Parity.BOTH else MergeMethod.SPLIT_SECOND
        else:
            return MergeMethod.NONE

    def __or__(self, other: Self) -> Self | MultiAddressRange:
        """
        Returns the union of two ranges.
        """
        # Letters in variable names mean:
        # i = [i]ncreasing
        # l = [l]ow
        # h = [h]igh
        # p = [p]arity
        # b = [b]oth
        self._validate()
        other._validate()
        self_i: AddressRange = self.as_increasing
        other_i: AddressRange = other.as_increasing


        match self_i, other_i:
            case AddressRange(parity=Parity.ZERO), AddressRange(parity=Parity.ZERO):
                return AddressRange.zero()
            case AddressRange(parity=Parity.ZERO), _:
                return other_i
            case _, AddressRange(parity=Parity.ZERO):
                return self_i
            case AddressRange(l1, h1, p1), AddressRange(l2, h2, p2) if abs(l1 - l2) == abs(h1 - h2) == 1 and {p1, p2} == _oe_set:
                return AddressRange(min(l1, l2), max(h1, h2), Parity.BOTH)
            case AddressRange(l1, h1, p1), AddressRange(l2, h2, p2) if (
                    (p1 == p2 == Parity.BOTH and (h1 + 1 == l2 or h2 + 1 == l1))
                    or (p1 == p2 and (h1 + 2 == l2 or h2 + 2 == l1))
            ):
                return AddressRange(min(l1, l2), max(h1, h2), p1)
            case (
                (AddressRange(lb, hb, Parity.BOTH), AddressRange(l2, h2, p2))
                | (AddressRange(l2, h2, p2), AddressRange(lb, hb, Parity.BOTH))
            ) if p2 in _oe_set:
                # Split the BOTH range
                low_p, high_p = calculate_parity((lb, hb), False)
                low_odd, low_even = (lb, lb + 1) if low_p == Parity.ODD else (lb + 1, lb)
                high_odd, high_even = (hb, hb - 1) if high_p == Parity.ODD else (hb - 1, hb)
                # Merge the O/E range with appropriate result of split
                if p2 == Parity.ODD:
                    split_odd = AddressRange(min(low_odd, l2), max(high_odd, h2), Parity.ODD)
                    split_even = AddressRange(low_even, high_even, Parity.EVEN)
                else:
                    split_odd = AddressRange(low_odd, high_odd, Parity.ODD)
                    split_even = AddressRange(min(low_even, l2), max(high_even, h2), Parity.EVEN)
                return MultiAddressRange((split_odd, split_even))
            case _:
                ...

    @property
    def details(self) -> str:
        """Returns a more detailed informational string than ``__str__()``."""
        return f"{self.from_addr}-{self.to_addr} [Parity = {self.parity}]"

    # def __str__(self) -> str:
    #     return f"{self.from_addr}-{self.to_addr} [{str(self.parity)[0]}]"

    def __bool__(self) -> bool:
        return self.parity != Parity.ZERO


class MultiAddressRange:

    def __init__(self, ranges: Optional[Collection[AddressRange]] = None):
        self._ranges: list[AddressRange] = self.consolidate(ranges) if ranges is not None else []

    @classmethod
    def consolidate(cls, ranges: Collection[AddressRange]) -> list[AddressRange]:
        fixed: list[AddressRange] = []
        for r in ranges:
            ...
            # if r.parity in {Parity.ODD, Parity.EVEN}:
            #     fixed.append(r)
            # elif r.parity == Parity.BOTH:
            #     from_parity = calculate_parity(r.from_addr)
            #     if  == Parity.ODD

    @property
    def ranges(self) -> frozenset[AddressRange]:
        return frozenset(self._ranges)

    @ranges.setter
    def ranges(self, val: Collection[AddressRange]):
        self._ranges = self.consolidate(val)


class NGUIDFormatError(ValueError):
    invalid_nguid: str
    problem: FeatureAttributeErrorCode
    _raw_details: str | None
    details: str

    def __init__(self, invalid_nguid: str, problem: FeatureAttributeErrorCode, details: Optional[str] = None, piece: Optional[Literal["LAYER", "LOCAL_ID", "AGENCY_ID"]] = None):
        self.invalid_nguid = invalid_nguid
        self.problem = problem
        self._raw_details = details
        self.piece = piece  # None indicates whole NGUID

        if self.piece:
            piece_text = {"LAYER": "Layer", "LOCAL_ID": "Local ID", "AGENCY_ID": "Agency ID"}[self.piece]
            self.details = f"'{piece_text}' part of NGUID '{invalid_nguid}' has an invalid format: {details}" if details else f"NGUID '{invalid_nguid}' has an invalid format."
        else:
            self.details = f"NGUID '{invalid_nguid}' has an invalid format: {details}" if details else f"NGUID '{invalid_nguid}' has an invalid format."

        super().__init__(f"[{self.problem}] {self.details}")

    @property
    def validation_message(self) -> str:
        """Returns a single-line string suitable for writing to a validation
        error table."""
        if self._raw_details:
            lines = self._raw_details.split("\n\t")
            return lines[0] if len(lines) == 1 else lines[0] + " | ".join(lines[1:])
        else:
            return self.details.replace("\n", " ")


@attrs.frozen
class NGUID:
    layer: str = attrs.field()
    local_id: str = attrs.field(converter=str)
    agency_id: str = attrs.field()

    LAYER_PATTERN: ClassVar[re.Pattern] = re.compile(r"[A-Za-z_]\w*")
    LOCALID_PATTERN: ClassVar[re.Pattern] = re.compile(r"[\w\(\)\[\]\{\}-]+")
    AGENCYID_PATTERN: ClassVar[re.Pattern] = re.compile(r"[A-Za-z\d][A-Za-z\d-]*(?:\.[A-Za-z\d][A-Za-z\d-]*)+")
    NGUID_PATTERN: ClassVar[re.Pattern] = re.compile(fr"{config.gdb_info.nguid_urn_prefix}:({LAYER_PATTERN.pattern}):({LOCALID_PATTERN.pattern}):({AGENCYID_PATTERN.pattern})")
    V2_LOCALID_PATTERN: ClassVar[re.Pattern] = re.compile(r"[\x20-\x39\x3b-\x3f\x41-\x7e]+")
    V2_NGUID_PATTERN: ClassVar[re.Pattern] = re.compile(fr"^({LAYER_PATTERN.pattern})_({V2_LOCALID_PATTERN.pattern})@({AGENCYID_PATTERN.pattern})$")

    def __str__(self):
        return f"{config.gdb_info.nguid_urn_prefix}:{self.layer}:{self.local_id}:{self.agency_id}"

    @classmethod
    def from_string(cls, string: str) -> Self:
        match: re.Match | None = cls.NGUID_PATTERN.match(string)
        if not match:
            raise cls._diagnose_v3(string)
        layer, local_id, agency_id = match.groups()
        if layer not in config.required_feature_class_names + config.optional_feature_class_names:
            raise NGUIDFormatError(string, "ERROR:NGUID:LAYER", f"Invalid layer '{layer}'.")
        if agency_id not in config.domains.AGENCYID.entries.keys():
            raise NGUIDFormatError(string, "ERROR:NGUID:AGENCY", f"Agency ID '{agency_id}' is not in domain '{config.domains.AGENCYID.name}'.")
        return cls(layer, local_id, agency_id)

    @classmethod
    def _diagnose_v3(cls, string: str) -> NGUIDFormatError:
        """
        Method to attempt to diagnose the specific format violation of (what
        should be) a v3 NGUID.

        :param string: The v3 NGUID string in violation
        :type string: str
        :return: Exception to analyze and/or raise
        :rtype: NGUIDFormatError
        """

        details: list[str] = []
        detail_message: str | None = None

        if cls.V2_NGUID_PATTERN.match(string):
            details.append("NGUID appears to be a v2 NGUID. Old (v2) NGUIDs need to be converted.")

        if not string.startswith(config.gdb_info.nguid_urn_prefix):
            details.append(f"NGUID should start with '{config.gdb_info.nguid_urn_prefix}'. Does this NGUID need to be converted from v2?")

        if "@" in string:
            details.append("NGUID contains an at-sign ('@'). Does this NGUID need to be converted from v2?")

        fc_name_expression: str = "|".join(config.required_feature_class_names + config.optional_feature_class_names)
        fc_underscore_localid_pattern = re.compile(f"({fc_name_expression})([-_@.,/|])?({cls.LOCALID_PATTERN.pattern})")
        if match := fc_underscore_localid_pattern.search(string):
            layer, delimiter, local_id = match.groups()
            details.append(f"NGUID appears to have the character '{delimiter}' instead of a colon (':') between the layer name '{layer}' and local ID '{local_id}'.")

        if len(details) == 1:
            detail_message = details.pop()
        elif len(details) > 1:
            detail_message = "\n\t" + "\n\t".join(details)

        return NGUIDFormatError(string, "ERROR:NGUID:FORMAT", detail_message)

    @classmethod
    def from_v2_string(cls, string: str) -> Self:
        match: re.Match | None = cls.V2_NGUID_PATTERN.match(string)
        if not match:
            details = (
                "Multiple '@' characters in v2 NGUID." if string.count("@") > 1
                else "Cannot convert from v2 NGUID containing a colon (':')." if ":" in string
                else None
            )
            raise NGUIDFormatError(string, "ERROR:NGUID:V2_FORMAT", details)
        result: Self = cls(*match.groups())
        if exc := cls.validate_string(str(result)):
            raise exc
        else:
            return result

    @classmethod
    def validate_string(cls, string: str) -> NGUIDFormatError | None:
        """
        Validates the format of an NGUID string.

        :param str string: NGUID to validate
        :return: ``None`` if the NGUID is valid, otherwise, an instance of
            :class:`NGUIDFormatError`
        :rtype: NGUIDFormatError | None
        """
        try:
            cls.from_string(string)
        except NGUIDFormatError as ex:
            return ex
        else:
            return None

    @property
    def feature_class(self) -> NG911FeatureClass:
        return config.get_feature_class_by_name(self.layer)

    @property
    def nguid_field(self) -> NG911Field:
        return self.feature_class.unique_id


# @register_extension_dtype
# class NGUIDDtype(ExtensionDtype):
#     # TODO: Finish implementation of this dtype and of the array type
#     type = NGUID
#     name = "nguid"
#     na_value = pd.NA
#
#     @classmethod
#     def construct_array_type(cls) -> type_t[ExtensionArray]:
#         return NGUIDArray
#
#     @property
#     def _is_numeric(self) -> bool:
#         return False
#
#     @property
#     def _is_boolean(self) -> bool:
#         return False
#
#
# class NGUIDArray(ExtensionArray):
#     dtype = NGUIDDtype()
#
#     def __init__(self, scalars: Sequence[NGUID | str]): ...
#
#     @classmethod
#     def _from_sequence(cls, scalars, *, dtype: Dtype | None = None, copy: bool = False):
#         return cls(scalars)
#
#     @classmethod
#     def _from_factorized(cls, values, original):
#         return super()._from_factorized(values, original)
#
#     @classmethod
#     def _from_sequence_of_strings(cls, strings, *, dtype: Dtype | None = None, copy: bool = False):
#         return super()._from_sequence_of_strings(strings, dtype=dtype, copy=copy)
#
#     @overload
#     def __getitem__(self, item: ScalarIndexer) -> Any:
#         ...
#
#     @overload
#     def __getitem__(self: ExtensionArrayT, item: SequenceIndexer) -> ExtensionArrayT:
#         ...
#
#     def __getitem__(self: ExtensionArrayT, item: PositionalIndexer) -> ExtensionArrayT | Any:
#         return super().__getitem__(item)
#
#     def __len__(self) -> int:
#         return super().__len__()
#
#     def __eq__(self, other: Any) -> ArrayLike:
#         return super().__eq__(other)
#
#     @property
#     def nbytes(self) -> int:
#         return super().nbytes
#
#     def isna(self) -> np.ndarray | ExtensionArraySupportsAnyAll:
#         return super().isna()
#
#     def take(self: ExtensionArrayT, indices: TakeIndexer, *, allow_fill: bool = False, fill_value: Any = None) -> ExtensionArrayT:
#         return super().take(indices, allow_fill=allow_fill, fill_value=fill_value)
#
#     def copy(self: ExtensionArrayT) -> ExtensionArrayT:
#         return super().copy()
#
#     @classmethod
#     def _concat_same_type(cls: type[ExtensionArrayT], to_concat: Sequence[ExtensionArrayT]) -> ExtensionArrayT:
#         return super()._concat_same_type(to_concat)
#
#
# @register_extension_dtype
# class AddressRangeDtype(ExtensionDtype):
#     # TODO: Finish implementation of this dtype and of the array type
#     _metadata = ("from_address_dtype", "to_address_dtype", "parity_dtype")
#     type = AddressRange
#     name = "addrng"
#     na_value = pd.NA
#
#     def __init__(self, address_ranges: Sequence[AddressRange]):
#         ...
#
#     @classmethod
#     def construct_array_type(cls) -> type_t[ExtensionArray]:
#         return AddressRangeArray
#
#     @property
#     def _is_numeric(self) -> bool:
#         return False
#
#     @property
#     def _is_boolean(self) -> bool:
#         return False
# # Experimental
#
#
# class AddressRangeArray(ExtensionArray):
#     dtype = AddressRangeDtype()
#
#     def __init__(self):
#         ...
#
#     @classmethod
#     def _from_sequence(cls, scalars, *, dtype: Dtype | None = None, copy: bool = False):
#         return cls(scalars)
#
#     @classmethod
#     def _from_factorized(cls, values, original):
#         return super()._from_factorized(values, original)
#
#     @classmethod
#     def _from_sequence_of_strings(cls, strings, *, dtype: Dtype | None = None, copy: bool = False):
#         return super()._from_sequence_of_strings(strings, dtype=dtype, copy=copy)
#
#     @overload
#     def __getitem__(self, item: ScalarIndexer) -> Any:
#         ...
#
#     @overload
#     def __getitem__(self: ExtensionArrayT, item: SequenceIndexer) -> ExtensionArrayT:
#         ...
#
#     def __getitem__(self: ExtensionArrayT, item: PositionalIndexer) -> ExtensionArrayT | Any:
#         return super().__getitem__(item)
#
#     def __len__(self) -> int:
#         return super().__len__()
#
#     def __eq__(self, other: Any) -> ArrayLike:
#         return super().__eq__(other)
#
#     @property
#     def nbytes(self) -> int:
#         return super().nbytes
#
#     def isna(self) -> np.ndarray | ExtensionArraySupportsAnyAll:
#         return super().isna()
#
#     def take(self: ExtensionArrayT, indices: TakeIndexer, *, allow_fill: bool = False, fill_value: Any = None) -> ExtensionArrayT:
#         return super().take(indices, allow_fill=allow_fill, fill_value=fill_value)
#
#     def copy(self: ExtensionArrayT) -> ExtensionArrayT:
#         return super().copy()
#
#     @classmethod
#     def _concat_same_type(cls: type[ExtensionArrayT], to_concat: Sequence[ExtensionArrayT]) -> ExtensionArrayT:
#         return super()._concat_same_type(to_concat)


@register_dataframe_accessor("ng911")
class NG911Accessor:
    def __init__(self, pandas_obj: pd.DataFrame):
        self._obj = pandas_obj

    def __getitem__(self, item: Union[NG911Field, Sequence[NG911Field]]) -> Union[pd.Series, pd.DataFrame]:
        indexer: Union[str, list[str]]
        if isinstance(item, Sequence):
            indexer = [f.name for f in item]
        else:
            indexer = item.name
        return self._obj[indexer]

    def address_range_left(self, suppress_errors=False, auto_fix_directionality=False) -> pd.Series:
        add_from: str = config.fields.add_l_from.name
        add_to: str = config.fields.add_l_to.name
        parity: str = config.fields.parity_l.name
        if not {add_from, add_to, parity}.issubset(self._obj.columns):
            raise KeyError(f"Columns '{add_from}', '{add_to}', '{parity}' are required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(
            lambda row:
                pd.NA if row.isna().any()
                else AddressRange.auto_fix_directionality(
                    row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
                ) if auto_fix_directionality
                else AddressRange(
                    row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
                ),
            axis=1
        )
        # ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(lambda row: AddressRange(row[add_from], row[add_to], Parity(p) if (p := row[parity]) is not pd.NA and p in parities else None, suppress_errors=True), axis=1)
        ranges.name = "$address_range_left"
        return ranges

    def address_range_right(self, suppress_errors=False, auto_fix_directionality=False) -> pd.Series:
        add_from: str = config.fields.add_r_from.name
        add_to: str = config.fields.add_r_to.name
        parity: str = config.fields.parity_r.name
        if not {add_from, add_to, parity}.issubset(self._obj.columns):
            raise KeyError(f"Columns '{add_from}', '{add_to}', '{parity}' are required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(
            lambda row:
            pd.NA if row.isna().any()
            else AddressRange.auto_fix_directionality(
                row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
            ) if auto_fix_directionality
            else AddressRange(
                row[add_from], row[add_to], Parity(p) if (p := row[parity]) in parities else pd.NA, suppress_errors=suppress_errors
            ),
            axis=1
        )
        # ranges: pd.Series = self._obj[[add_from, add_to, parity]].apply(lambda row: AddressRange(row[add_from], row[add_to], Parity(p) if (p := row[parity]) is not pd.NA and p in parities else None, suppress_errors=True), axis=1)
        ranges.name = "$address_range_right"
        return ranges

    @property
    def parity_left(self) -> pd.Series:
        field_name: str = config.fields.parity_l.name
        if field_name not in self._obj.columns:
            raise KeyError(f"Column '{field_name}' is required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        result: pd.Series = self._obj[field_name].apply(lambda value: Parity(value) if value in parities else pd.NA)
        result.name = "$parity_left"
        return result

    @property
    def parity_right(self) -> pd.Series:
        field_name: str = config.fields.parity_r.name
        if field_name not in self._obj.columns:
            raise KeyError(f"Column '{field_name}' is required to compute this property.")
        parities: set[str] = set(Parity.__members__)
        result: pd.Series = self._obj[field_name].apply(lambda value: Parity(value) if value in parities else pd.NA)
        result.name = "$parity_right"
        return result

    @property
    def address_parity(self) -> pd.Series:
        field_name: str = config.fields.addnumber.name
        if field_name not in self._obj.columns:
            raise KeyError(f"Column '{field_name}' is required to compute this property.")
        parity_values: pd.Series = self._obj[field_name] % 2  # 0 = Even, 1 = Odd
        result: pd.Series = parity_values.pipe(Parity.from_modulo_result_series)
        result.name = "$parity"
        return result

    @property
    def directionality_left(self) -> pd.Series:
        result: pd.Series = self._obj.ng911.address_range_left().apply(lambda ar: ar.directionality if isinstance(ar, AddressRange) else pd.NA)
        result.name = "$directionality_left"
        return result

    @property
    def directionality_right(self) -> pd.Series:
        result: pd.Series = self._obj.ng911.address_range_right().apply(lambda ar: ar.directionality if isinstance(ar, AddressRange) else pd.NA)
        result.name = "$directionality_right"
        return result

    def to_table(self, gdb: "NG911Session | Path | str", name: str, convert_dtypes: bool = True, overwrite: bool = False) -> Path | None:
        gdb_path_str: str = str(Path(gdb))
        table_path: Path = Path(gdb) / name
        table_path_str: str = str(table_path)

        if self._obj.empty:
            if overwrite:
                arcpy.management.Delete(table_path_str)
            return None

        if convert_dtypes:
            df = self._obj.copy().convert_dtypes()
        else:
            df = self._obj.copy()

        if (object_cols := df.dtypes[df.dtypes.eq(object)]).any():  # type: ignore
            raise TypeError(f"The following column(s) have dtype 'object': {', '.join(quote(object_cols.index))}")

        field_types: dict[str, ArcFieldTypeKeyword] = {}
        field_types |= {col: "LONG" for col in df.select_dtypes(np.integer).columns}  # type: ignore
        field_types |= {col: "DOUBLE" for col in df.select_dtypes(np.floating).columns}  # type: ignore
        field_types |= {col: "TEXT" for col in df.select_dtypes(pd.StringDtype()).columns}  # type: ignore
        field_types |= {col: "DATEHIGHPRECISION" for col in df.select_dtypes(np.datetime64).columns}  # type: ignore
        if other_columns := set(df.columns) - set(field_types.keys()):
            _logger.warning(f"ArcGIS data types were not determined for all columns:\n{indent(str(other_columns))}")
        df = df[list(field_types.keys())]  # type: ignore

        if overwrite:
            arcpy.management.Delete(table_path_str)
        arcpy.management.CreateTable(gdb_path_str, name)
        arcpy.management.AddFields(table_path_str, [
            [
                col_name,  # Field name
                col_type,  # Field type
                None,  # Field alias
                max(df[col_name].fillna("").str.len().max(), 254) if col_type == "TEXT" else None  # Field length: if text, the greater of 254 or len of longest string in column; else None
            ]
            for col_name, col_type
            in field_types.items()
        ])

        with arcpy.da.InsertCursor(table_path_str, list(field_types.keys())) as cursor:
            for row in df.to_numpy(na_value=None):  # type: ignore
                cursor.insertRow(row)
        return table_path


@register_series_accessor("ng911")
class NG911SeriesAccessor:
    def __init__(self, pandas_obj: pd.Series):
        self._obj = pandas_obj

    def __getitem__(self, item: NGUID | str) -> Scalar:
        if self._obj.index.name != item.nguid_field.name:
            raise ValueError(f"This method is only available for NG911 feature class data frames.")
        return self._obj[str(item)]

    @property
    def field(self) -> NG911Field:
        return config.get_field_by_name(self._obj.index.name)


# def snap_int(value: int, direction: Literal["DOWN", "UP", -1, 1], parity: OddEvenParity) -> int:
#     if value == 0 and parity != Parity.ZERO:
#         raise ValueError(f"Value {value} is zero parity but parity argument was set to '{parity}'.")
#     in_parity = calculate_parity(value)
#     if in_parity == parity:
#         return value
#     elif direction == "DOWN":
#         return value - 1
#     elif direction == "UP":
#         return value + 1
#     else:
#         raise ValueError(f"Argument 'direction' must be 'DOWN' (or -1) or 'UP' (or 1), but got '{value}'.")



# def split_both_range(low: int, high: int) -> tuple[tuple[int, int], tuple[int, int]]:
#     """Returns ``((odd_low, odd_high), (even_low, even_high))``."""
#     return (
#         snap_int(low, 1, Parity.ODD),
#         snap_int(high, -1, Parity.ODD)
#     ), (
#         snap_int(low, 1, Parity.EVEN),
#         snap_int(high, -1, Parity.EVEN)
#     )
#
#
# def _ranges_touch(r1: tuple[int, int], r2: tuple[int, int]) -> bool:
#     return r1[1] + 2 == r2[0]
#
# def _ranges_overlap(r1: tuple[int, int], r2: tuple[int, int]) -> bool:
#     return r1[1] <= r2[0]
#
# def _merge_touching(r1: tuple[int, int], r2: tuple[int, int]) -> tuple[int, int]:
#     return r1[0], r2[1]
#
# def _merge_overlapping(r1: tuple[int, int], r2: tuple[int, int]) -> tuple[int, int]:
#     return min(*r1, *r2), max(*r1, *r2)


RoadRangeTuple: TypeAlias = tuple[int, int, Parity]
RoadRangeArrayTuple: TypeAlias = tuple[np.ndarray, Parity]


class RoadRangeCoverage:
    """
    Keeps track of address ranges covered by a road, even if that road is
    represented by multiple features with non-adjacent ranges.

    Unlike :class:`AddressRange`, this class is not intended to keep track of
    or compare the address ranges of individual features. The method
    :meth:`_consolidate`, called as part of various internal procedures,
    automatically sorts and (when applicable) merges address ranges.
    """

    __match_args__ = ("name", "community", "esn", "odd_ranges", "even_ranges")

    def __init__(self,
                 name: str,
                 community: str,
                 esn: str,
                 odd_ranges: Optional[np.ndarray | Sequence[tuple[int, int]]] = None,
                 even_ranges: Optional[np.ndarray | Sequence[tuple[int, int]]] = None,
                 both_ranges: Optional[np.ndarray | Sequence[tuple[int, int]]] = None,
                 source_id: Optional[int | str] = None,
                 odd_side: Optional[Literal["LEFT", "RIGHT"]] = None,
                 even_side: Optional[Literal["LEFT", "RIGHT"]] = None):
        self.name: str = name
        """Full name of the road. Intended to correspond to :ng911field:`fullname` or :ng911field:`lgcyfulst`."""

        self.community: str = community
        """MSAG community for the range. Intended to correspond to :ng911field:`msagcomm_l`, or :ng911field:`msagcomm_r`"""

        self.esn: str = esn
        """ESN for the range. Intended to correspond to :ng911field:`esn_l` or :ng911field:`esn_r`."""

        self.source_id: int | str | None = source_id
        """ID from the data source. If provided, this should correspond to :ng911field:`nguid_rdcl` or to the MSAG row number."""

        self.odd_side: Literal["LEFT", "RIGHT"] | None = odd_side
        """Side of the road with odd parity."""

        self.even_side: Literal["LEFT", "RIGHT"] | None = even_side
        """Side of the road with even parity."""

        try:
            odd_ranges_array = (
                arr
                if odd_ranges is not None
                   and (arr := np.asarray(odd_ranges, dtype=np.int32)).ndim == 2
                else np.ndarray((0, 2), dtype=np.int32)
            )  # Column 0 = From; Column 1 = To
        except TypeError as exc:
            exc.add_note(f"{odd_ranges = }")
            raise exc

        try:
            even_ranges_array = (
                arr
                if even_ranges is not None
                    and (arr := np.asarray(even_ranges, dtype=np.int32)).ndim == 2
                else np.ndarray((0, 2), dtype=np.int32)
            )
        except TypeError as exc:
            exc.add_note(f"{even_ranges = }")
            raise exc

        if both_ranges is not None:
            try:
                both_ranges_array = np.asarray(both_ranges, dtype=np.int32)
            except TypeError as exc:
                exc.add_note(f"{both_ranges = }")
                raise exc
            if both_ranges_array.ndim != 2:
                raise ValueError(f"Could not create 2-D array from 'both_ranges' argument. Resulting array shape: {both_ranges_array.shape}")
            self._validate_array_parity(both_ranges_array, Parity.BOTH)
            odd_split, even_split = self._split_both(both_ranges_array)
            odd_ranges_array = np.concatenate((odd_ranges_array, odd_split))
            even_ranges_array = np.concatenate((even_ranges_array, even_split))

        self._even_ranges: np.ndarray = even_ranges_array
        """Even address ranges as a 2D array. Each entry on axis 0 represents a
        range, and the two entries on axis 1 represent the from- and to-values,
        in that order. This array is not guaranteed to be consolidated. For a
        consolidated array, use :attr:`even_ranges`."""

        self._odd_ranges: np.ndarray = odd_ranges_array
        """Odd address ranges as a 2D array. Each entry on axis 0 represents a
        range, and the two entries on axis 1 represent the from- and to-values,
        in that order. This array is not guaranteed to be consolidated. For a
        consolidated array, use :attr:`odd_ranges`."""

        self._is_consolidated: bool = False
        """Whether :meth:`_consolidate` has been called since either range
        array was last updated."""

        self._consolidate()

    @staticmethod
    def _validate_array_parity(ranges: np.ndarray, parity: Parity) -> None:
        if parity != Parity.ZERO and (bad_ranges := ranges <= 0).any():
            raise MixedZeroParityError(ranges[bad_ranges])
        elif parity == Parity.ZERO and (bad_ranges := np.not_equal(ranges, 0)).any():
            raise MixedZeroParityError(ranges[bad_ranges])
        remainders = ranges % 2
        if parity == Parity.ODD and (bad_ranges := np.not_equal(remainders, 1)).any():
            raise InconsistentParityError(ranges[bad_ranges])
        elif parity == Parity.EVEN and (bad_ranges := np.not_equal(remainders, 0)).any():
            raise InconsistentParityError(ranges[bad_ranges])

    @staticmethod
    def _split_both(both_range: np.ndarray) -> tuple[np.ndarray, np.ndarray]:  # (<odd array>, <even array>)
        odds = both_range.copy()
        odds[odds[:, 0] % 2 == 0, 0] += 1  # Increase even numbers in column 0 (from) by 1 to make them odd
        odds[odds[:, 1] % 2 == 0, 1] -= 1  # Decrease even numbers in column 1 (to) by 1 to make them odd
        evens = both_range.copy()
        evens[evens[:, 0] % 2 == 1, 0] += 1  # Increase odd numbers in column 0 (from) by 1 to make them even
        evens[evens[:, 1] % 2 == 1, 1] -= 1  # Decrease odd numbers in column 1 (to) by 1 to make them even
        return odds, evens

    @staticmethod
    def _consolidate_side(ranges: ArrayLike[np.int32]) -> np.ndarray:
        """Implements much of the logic for, and is only intended to be called
        from, :meth:`_consolidate`."""
        # Shape: (n, 2) [where n is the number of rows/ranges]
        range_array: NDArray[np.int32] = np.array(ranges, dtype=np.int32)
        if range_array.size == 0:
            return range_array

        # Ensure each row is ordered [low, high]
        range_array.sort(axis=1)

        # Sort address ranges ascending
        range_array.sort(axis=0)

        # To-address column of range_array; shape: (n-1, ) [omits first row]
        prev_to: NDArray[np.int32] = range_array[:-1, 1]

        # Cumulative max of prev_to; shape: (n-1, )
        max_prev_to: NDArray[np.int32] = np.maximum.accumulate(prev_to)

        # Determine indices to use as consolidated from-addresses
        start_new_range: NDArray[np.bool_] = np.concatenate([
            # First row should always be a new range
            [True],
            # Whether the from-address is higher than the highest previously seen to-address plus 2. If True, start a new range; if False, consolidate into the previous one. Omits first row.
            range_array[1:, 0] > max_prev_to + 2
        ])  # Shape: (n, )

        # Determine indices to use as consolidated to-addresses
        end_of_range: NDArray[np.bool_] = np.concatenate([
            start_new_range[1:],  #Whether the to-address should be the end of a consolidated range. Should be True for a row when start_new_range is True for the next row. Omits first
            [True]
        ])

        # Get from-addresses
        starts = range_array[start_new_range, 0]

        # Get to-addresses
        ends = range_array[end_of_range, 1]

        # Put together as columns
        consolidated_ranges = np.column_stack([starts, ends])
        return consolidated_ranges


        # range_df = pd.DataFrame(data=range_array, columns=["add_from", "add_to"], dtype=pd.Int32Dtype()).sort_values(["add_from", "add_to"])
        # range_df["prev_to"] = range_df["add_to"].shift(1)
        # # df["next_from"] = df["add_from"].shift(-1)
        # range_df["max_prev_to"] = range_df["prev_to"].cummax()
        # range_df["start_new_range"] = (range_df["add_from"] - 2 > range_df["max_prev_to"]).fillna(False)
        # range_df["consolidation_group"] = range_df["start_new_range"].cumsum()
        # consolidated = range_df.groupby("consolidation_group").agg({"add_from": np.min, "add_to": np.max}).astype(np.int32)
        # return consolidated.values

    @property
    def odd_ranges(self) -> np.ndarray:
        if not self._is_consolidated:
            self._consolidate()
        return self._odd_ranges

    @odd_ranges.setter
    def odd_ranges(self, odd_ranges: np.ndarray) -> None:
        self._odd_ranges = odd_ranges
        self._is_consolidated = False

    @property
    def even_ranges(self) -> np.ndarray:
        if not self._is_consolidated:
            self._consolidate()
        return self._even_ranges

    @even_ranges.setter
    def even_ranges(self, even_ranges: np.ndarray) -> None:
        self._even_ranges = even_ranges
        self._is_consolidated = False

    @property
    def empty(self) -> bool:
        return self._odd_ranges.size == 0 and self._even_ranges.size == 0

    def _consolidate(self) -> None:
        if self._is_consolidated:
            return
        self._odd_ranges = self._consolidate_side(self._odd_ranges)
        self._even_ranges = self._consolidate_side(self._even_ranges)
        self._is_consolidated = True

    def nonzero_contains(self, item: int) -> bool:
        """Returns whether the value is within the ranges. Unlike
        :meth:`__contains__`, however, ``0`` is *not* considered to be in the
        ranges."""
        if item == 0:
            return False
        else:
            return item in self

    def __str__(self) -> str:
        return f"<{self.__class__.__name__} [{self.name}] {self.odd_ranges.shape[0]} Odd(s) / {self.even_ranges.shape[0]} Even(s)>"

    def __contains__(self, item) -> bool:
        """Returns whether the value is within the ranges. ``0`` is **always**
        considered to be in the ranges."""
        if item == 0:
            return True
        rng: np.ndarray
        match calculate_parity(item):
            case Parity.ZERO:
                return True
            case Parity.ODD:
                rng = self.odd_ranges
            case Parity.EVEN:
                rng = self.even_ranges
            case _:
                raise RuntimeError
        return bool(((rng[:, 0] <= item) & (item <= rng[:, 1])).any())

    @overload
    def __or__(self, other: Self) -> Self: ...

    @overload
    def __or__(self, other: RoadRangeArrayTuple) -> Self: ...

    @overload
    def __or__(self, other: RoadRangeTuple) -> Self: ...

    def __or__(self, other):
        rrc = partial(RoadRangeCoverage, self.name, self.community, self.esn)
        match other:
            case RoadRangeCoverage(n, c, esn, _odd_ranges=o, _even_ranges=e):
                if (self.name, self.community, self.esn) != (n, c, esn):
                    raise ValueError("Ranges have different names/communities/ESNs.")
                return rrc(np.concatenate((self._odd_ranges, o)), np.concatenate((self._even_ranges, e)))
            case (_, Parity.ZERO) | (_, _, Parity.ZERO):
                return rrc(self._odd_ranges.copy(), self._even_ranges.copy())
            case a, Parity.ODD:
                return rrc(np.concatenate((self._odd_ranges, a)), self._even_ranges.copy())
            case a, Parity.EVEN:
                return rrc(self._odd_ranges.copy(), np.concatenate((self._even_ranges, a)))
            case a, Parity.BOTH:
                return rrc(self._odd_ranges.copy(), self._even_ranges.copy(), both_ranges=a)
            case f, t, Parity.ODD:
                return rrc(np.append(self._odd_ranges, ((f, t),), axis=0), self._even_ranges.copy())  # type: ignore
            case f, t, Parity.EVEN:
                return rrc(self._odd_ranges.copy(), np.append(self._even_ranges, ((f, t),), axis=0))  # type: ignore
            case f, t, Parity.BOTH:
                return rrc(self._odd_ranges.copy(), self._even_ranges.copy(), both_ranges=((f, t),))  # type: ignore
            case _:
                raise NotImplemented

    @overload
    def __ior__(self, other: Self) -> Self: ...

    @overload
    def __ior__(self, other: RoadRangeArrayTuple) -> Self: ...

    @overload
    def __ior__(self, other: RoadRangeTuple) -> Self: ...

    def __ior__(self, other):
        match other:
            case RoadRangeCoverage(n, c, esn, _odd_ranges=o, _even_ranges=e):
                if (self.name, self.community, self.esn) != (n, c, esn):
                    raise ValueError(f"Ranges have different names/communities/ESNs: ({self.name}, {self.community}, {self.esn}) / ({n}, {c}, {esn})")
                self.odd_ranges = o if self.empty else np.concatenate((self._odd_ranges, o))
                self.even_ranges = e if self.empty else np.concatenate((self._even_ranges, e))
            case (_, Parity.ZERO) | (_, _, Parity.ZERO):
                pass  # No action needed
            case a, Parity.ODD:
                self.odd_ranges = np.concatenate((self._odd_ranges, a))
            case a, Parity.EVEN:
                self.even_ranges = np.concatenate((self._even_ranges, a))
            case a, Parity.BOTH:
                o, e = self._split_both(a)
                self.odd_ranges = np.concatenate((self._odd_ranges, o))
                self.even_ranges = np.concatenate((self._even_ranges, e))
            case f, t, Parity.ODD:
                self.odd_ranges = np.append(self._odd_ranges, ((f, t),), axis=0)  # type: ignore
            case f, t, Parity.EVEN:
                self.even_ranges = np.append(self._even_ranges, ((f, t),), axis=0)  # type: ignore
            case f, t, Parity.BOTH:
                o, e = self._split_both(np.array((f, t)))
                self.odd_ranges = np.concatenate((self._odd_ranges, o))
                self.even_ranges = np.concatenate((self._even_ranges, e))
            case _:
                raise NotImplemented
        return self

    @staticmethod
    def _intersect_side(ranges1: np.ndarray, ranges2: np.ndarray) -> np.ndarray:
        """Implements much of the logic for, and is only intended to be called
        from, :meth:`__and__`."""
        max_froms: np.ndarray = np.maximum(
            ranges1[:, np.newaxis, 0],
            ranges2[:, 0]
        )
        min_tos: np.ndarray = np.minimum(
            ranges1[:, np.newaxis, 1],
            ranges2[:, 1]
        )
        validity: np.ndarray = max_froms <= min_tos
        return np.array((max_froms[validity], min_tos[validity])).T

    def __and__(self, other: Self) -> Self:
        if (self.name, self.community, self.esn) != (other.name, other.community, other.esn):
            raise ValueError("Ranges have different names/communities/ESNs.")
        # noinspection PyTypeChecker
        return RoadRangeCoverage(
            self.name,
            self.community,
            self.esn,
            self._intersect_side(self.odd_ranges, other.odd_ranges),
            self._intersect_side(self.even_ranges, other.even_ranges)
        )

    @staticmethod
    def _sub_side(ranges1: np.ndarray, ranges2: np.ndarray) -> np.ndarray:
        # There's probably a faster, vectorized solution, but I'm short on time
        if not ranges1.size:
            return np.empty((0, 2), dtype=np.int32)
        elif not ranges2.size:
            return ranges1
        vals1 = np.concatenate([np.arange(f, t + 1, 2, dtype=np.int32) for f, t in ranges1])
        vals2 = np.concatenate([np.arange(f, t + 1, 2, dtype=np.int32) for f, t in ranges2])
        result_vals = np.setdiff1d(vals1, vals2)
        diffs = np.diff(result_vals)
        starts = np.concatenate((result_vals[:1], result_vals[1:][diffs != 2]))
        ends = np.concatenate((result_vals[:-1][diffs != 2], result_vals[-1:]))
        return np.column_stack((starts, ends))

    def __sub__(self, other: Self) -> Self:
        if (self.name, self.community, self.esn) != (other.name, other.community, other.esn):
            raise ValueError("Ranges have different names/communities/ESNs.")
        return RoadRangeCoverage(
            self.name, self.community, self.esn,
            self._sub_side(self.odd_ranges, other.odd_ranges),  # type: ignore
            self._sub_side(self.even_ranges, other.even_ranges)  # type: ignore
        )
