"""
:mod:`datachanges.py` -- ArcGIS Python Toolbox Tool "datachanges.py"

Notes
----------
:synopsis: Functions and Classes that perform changes to data and/or schema or assist changes.

Functions Include: create_std_gdb, assign_domain_to_gdb, user_fc_to_std_fc, get_road_range_by_parity

Classes Include: FullNameCalculator, FullAddrCalculator, SubmitCalculator,

ParityCalculator, RCLValuesCalculator, MSAGCommCalculator, TypeDirectionConverter, SpaceFixer, DomainFixer, CalculateConvertFixImplementer

:authors: Riley Baird (OK), Emma Baker (OK)
:created: August 02, 2024
:modified:  January 06, 2025
"""
from itertools import count
from time import time_ns

from .config_dataclasses import NG911Field, NG911FeatureClass
# lib import
from .session import config
from .misc import LoadDataFrame, Parity, calculate_parity, na_eq, na_in_list, FeatureAttributeValue, convert_attribute_value, AttributeConversionError, RowTuple
import os
import arcpy
import pandas as pd
import numpy as np
# noinspection PyUnresolvedReferences
from arcgis.features import GeoAccessor, GeoSeriesAccessor
try:
    from typing import ClassVar, Optional, Union, Protocol, TypeVar, Generic, TypeVar, overload, get_args, Literal
    from typing_extensions import LiteralString
except:
    pass



class StandardGeodatabaseCreation:
    def __init__(self, folder_path, gdb_name):
        self.folder_path = folder_path
        self.gdb_name = f"{gdb_name}.gdb"
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.optional_dataset_name = config.gdb_info.optional_dataset_name
        self.gdb_path = os.path.join(self.folder_path, self.gdb_name)
        self.required_dataset_path = os.path.join(self.gdb_path, self.required_dataset_name)
        self.optional_dataset_path = os.path.join(self.gdb_path, self.optional_dataset_name)

    def create_std_datasets(self, dataset_name, gdb_sr: arcpy.SpatialReference):
        arcpy.CreateFeatureDataset_management(self.gdb_path, dataset_name, gdb_sr)

    def create_std_gdb(self, gdb_sr: arcpy.SpatialReference):
        if arcpy.Exists(self.gdb_path):
            arcpy.AddMessage(f"...GDB `{self.gdb_name}` already exists. Deleting...")
            arcpy.Delete_management(self.gdb_path)
        arcpy.CreateFileGDB_management(self.folder_path, self.gdb_name)
        self.create_std_datasets(self.required_dataset_name, gdb_sr)
        self.create_std_datasets(self.optional_dataset_name, gdb_sr)

    def assign_domain_to_gdb(self):
        domains = config.domains.values()
        domain_field_type_dict = {field.domain.name: field.type if field.type else None for field in config.fields.values() if field.domain}
        for domain in domains:
            domain_name = domain.name
            domain_desc = domain.description
            # domain_table_dict = {'Code': list(domain.entries.keys()),
                                 # 'Description': list(domain.entries.values())}
            domain_table_dict = {'Code': pd.Series(domain.entries.keys()),
                                 'Description': pd.Series(domain.entries.values())}
            domain_table_df = pd.DataFrame.from_dict(domain_table_dict)
            domain_table_df.spatial.to_table(domain_table := f"memory/domain_{domain_name}_{time_ns()}")
            # domain_type = domain.type if domain.type else None
            arcpy.AddMessage(f"...Creating `{domain_name}`...")
            try:
                arcpy.management.TableToDomain(domain_table, "Code", "Description", self.gdb_path, domain_name, domain_desc)
                # domain_field_type = domain_field_type_dict[domain_name]
                # if domain_type and domain_field_type:
                #     arcpy.CreateDomain_management(in_workspace=self.gdb_path, domain_name=domain_name, domain_description=domain_desc, field_type=domain_field_type, domain_type=domain_type)
                # elif domain_type and not domain_field_type:
                #     arcpy.CreateDomain_management(in_workspace=self.gdb_path, domain_name=domain_name, domain_description=domain_desc, domain_type=domain_type)
                # else:
                #     arcpy.CreateDomain_management(in_workspace=self.gdb_path, domain_name=domain_name, domain_description=domain_desc)
            except Exception as ex:
                exception_info = f"{ex.__class__.__name__}: {str(ex)}"
                arcpy.AddError(f"...Issues adding domain `{domain_name}` to gdb `{self.gdb_name}`. Domain skipped...\n\tDetails: {exception_info}")
                continue
            finally:
                arcpy.management.Delete(domain_table)
            # arcpy.AddMessage(f"...Adding domain information to domain...")
            # for idx in domain_table_df.index:
            #     current_code = domain_table_df.loc[idx, 'Code']
            #     current_desc = domain_table_df.loc[idx, 'Description']
            #     try:
            #         arcpy.AddCodedValueToDomain_management(in_workspace=self.gdb_path, domain_name=domain_name, code=current_code, code_description=current_desc)
            #     except:
            #         arcpy.AddError(f"...Issues adding 'Code'|'Description' pair {current_code}|{current_desc} for domain `{domain_name}`. Domain pair skipped...")
            #         continue
            arcpy.AddMessage(f"...SUCCESSFULLY CREATED!")


class StandardFeatureClassCreation:
    def __init__(self, gdb_path: str, std_fc_name: str):
        self.gdb_path: str = gdb_path
        self.std_fc_name: str = std_fc_name
        self.required_dataset_name: str = config.gdb_info.required_dataset_name
        self.required_dataset_path: str = os.path.join(self.gdb_path, self.required_dataset_name)
        self.std_fc_path: str = os.path.join(str(self.required_dataset_path), self.std_fc_name)

    def create_blank_fc(self, std_fc_geom: str) -> Optional[str]:
        arcpy.AddMessage(f"`{self.std_fc_name}`...")
        if arcpy.Exists(self.std_fc_path):
            arcpy.AddWarning(f"...Feature class already exists in required dataset `{self.required_dataset_name}` in gdb `{os.path.basename(self.gdb_path)}`. Deleting fc...")
            arcpy.Delete_management(self.std_fc_path)
        try:
            arcpy.AddMessage(f"...Creating blank feature class...")
            arcpy.CreateFeatureclass_management(self.required_dataset_path, self.std_fc_name, std_fc_geom, has_z="ENABLED", has_m="ENABLED")
        except:
            return f"...Issues creating feature class `{self.std_fc_name}` to gdb `{os.path.basename(os.path.dirname(self.required_dataset_path))}`. Skipping fc.."
        arcpy.AddMessage(f"...Adding Standard fields to blank feature class...")
        std_fields = config.get_feature_class_by_name(self.std_fc_name).fields.values()
        field_add_error = []
        for std_field in std_fields:
            std_name = std_field.name
            std_type = std_field.type
            std_length = std_field.length
            std_domain = std_field.domain.name if std_field.domain else None
            try:
                # [field_name, field_type, field_name, field_length, field_domain]
                arcpy.AddField_management(
                    in_table=self.std_fc_path,
                    field_name=std_name,
                    field_type=std_type,
                    field_alias=std_name,
                    field_length=std_length,
                    field_domain=std_domain
                )
            except:
                field_add_error.append(f"...Issues adding field `{std_name}` to feature class `{self.std_fc_name}`. Skipping field...")
        if field_add_error:
            return "\n".join(field_add_error)
        arcpy.AddMessage(f"...BLANK SUCCESSFULLY CREATED!")
        return None

    # def arc_field_mapping(self, field_map_dict: dict[str, str], user_fc_path: str, fm_ignore_list: list[str]) -> arcpy.FieldMappings:
    #     field_mappings = arcpy.FieldMappings()
    #     field_mappings.addTable(self.std_fc_path)  # target/output
    #     for std_field_name, user_field_name in field_map_dict.items():
    #         if user_field_name in fm_ignore_list or not user_field_name:
    #             continue
    #         field_map = arcpy.FieldMap()
    #         field_map.addInputField(user_fc_path, user_field_name)  # input
    #         out_field_map = field_map.outputField
    #         out_field_map.name = std_field_name
    #         field_map.outputField = out_field_map
    #         field_mappings.addFieldMap(field_map)
    #     return field_mappings

    def append_data_to_std_fc(self, user_fc_path: str, field_map_dict: dict[str, str]):
        arcpy.AddMessage(f"...Adding data from user fc {os.path.basename(user_fc_path)} to standard fc {self.std_fc_name}.")
        field_map_dict = field_map_dict.copy()
        field_map_dict["SHAPE@"] = "SHAPE@"
        std_fields: list[str] = list(field_map_dict.keys())  # + ['SHAPE@']
        user_fields: list[str] = list(field_map_dict.values())  # + ['SHAPE@']
        # edit = arcpy.da.Editor(self.gdb_path)
        # edit.startEditing(False, False)
        # total_user_objects = int(arcpy.GetCount_management(user_fc_path)[0])
        # user_arctypes_dict: dict[str, ArcFieldTypeStr] = {field.name: field.type for field in arcpy.da.Describe(user_fc_path)["fields"] if field.name in user_fields}
        with (
            arcpy.da.Editor(self.gdb_path) as editor,
            arcpy.da.SearchCursor(user_fc_path, user_fields) as user_search_cursor,
            arcpy.da.InsertCursor(self.std_fc_path, std_fields, explicit=True) as std_insert_cursor
        ):
            user_row: RowTuple
            for row_counter, user_row in zip(count(1), user_search_cursor):  # Iterate over the input rows
                user_row_data: dict[str, FeatureAttributeValue] = {user_field_name: attribute_value for user_field_name, attribute_value in zip(user_fields, user_row)}
                std_row_data: dict[str, FeatureAttributeValue] = {std_field_name: user_row_data[user_field_name] for std_field_name, user_field_name in field_map_dict.items()}
                user_field_name: str
                for std_field_name in std_row_data:  # Iterate over each field in the input row
                    attribute_value: FeatureAttributeValue = std_row_data[std_field_name]
                    if attribute_value is None or std_field_name == "SHAPE@":
                        # If the value is null, or if this is the geometry field, leave it as is
                        continue
                    std_field: NG911Field = config.get_field_by_name(std_field_name)
                    try:
                        attribute_value = convert_attribute_value(attribute_value, std_field)
                        std_row_data[std_field_name] = attribute_value
                    except Exception as exc:  # Add additional context and re-raise
                        exc.add_note(f"Input Feature Class: {user_fc_path}")
                        exc.add_note(f"Input Field: {user_field_name}")
                        exc.add_note(f"Input Row Number: {row_counter}")
                        exc.add_note(f"Destination Field: {std_field_name}")
                        raise exc
                try:
                    std_insert_cursor.insertRow(tuple(std_row_data.values()))
                except Exception as exc:  # Add additional context and re-raise
                    exc.add_note(f"Failed on row number {row_counter} of input feature class '{user_fc_path}'.")
                    raise exc

        # user_search_cursor = arcpy.da.SearchCursor(user_fc_path, user_fields)
        # std_insert_cursor = arcpy.da.InsertCursor(self.std_fc_path, std_fields, explicit=True)
        # user_arctypes_dict: dict[str, ArcFieldTypeStr] = {field.name: field.type for field in arcpy.Describe(user_fc_path).fields if field.name in user_fields}
        # row_counter = 1
        # user_search_row: RowTuple
        # for user_search_row in user_search_cursor:  # For each row in user-supplied feature class...
        #     std_insert_row: list[FeatureAttributeValue] = list(user_search_row)
        #     # Update values in row
        #     for user_field_name in user_fields:  # For each user-supplied field name...
        #         user_list_idx = user_fields.index(user_field_name)
        #         user_field_val: FeatureAttributeValue = user_search_row[user_list_idx]  # User value for current user field
        #         user_type: ArcFieldTypeStr | None = None
        #         if user_field_name in list(user_arctypes_dict.keys()):
        #             user_type = user_arctypes_dict[user_field_name]
        #         # Standard field information
        #         std_field_name = std_fields[user_list_idx] # Standard field for update
        #         std_type: ArcFieldTypeStr | None = None
        #         std_length: int | None = None
        #         std_domain_list = None
        #         if 'SHAPE@' != std_field_name:
        #             std_field = config.get_field_by_name(std_field_name)
        #             std_type = std_field.type
        #             std_length = std_field.length
        #             std_domain_list = list(std_field.domain.entries.keys()) if std_field.domain else None
        #         new_val = None
        #         if user_field_name == 'SHAPE@':
        #             new_val = user_field_val
        #         elif user_field_val in ['', ' ', 'None', 'Null', None]: # User value is None
        #             new_val = None
        #         elif user_type != std_type: # Different field types
        #             if std_type == 'TEXT': # STR: TEXT
        #                 try:
        #                     new_val = str(user_field_val)
        #                     if len(new_val) > std_length:
        #                         new_val = new_val[:std_length]
        #                 except:
        #                     # error_text_list.append(f"Issues with converting user field {user_field_name} value {user_field_val} with type {std_type} to standard field {std_field_name} at std fc index row {row_counter}...")
        #                     pass
        #             elif std_type == 'DATE': # DATETIME: DATE
        #                 try:
        #                     new_val = datetime.datetime.strptime(user_field_val, '%Y-%m-%d %H:%M:%S')
        #                 except:
        #                     pass
        #             elif std_type == 'SHORT': # INT: SHORT
        #                 try:
        #                     new_val = int(user_field_val)
        #                 except:
        #                     pass
        #             elif std_type in ["LONG", "DOUBLE"]: # FLOAT: LONG, DOUBLE
        #                 try:
        #                     new_val = float(user_field_val)
        #                 except:
        #                     pass
        #             else: # OTHER
        #                 new_val = user_field_val
        #         else: # Field is the same
        #             new_val = user_field_val
        #         if std_domain_list: # Domain check
        #             if new_val and new_val not in std_domain_list:
        #                 new_val = None
        #         else:
        #             if new_val and std_type == 'TEXT':
        #                 if len(new_val) > std_length:
        #                     new_val = new_val[:(std_length - 4)] + "..."
        #         std_insert_row[user_list_idx] = new_val
        #     std_insert_cursor.insertRow(std_insert_row)
        #     row_counter += 1
        # edit.stopEditing(True)
        #
        # del std_insert_cursor, user_search_cursor, user_search_row, std_insert_row
        #
        # return

    def user_to_std_field_mapping(self, user_fc_path: Optional[str], field_map_dict: Optional[dict[str, str]] = None) -> Optional[str]:
        if not field_map_dict:
            arcpy.AddMessage(f"...using Standard user-specified fc `{os.path.basename(user_fc_path)}` for field mapping...")
            std_field_name_list = [field.name for field in config.get_feature_class_by_name(self.std_fc_name).fields.values()]
            user_field_names_list = config.match_field_names([f.name for f in arcpy.ListFields(user_fc_path) if not f.required], on_mismatch="DROP")
            user_std_field_dict = {field_name: field_name for field_name in std_field_name_list}
            # user_field_names_list = [desc_fld.name for desc_fld in arcpy.Describe(user_fc_path).fields if 'object' not in desc_fld.name and 'shape' not in desc_fld.name]
            missing_fields_list = list(set(std_field_name_list) - set(user_field_names_list))
            if missing_fields_list:
                arcpy.AddWarning(f"Missing Standard field(s) ({missing_fields_list}) from the user-specified fc... Skipping field(s)...")
                for missing_field in missing_fields_list:
                    del user_std_field_dict[missing_field]
        else:
            arcpy.AddMessage(f"...using non-Standard user-specified fc `{os.path.basename(user_fc_path)}` for field mapping...")
            user_std_field_dict = field_map_dict
        if not user_std_field_dict:
            return f"Could not get fields dictionary for field mapping... Skipping..."

        bad_results = self.append_data_to_std_fc(user_fc_path, user_std_field_dict)

        # field_map = self.arc_field_mapping(user_std_field_dict, user_fc_path, fc_ignore_list)
        # arcpy.AddMessage(f"...fc mapping complete...")
        # try:
        #     arcpy.Append_management([user_fc_path], self.std_fc_path, schema_type="NO_TEST", field_mapping=field_map)
        # except:
        #     return f"Could not append user-specified feature class `{os.path.basename(user_fc_path)}` to Standard feature class `{self.std_fc_name}`. Please check the schema (field domain, length, type, etc.)..."
        if bad_results:
            return bad_results

        arcpy.AddMessage(f"...SUCCESS!")
        return None


class FullNameCalculator:
    def __init__(self, std_gdb_path, fc_name_list, variant_list):
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.std_gdb_path = std_gdb_path
        self.fc_name_list = fc_name_list
        self.variant_list = variant_list
        self.target_field = None
        self.street_fields = config.street_field_names

    def execute_calculation(self):
        arcpy.AddMessage(f"Current analysis parameters:")
        run_check = 1
        for variant in self.variant_list:
            self.target_field = config.fields.fullname.name
            field_list = list(self.street_fields)
            if variant.lower() == "legacy":
                self.target_field = config.fields.lgcyfulst.name
                field_list[field_list.index(config.fields.predir.name)] = config.fields.lgcypredir.name
                field_list[field_list.index(config.fields.pretype.name)] = config.fields.lgcypretyp.name
                field_list[field_list.index(config.fields.street.name)] = config.fields.lgcystreet.name
                field_list[field_list.index(config.fields.streettype.name)] = config.fields.lgcytype.name
                field_list[field_list.index(config.fields.sufdir.name)] = config.fields.lgcysufdir.name
            for fc_name in self.fc_name_list:
                if fc_name == config.feature_classes.address_point.name: # ADDRESS
                    fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
                else: # ROAD
                    fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
                arcpy.AddMessage(f"...Run {run_check}\tVariant: {variant}\n\tFC: {fc_name}")
                edit = arcpy.da.Editor(self.std_gdb_path)
                edit.startEditing(False, False)
                u_cursor = arcpy.da.UpdateCursor(fc_path, [self.target_field] + field_list)
                for row in u_cursor:
                    expr_string = ' '.join([f'{field_val}' for field_val in row[1:] if field_val not in ['',' ','None','Null',None]])
                    if expr_string in ['',' ','None','Null',None]:
                        expr_string = None
                    row[0] = expr_string
                    u_cursor.updateRow(row)
                edit.stopEditing(True)
                run_check += 1


class FullAddrCalculator:
    def __init__(self, std_gdb_path, variant_list):
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.fc_name = config.feature_classes.address_point.name
        # self.fc_name_list = fc_name_list
        self.variant_list = variant_list
        self.target_field = None
        self.street_fields = config.street_field_names
        self.fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, self.fc_name)

    def execute_calculation(self):
        for variant in self.variant_list:
            self.target_field = config.fields.fulladdr.name
            street_for_analysis = list(self.street_fields)
            if variant.lower() == "legacy":
                self.target_field = config.fields.lgcyfuladd.name
                street_for_analysis[street_for_analysis.index(config.fields.predir.name)] = config.fields.lgcypredir.name
                street_for_analysis[street_for_analysis.index(config.fields.pretype.name)] = config.fields.lgcypretyp.name
                street_for_analysis[street_for_analysis.index(config.fields.street.name)] = config.fields.lgcystreet.name
                street_for_analysis[street_for_analysis.index(config.fields.streettype.name)] = config.fields.lgcytype.name
                street_for_analysis[street_for_analysis.index(config.fields.sufdir.name)] = config.fields.lgcysufdir.name
            field_list = [
                config.fields.addnumpre.name,
                config.fields.addnumber.name,
                config.fields.addnumsuf.name,
                *street_for_analysis
            ]
            edit = arcpy.da.Editor(self.std_gdb_path)
            edit.startEditing(False, False)
            u_cursor = arcpy.da.UpdateCursor(self.fc_path, [self.target_field] + field_list)
            for row in u_cursor:
                expr_string = ' '.join([f'{field_val}' for field_val in row[1:] if field_val not in ['',' ','None','Null',None]])
                if expr_string in ['',' ','None','Null',None]:
                    expr_string = None
                row[0] = expr_string
                u_cursor.updateRow(row)
            edit.stopEditing(True)


class NullToNCalculator:
    def __init__(self, std_gdb_path, fc_name_list):
        self.std_gdb_path = std_gdb_path
        self.fc_name_list = fc_name_list
        self.required_dataset_name = config.gdb_info.required_dataset_name

    def execute_calculation(self):
        for fc_name in self.fc_name_list:
            fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
            for field in arcpy.Describe(fc_path).fields:
                if field.name in [con_field.name for con_field in config.get_feature_class_by_name(fc_name).fields.values()]:
                    config_field = config.get_field_by_name(field.name)
                    if config_field.domain:
                        if config_field.domain.name == config.domains.YESNO.name:
                            arcpy.AddMessage(f"...Updating `{fc_name}` feature class field `{field.name}` null values to 'N'...")
                            edit = arcpy.da.Editor(self.std_gdb_path)
                            edit.startEditing(False, False)
                            u_cursor = arcpy.da.UpdateCursor(fc_path, [field.name])
                            for row in u_cursor:
                                if row[0] in ['', ' ', 'None', 'Null', None]:
                                    row[0] = 'N'
                                else:
                                    row[0] = row[0]
                                u_cursor.updateRow(row)
                            edit.stopEditing(True)


class DefaultCalculator:
    def __init__(self, std_gdb_path, fc_name_list, field_list):
        self.std_gdb_path = std_gdb_path
        self.fc_name_list = fc_name_list
        self.field_list = field_list
        self.required_dataset_name = config.gdb_info.required_dataset_name

    def execute_calculation(self):
        for fc_name in self.fc_name_list:
            fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
            for target_field in self.field_list:
                target = config.get_field_by_name(target_field)
                target_domain = target.domain if target.domain else None
                target_mandatory = target.priority if target.priority == 'M' else None
                target_default = target.fill_value if target.fill_value else None
                edit = arcpy.da.Editor(self.std_gdb_path)
                edit.startEditing(False, False)
                u_cursor = arcpy.da.UpdateCursor(fc_path, [target_field])
                for row in u_cursor:
                    if row[0] in ['', ' ', 'None', 'Null', None]:
                        new_val = None
                        if target_domain and target_mandatory:
                            if target_default in list(target_domain.entries.keys()):
                                new_val = target_default
                    else:
                        new_val = row[0]
                    row[0] = new_val
                    u_cursor.updateRow(row)
                edit.stopEditing(True)


class ParityCalculator:
    def __init__(self, std_gdb_path):
        self.std_gdb_path = std_gdb_path
        self.fc_name = config.feature_classes.road_centerline.name
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, self.fc_name)
        self.field_list = [
            config.fields.parity_l.name,
            config.fields.add_l_from.name,
            config.fields.add_l_to.name,
            config.fields.parity_r.name,
            config.fields.add_r_from.name,
            config.fields.add_r_to.name
        ]

    def execute_calculation(self):
        edit = arcpy.da.Editor(self.std_gdb_path)
        edit.startEditing(False, False)
        u_cursor = arcpy.da.UpdateCursor(self.fc_path, self.field_list)
        for row in u_cursor:
            # left analysis
            from_l_field = row[self.field_list.index(config.fields.add_l_from.name)]
            to_l_field = row[self.field_list.index(config.fields.add_l_to.name)]
            if from_l_field and to_l_field:
                l_val = f"{calculate_parity([from_l_field, to_l_field], True)}"
            else:
                l_val = f"{Parity.ZERO}"
            row[self.field_list.index(config.fields.parity_l.name)] = l_val
            # right analysis
            from_r_field = row[self.field_list.index(config.fields.add_r_from.name)]
            to_r_field = row[self.field_list.index(config.fields.add_r_to.name)]
            if from_r_field and to_r_field:
                r_val = f"{calculate_parity([from_r_field, to_r_field], True)}"
            else:
                r_val = f"{Parity.ZERO}"
            row[self.field_list.index(config.fields.parity_r.name)] = r_val
            u_cursor.updateRow(row)
        edit.stopEditing(True)


class RCLValuesCalculator:
    def __init__(self, std_gdb_path):
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.add_fc_name = config.feature_classes.address_point.name
        self.add_unique_id = config.feature_classes.address_point.unique_id
        self.rclmatch = config.fields.rclmatch.name
        self.rclside = config.fields.rclside.name
        self.street_fields = config.street_field_names
        self.address_field_list = [
            self.rclmatch,
            self.rclside,
            config.fields.addnumpre.name,
            config.fields.addnumber.name,
            config.fields.addnumsuf.name,
            *self.street_fields,
            config.fields.msagcomm.name
        ]
        self.road_fc_name = config.feature_classes.road_centerline.name
        self.road_unique_id = config.feature_classes.road_centerline.unique_id
        self.parity_l = config.fields.parity_l.name
        self.parity_r = config.fields.parity_r.name
        self.road_field_list = [
            self.parity_l,
            self.parity_r,
            config.fields.add_l_pre.name,
            config.fields.add_r_pre.name,
            config.fields.add_l_from.name,
            config.fields.add_l_to.name,
            config.fields.add_r_from.name,
            config.fields.add_r_to.name,
            *self.street_fields,
            config.fields.msagcomm_l.name,
            config.fields.msagcomm_r.name
        ]
        self.add_fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, self.add_fc_name)
        self.road_fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, self.road_fc_name)
        self.ap_df = LoadDataFrame(self.add_fc_path).fix_df_fields([self.add_unique_id.name] + self.address_field_list)
        self.road_df = LoadDataFrame(self.road_fc_path).fix_df_fields([self.road_unique_id.name] + self.road_field_list)

    def get_road_range_by_parity(self, df, parity_name, from_name, to_name) -> pd.Series:
        # road_range_match = [0]  # Parity.ZERO
        # if parity_name in [f"{Parity.ODD}", f"{Parity.EVEN}"]:  # By 2
        # elif parity_name == f"{Parity.BOTH}":  # By 1
        road_range_match: pd.Series = df[[from_name, to_name, parity_name]].apply(lambda row: pd.NA if row.isna().any() else list(range(row[from_name], (row[to_name] + 2), 2)) if row[parity_name] in [f"{Parity.ODD}", f"{Parity.EVEN}"] else list(range(row[from_name], (row[to_name] + 1), 1)) if row[parity_name] == f"{Parity.BOTH}" else [0], axis=1)
        return road_range_match

    def execute_calculation(self):
        # CALCULATES RCLMATCH AND RCLSIDE VALUES FROM APPROPRIATE ROAD UNIQUE IDS
        arcpy.AddMessage(f"...Prepping RCLMatch and RCLSide fields for analysis...")
        # ap_field_list = [self.add_unique_id.name] + self.address_field_list
        ap_analysis_df = self.ap_df[(self.ap_df[config.fields.addnumber.name].notna() & ~self.ap_df[config.fields.addnumber.name].isin([np.nan, pd.NA, None]))].copy()
        ap_analysis_df[self.rclmatch] = pd.NA
        ap_analysis_df[self.rclside] = pd.NA
        # rd_field_list = [self.road_unique_id.name] + self.road_field_list
        road_analysis_df = self.road_df[
            (self.road_df[config.fields.add_l_from.name].notna() & ~self.road_df[config.fields.add_l_from.name].isin([np.nan, pd.NA, None])) &
            (self.road_df[config.fields.add_l_to.name].notna() & ~self.road_df[config.fields.add_l_to.name].isin([np.nan, pd.NA, None])) &
            (self.road_df[config.fields.add_r_from.name].notna() & ~self.road_df[config.fields.add_r_from.name].isin([np.nan, pd.NA, None])) &
            (self.road_df[config.fields.add_r_to.name].notna() & ~self.road_df[config.fields.add_r_to.name].isin([np.nan, pd.NA, None]))].copy()
        arcpy.AddMessage(f"...Accessing Road Segments against each Address Point...")
        # JOIN STREET FIELDS FOR CHECK
        road_suffix = '_road'
        join_df = ap_analysis_df.set_index(list(self.street_fields)).join(other=road_analysis_df.set_index(list(self.street_fields)), how='left', rsuffix=road_suffix)
        # SEE IF ADDNUMBER (ADDRESS) IS IN R_ROAD_RANGE OR L_ROAD_RANGE (ROAD): GETS SIDE
        street_join_df = join_df[(join_df[self.road_unique_id.name].notna() & ~join_df[self.road_unique_id.name].isin([np.nan, pd.NA, None]))].copy()
        arcpy.AddMessage(f"Number of possible matches before side analysis: {len(street_join_df.index)}")
        street_join_df["$r_road_range"] = self.get_road_range_by_parity(street_join_df, config.fields.parity_r.name,
                                                                          config.fields.add_r_from.name,
                                                                          config.fields.add_r_to.name)
        street_join_df["$l_road_range"] = self.get_road_range_by_parity(street_join_df, config.fields.parity_l.name,
                                                                          config.fields.add_l_from.name,
                                                                          config.fields.add_l_to.name)
        side_analysis_list = [config.fields.addnumber.name, "$r_road_range", "$l_road_range"]
        street_join_df['analysis_side'] = street_join_df[side_analysis_list].apply(lambda field_row:
           "RIGHT" if na_in_list(field_row[side_analysis_list[0]], field_row[side_analysis_list[1]]) else
           "LEFT" if na_in_list(field_row[side_analysis_list[0]], field_row[side_analysis_list[2]]) else
           "NO MATCH", axis = 1)
        # SEE IF PREADD (ADDRESSS) IS IN ADD_L_PRE, ADD_R_PRE (ROAD)
        street_match_join_df = street_join_df[street_join_df['analysis_side'] != "NO MATCH"].copy()
        arcpy.AddMessage(f"Number of possible matches post side analysis: {len(street_match_join_df.index)}")
        pre_val_analysis_list = ['analysis_side', config.fields.addnumpre.name, f'{config.fields.add_l_pre.name}', f'{config.fields.add_r_pre.name}']
        street_match_join_df['analysis_preadd'] = street_match_join_df[pre_val_analysis_list].apply(lambda field_row:
            "YES" if na_eq(field_row[pre_val_analysis_list[0]], "LEFT") and na_eq(field_row[pre_val_analysis_list[1]], field_row[pre_val_analysis_list[2]]) else
            "YES" if na_eq(field_row[pre_val_analysis_list[0]], "RIGHT") and na_eq(field_row[pre_val_analysis_list[1]], field_row[pre_val_analysis_list[3]]) else
            "NO", axis = 1)
        # SEE IF MSAGCOMM (ADDRESSS) IS IN MSAG_COMM_L, MSAG_COMM_R (ROAD)
        preval_match_join_df = street_match_join_df[street_match_join_df['analysis_preadd'] != "NO"].copy()
        arcpy.AddMessage(f"Number of possible matches post pre val analysis: {len(preval_match_join_df.index)}")
        msag_val_analysis_list = ['analysis_side', config.fields.msagcomm.name, f'{config.fields.msagcomm_l.name}', f'{config.fields.msagcomm_r.name}']
        preval_match_join_df['analysis_msag'] = preval_match_join_df[msag_val_analysis_list].apply(lambda field_row:
             "YES" if  na_eq(field_row[msag_val_analysis_list[0]], "LEFT") and na_eq(field_row[msag_val_analysis_list[1]], field_row[msag_val_analysis_list[2]]) else
             "YES" if na_eq(field_row[msag_val_analysis_list[0]], "RIGHT") and na_eq(field_row[msag_val_analysis_list[1]], field_row[msag_val_analysis_list[3]]) else
             "NO", axis = 1)
        possible_match_join_df = preval_match_join_df[preval_match_join_df['analysis_msag'] != "NO"].copy()
        arcpy.AddMessage(f"Number of possible matches msag side analysis: {len(possible_match_join_df.index)}")
        possible_group_df = possible_match_join_df.groupby(self.add_unique_id.name)
        unique_id_list: list[str] = [unique_id for unique_id, group in possible_group_df]
        arcpy.AddMessage(f"...Creating dictionary for analysis...")
        results_dict = {}
        if unique_id_list:
            possible_results_dict: dict[str, list[str | None]] = {unique_id: [None, "NO MATCH"] for unique_id in unique_id_list}
            for unique_id, unique_group in possible_group_df:
                # UNIQUE ID FOUND
                if len(unique_group) > 1:
                    match = '|'.join([':'.join(val.split(':')[-2:]) for val in unique_group[self.road_unique_id.name].to_list()])
                    side= "NO MATCH"
                else:
                    match = unique_group[self.road_unique_id.name].to_list()[0]
                    side = unique_group['analysis_side'].to_list()[0]
                if len(match) > self.road_unique_id.length:
                    match = match[:(self.road_unique_id.length - 4)] + '...'
                possible_results_dict[unique_id] = [match, side]
            results_dict = possible_results_dict.copy()
            for unique_id, rcl_values in possible_results_dict.items():
                rcl_match = rcl_values[0]
                if not rcl_match:
                    del results_dict[unique_id]
        edit = arcpy.da.Editor(self.std_gdb_path)
        ap_match_counter = 1
        apr_match_total = len(list(results_dict.keys()))
        arcpy.AddMessage(f"...`{apr_match_total}` addresses found possible matches... Updating `{self.rclmatch}` and `{self.rclside}` results...")
        for ap_unique_id in list(results_dict.keys()):
            # arcpy.AddMessage(f"MATCH FOUND: {ap_match_counter}/{apr_match_total}")
            rclmatch_result: str = results_dict[ap_unique_id][0]
            rclside_result: str = results_dict[ap_unique_id][1]
            edit.startEditing(False, False)
            u_wc = f"{self.add_unique_id.name} = '{ap_unique_id}'"
            ap_u_cursor = arcpy.da.UpdateCursor(self.add_fc_path, [self.rclmatch, self.rclside], where_clause=u_wc)
            for row in ap_u_cursor:
                row[0] = rclmatch_result
                row[1] = rclside_result
                ap_u_cursor.updateRow(row)
            edit.stopEditing(True)
            ap_match_counter += 1
        arcpy.AddMessage(f"...Calculating leftover nulls as `NO MATCH`...")
        # UPDATES THE REMAINING NULL VALUES FOR RCLMATCH AND RCLSIDE TO `NO MATCH`
        u_wc = f"{self.rclmatch} IS NULL OR {self.rclside} IS NULL"
        ap_u_cursor = arcpy.da.UpdateCursor(self.add_fc_path, [self.rclmatch, self.rclside], where_clause=u_wc)
        edit.startEditing(False, False)
        for row in ap_u_cursor:
            row[0] = "NO MATCH"
            row[1] = "NO MATCH"
            ap_u_cursor.updateRow(row)
        edit.stopEditing(True)

        arcpy.AddMessage(f"\n\nSUCCESS!\nTies are concatenated with a `|`.")


class MSAGCommCalculator:
    def __init__(self, std_gdb_path, fc_name_list):
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.fc_name_list = fc_name_list
        self.msagcomm_field = config.fields.msagcomm
        self.msagcomm_l_field = config.fields.msagcomm_l
        self.msagcomm_r_field = config.fields.msagcomm_r
        self.geomsag_l_field = config.fields.geomsag_l
        self.geomsag_r_field = config.fields.geomsag_r
        self.city_field = config.fields.city
        self.city_l_field = config.fields.city_l
        self.city_r_field = config.fields.city_r
        self.county_field = config.fields.county
        self.county_l_field = config.fields.county_l
        self.county_r_field = config.fields.county_r

    def msag_fields_per_fc_determiner(self, fc_name) -> (list[[str]], list[[Optional[str]]], list[[str]], list[[str]]):
        if fc_name == config.feature_classes.address_point.name: # ADDRESS_POINT
            msag = [self.msagcomm_field.name]
            geomsag = []
            city = [self.city_field.name]
            county = [self.county_field.name]
            return msag, geomsag, city, county
        else: # ROAD_CENTERLINE
            msag = [self.msagcomm_l_field.name, self.msagcomm_r_field.name]
            geomsag = [self.geomsag_l_field.name, self.geomsag_r_field.name]
            city = [self.city_l_field.name, self.city_r_field.name]
            county = [self.county_l_field.name, self.county_r_field.name]
            return msag, geomsag, city, county

    def execute_calculation(self):
        for fc_name in self.fc_name_list:
            msag_list, geomsag_list, city_list, county_list = self.msag_fields_per_fc_determiner(fc_name)
            fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
            for city_key, city_field in enumerate(city_list):
                city_analysis_field = city_field
                county_analysis_field = county_list[city_key]
                target_msag_field = msag_list[city_key]
                target_geomsag_field = geomsag_list[city_key] if geomsag_list else None
                edit = arcpy.da.Editor(self.std_gdb_path)
                edit.startEditing(False, False)
                analysis_field_list = [target_msag_field, city_analysis_field, county_analysis_field]
                msag_u_cursor = arcpy.da.UpdateCursor(fc_path, analysis_field_list)
                for row in msag_u_cursor:
                    new_val = None
                    if row[0] not in ['', ' ', 'None', 'Null', None]:
                        new_val = row[0]
                    else:
                        if row[1] not in ['', ' ', 'None', 'Null', None]:
                            new_val = row[1]
                        elif row[2] not in ['', ' ', 'None', 'Null', None]:
                            new_val = row[2]
                    row[0] = new_val
                    msag_u_cursor.updateRow(row)
                if target_geomsag_field:
                    analysis_field_list = [target_geomsag_field, target_msag_field]
                    geo_u_cursor = arcpy.da.UpdateCursor(fc_path, analysis_field_list)
                    for row in geo_u_cursor:
                        new_val = 'N'
                        if row[1] not in ['', ' ', 'None', 'Null', None]:
                            new_val = 'Y'
                        row[0] = new_val
                        geo_u_cursor.updateRow(row)
                arcpy.AddMessage(f"...Calculated `{fc_name}` MSAGComm-associated fields...")
                edit.stopEditing(True)


class TypeDirectionConverter:
    def __init__(self, std_gdb_path, fc_name_list, analysis_type, tool_option_list, parameter_tool_variant_list):
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.fc_name_list = fc_name_list
        self.analysis_type = analysis_type
        self.tool_variant = tool_option_list[0]
        self.parameter_tool_variant_list = parameter_tool_variant_list

    def execute_conversion(self):
        for fc_name in self.fc_name_list:
            fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
            if self.analysis_type == f'type': # type
                pre_field = config.fields.pretype.name
                suf_field = config.fields.streettype.name
                lgcypre_field = config.fields.lgcypretyp.name
                lgcysuf_field = config.fields.lgcytype.name
                domain = config.domains.STREETTYPE
                lgcydomain = config.domains.LGCYSTREETTYPE
            else: # direction
                pre_field = config.fields.predir.name
                suf_field = config.fields.sufdir.name
                lgcypre_field = config.fields.lgcypredir.name
                lgcysuf_field = config.fields.lgcysufdir.name
                domain = config.domains.DIRECTION
                lgcydomain = config.domains.LGCYDIRECTION
            if self.tool_variant == self.parameter_tool_variant_list[0]: # NEXT GEN: CALCULATE NEXT-GEN FIELDS FROM LEGACY FIELDS, i.e., LEGACY (OLD/FROM) TO NEXT-GEN (TARGET/TO)
                # TARGET/TO
                pre_target_field = pre_field
                suf_target_field = suf_field
                to_domain = domain
                to_street_field = config.fields.street.name
                # OLD/FROM
                pre_old_field = lgcypre_field
                suf_old_field = lgcysuf_field
                from_domain = lgcydomain
                from_street_field = config.fields.lgcystreet.name
            else: # LEGACY: CALCULATE LEGACY FIELDS FROM NEXT-GEN FIELDS, i.e., NEXT-GEN (OLD/FROM) TO LEGACY (TARGET/TO)
                # TARGET/TO
                pre_target_field = lgcypre_field
                suf_target_field = lgcysuf_field
                to_domain = lgcydomain
                to_street_field = config.fields.lgcystreet.name
                # OLD/FROM
                pre_old_field = pre_field
                suf_old_field = suf_field
                from_domain = domain
                from_street_field = config.fields.street.name
            target_list = [pre_target_field, suf_target_field]
            from_old_list = [pre_old_field, suf_old_field]
            for pre_suf_key, target_field in enumerate(target_list):
                from_old_field = from_old_list[pre_suf_key]
                edit = arcpy.da.Editor(self.std_gdb_path)
                edit.startEditing(False, False)
                field_name_list = [target_field, from_old_field, to_street_field, from_street_field]
                u_cursor = arcpy.da.UpdateCursor(fc_path, field_name_list)
                for row in u_cursor:
                    from_old_val = row[1]
                    from_old_idx = list(from_domain.entries.keys()).index(from_old_val) if from_old_val in list(from_domain.entries.keys()) else None
                    new_target_val = list(to_domain.entries.keys())[from_old_idx] if from_old_idx else row[0]
                    row[0] = new_target_val if new_target_val not in ['', ' ', 'None', 'Null', None, '<NA>'] else None
                    row[2] = row[3]
                    u_cursor.updateRow(row)
                del u_cursor, from_old_val, from_old_idx, new_target_val
                edit.stopEditing(True)


class SpaceFixer:
    def __init__(self, std_gdb_path, fc_name_list):
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.fc_name_list = fc_name_list

    def execute_fixes(self):
        for fc_name in self.fc_name_list:
            fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
            fc_desc = arcpy.Describe(fc_path)
            for field in fc_desc.fields:
                if field.type != 'TEXT':
                    continue
                try:
                    arcpy.CalculateField_management(in_table=fc_path, field=field.name, expression=f"!{field.name}!.strip()", expression_type="PYTHON3")
                except:
                    arcpy.AddError(f"Could not fix leading/trailing spaces for field {field.name} in feature class `{fc_name}`.")


class DomainFixer:
    def __init__(self, std_gdb_path, fc_name_list):
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.fc_name_list = fc_name_list

    def execute_fixes(self):
        for fc_name in self.fc_name_list:
            arcpy.AddMessage(f"...Altering fields with domains to match domains in `{fc_name}`...")
            fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, fc_name)
            fc_desc = arcpy.Describe(fc_path)
            domain_field_dict = {}
            for field in fc_desc.fields:
                if not field.domain:
                    continue
                domain_dict_list = [domain.entries for domain in config.domains.values() if domain.name == field.domain]
                domain_options = list(domain_dict_list[0].keys()) if len(domain_dict_list) == 1 else []
                if not domain_options:
                    arcpy.AddError(f"Non-standard field domain `{field.domain}` found. Please check field `{field.name}`. Skipping...")
                    continue
                domain_field_dict[field.name] = domain_options
            analysis_field_list = list(domain_field_dict.keys())
            edit = arcpy.da.Editor(self.std_gdb_path)
            edit.startEditing(False, False)
            for field_name in analysis_field_list:
                field_type = config.get_field_by_name(field_name).type
                normal_list = domain_field_dict[field_name]
                lower_list = [domain_val.lower() for domain_val in normal_list]
                u_cursor = arcpy.da.UpdateCursor(fc_path, field_name)
                for row in u_cursor:
                    if row[0] in ['',' ','None','Null',None]:
                        new_row_val = None
                    elif field_type == 'TEXT':
                        lwr_idx = lower_list.index(row[0].lower()) if row[0].lower() in lower_list else None
                        normal_val = normal_list[lwr_idx] if lwr_idx else None
                        new_row_val = normal_val if normal_val else row[0]
                    else:
                        new_row_val = row[0] if row[0] in normal_list else None
                    row[0] = new_row_val
                    u_cursor.updateRow(row)
                del u_cursor, normal_list, lower_list, new_row_val
            edit.stopEditing(True)

        arcpy.AddMessage(f"...SUCCESS!")


class LevelFixer:
    def __init__(self, std_gdb_path, field_list):
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.road_fc_name = config.feature_classes.road_centerline.name
        self.road_fc_path = os.path.join(self.std_gdb_path, self.required_dataset_name, self.road_fc_name)
        self.from_level = config.fields.fromlevel
        self.tolevel = config.fields.tolevel
        self.analysis_field_list = field_list
        self.level_domain_list = list(config.domains.LEVEL.entries.keys())

    def execute_fixes(self):
        edit = arcpy.da.Editor(self.std_gdb_path)
        edit.startEditing(False, False)
        u_cursor = arcpy.da.UpdateCursor(self.road_fc_path, self.analysis_field_list)
        for row in u_cursor:
            for field_key in range(0, len(self.analysis_field_list)):
                new_row_val = row[field_key]
                if row[field_key] in ['', ' ', 'None', 'Null', None]:
                    new_row_val = None
                else:
                    try:
                        new_int_val = int(row[0])
                    except:
                        new_int_val = None
                    if new_int_val:
                        if new_int_val <= (len(self.level_domain_list) - 1):
                            new_row_val = self.level_domain_list[new_int_val]
                row[field_key] = new_row_val
            u_cursor.updateRow(row)
        del u_cursor, new_row_val
        edit.stopEditing(True)

        arcpy.AddMessage(f"...SUCCESS!")


class CalculateConvertFixImplementer:
    def __init__(self, param_list: list[arcpy.Parameter], analysis_dict: dict[str, str], tool_variant_full_list: list[str]):
        self.param_list = param_list
        self.analysis_dict = analysis_dict
        self.std_gdb_path = param_list[0].valueAsText
        self.tool_variant_full_list = tool_variant_full_list
        self.selected_analysis = param_list[1].value
        key_idx = list(self.analysis_dict.values()).index(self.selected_analysis)
        self.analysis_field_analysis = list(self.analysis_dict.keys())[key_idx] # dictionary key
        self.analysis_indexes = [idx for idx in range(2, len(self.param_list)) if self.analysis_field_analysis in self.param_list[idx].name]
        if not self.analysis_indexes:
            arcpy.AddError(f"PARAMETER INDEX ISSUES SELECTING ANALYSIS. HOW?")
            raise Exception(f"PARAMETER INDEX ISSUES SELECTING ANALYSIS. HOW?")
        self.fc_name_list = []
        self.tool_user_list = []
        self.field_list = []
        if len(self.analysis_indexes) == 2:  # 2
            self.fc_name_list = self.param_list[self.analysis_indexes[0]].valueAsText.split(";")
            if config.fields.fullname.role in self.param_list[self.analysis_indexes[0]].name: # fullname
                self.tool_user_list = self.param_list[self.analysis_indexes[1]].valueAsText.split(";")
            else: # type, direction
                self.tool_user_list = [self.param_list[self.analysis_indexes[1]].valueAsText]
        else:  # 1
            if self.param_list[self.analysis_indexes[0]].datatype in ["Value Table", "GPValueTable"]:  # parity, rclmatch
                self.fc_name_list = self.param_list[self.analysis_indexes[0]].values[0][0].split(f'\n')
            elif 'variant' in self.param_list[self.analysis_indexes[0]].name:  # fulladdr
                self.fc_name_list = [config.feature_classes.address_point.name]
                self.tool_user_list = self.param_list[self.analysis_indexes[0]].valueAsText.split(";")
            elif 'level' in self.param_list[self.analysis_indexes[0]].name: # level
                self.fc_name_list = [config.feature_classes.road_centerline.name]
                self.field_list = self.param_list[self.analysis_indexes[0]].valueAsText.split(";")
            else:  # nulls, spaces, domain, msag
                self.fc_name_list = self.param_list[self.analysis_indexes[0]].valueAsText.split(";")

        if not self.fc_name_list:
            arcpy.AddError(f"Issues retrieving specified or necessary feature class names.")
            raise Exception(f"Issues retrieving specified or necessary feature class names.")

    def implement_analysis(self):
        if self.analysis_field_analysis == f"{config.fields.fullname.role}":  # FullName
            fullname_calc = FullNameCalculator(self.std_gdb_path, self.fc_name_list, self.tool_user_list)
            fullname_calc.execute_calculation()
        elif self.analysis_field_analysis == f"{config.fields.fulladdr.role}":  # FullAddr
            fulladdr_calc = FullAddrCalculator(self.std_gdb_path, self.tool_user_list)
            fulladdr_calc.execute_calculation()
        elif self.analysis_field_analysis == f"nulls":  # nulls
            nulls_calc = NullToNCalculator(self.std_gdb_path, self.fc_name_list)
            nulls_calc.execute_calculation()
        elif self.analysis_field_analysis == f'parity': # Parity
            parity_calc = ParityCalculator(self.std_gdb_path)
            parity_calc.execute_calculation()
        elif self.analysis_field_analysis == f'{config.fields.rclmatch.role}': # RCLMatch/RCLSide
            rcl_values_calc = RCLValuesCalculator(self.std_gdb_path)
            rcl_values_calc.execute_calculation()
        elif self.analysis_field_analysis == f'{config.fields.msagcomm.role}': # RCLMatch/RCLSide
            msag_calc = MSAGCommCalculator(self.std_gdb_path, self.fc_name_list)
            msag_calc.execute_calculation()
        elif self.analysis_field_analysis in [f'type', f'direction']: # Type, Direction
            conversion = TypeDirectionConverter(self.std_gdb_path, self.fc_name_list, self.analysis_field_analysis, self.tool_user_list, self.tool_variant_full_list)
            conversion.execute_conversion()
        elif self.analysis_field_analysis == f'spaces': # Spaces
            space_fix = SpaceFixer(self.std_gdb_path, self.fc_name_list)
            space_fix.execute_fixes()
        elif self.analysis_field_analysis == f'domain': # Domain
            domain_fix = DomainFixer(self.std_gdb_path, self.fc_name_list)
            domain_fix.execute_fixes()
        elif self.analysis_field_analysis == f'level': # Level
            level_fix = LevelFixer(self.std_gdb_path, self.field_list)
            level_fix.execute_fixes()
        else:  # "None": f"--PLEASE SELECT ANALYSIS--"
            pass


class ESNtoESZESBFCsImplementer:
    def __init__(self, std_gdb_path, esn_fc, assign_switch, nguid_field, fc_dict):
        self.stat_method = "CONCATENATE"
        self.sep_string = "||"
        self.std_gdb_path = std_gdb_path
        self.required_dataset_name = config.gdb_info.required_dataset_name
        self.required_dataset_path = os.path.join(self.std_gdb_path, self.required_dataset_name)
        self.esn_fc = esn_fc
        self.assign_switch = assign_switch
        self.nguid_field = nguid_field
        self.fc_dict = fc_dict
        self.agency_id = config.fields.agency_id
        self.local_id = config.fields.local_id
        self.esz_fc = config.feature_classes.esz_boundary
        self.ems_fc = config.feature_classes.esb_ems_boundary
        self.law_fc = config.feature_classes.esb_law_boundary
        self.fire_fc = config.feature_classes.esb_fire_boundary
        self.esn = config.fields.esn
        self.esz = config.fields.esz
        self.ems = config.fields.ems
        self.law = config.fields.law
        self.fire = config.fields.fire
        self.dissolve_field_list = [self.esz.name, self.ems.name, self.law.name, self.fire.name]

    @property
    def output_esz_path(self) -> str:
        return os.path.join(self.required_dataset_path, self.esz_fc.name)

    @property
    def output_ems_path(self) -> str:
        return os.path.join(self.required_dataset_path, self.ems_fc.name)

    @property
    def output_fire_path(self) -> str:
        return os.path.join(self.required_dataset_path, self.fire_fc.name)

    @property
    def output_law_path(self) -> str:
        return os.path.join(self.required_dataset_path, self.law_fc.name)

    def check_for_standard_dataset(self):
        if not arcpy.Exists(self.required_dataset_path):
            arcpy.AddWarning(f"...No `{self.required_dataset_name}` dataset in `{os.path.basename(self.std_gdb_path)}`. Creating...")
            arcpy.CreateFeatureDataset_management(self.std_gdb_path, self.required_dataset_name, arcpy.SpatialReference(config.gdb_info.spatial_reference_factory_code_2d))

    def check_for_feature_class(self, fc_name: str | LiteralString):
        fc_path = os.path.join(str(self.required_dataset_path), fc_name)
        if arcpy.Exists(fc_path):
            arcpy.AddWarning(f"...`{fc_name}` already exists in `{os.path.basename(self.std_gdb_path)}/{self.required_dataset_name}`. Deleting...")
            arcpy.Delete_management(fc_path)
        return fc_path

    def dissolve_esn(self, fc_path, fc_dict, diss_field):
        ## TODO: CHECK STANDARD FOR MULTI-PART OR SINGLE-PART DISSOLVE
        # Dissolve current fc with appropriate dissolve field and user-specified fields to concatenate
        if not fc_dict:
            dis_stat_str = ""
            arcpy.PairwiseDissolve_analysis(
                in_features=self.esn_fc,
                out_feature_class=fc_path,
                dissolve_field=diss_field,
                statistics_fields=dis_stat_str,
                multi_part="MULTI_PART")
        else:
            dis_stat_list = []
            for user_specified_field in fc_dict.values():
                dis_stat_list.append(f"{user_specified_field} {self.stat_method}")
            if self.assign_switch == "NGUID":
                dis_stat_list.append(f"{self.nguid_field} {self.stat_method}")
            dis_stat_str = ";".join(dis_stat_list)
            arcpy.PairwiseDissolve_analysis(
                in_features=self.esn_fc,
                out_feature_class=fc_path,
                dissolve_field=diss_field,
                statistics_fields=dis_stat_str,
                multi_part="MULTI_PART",
                concatenation_separator=self.sep_string)

    def fix_esz_fc(self, fc_path):
        # Fix `esn`, `esz` fields for the `esz` fc
        esz_field_name = self.esz.name
        esz_field_type = self.esz.type
        esz_field_length = self.esz.length
        esn_field_name = self.esn.name
        esn_field_type = self.esn.type
        esn_field_length = self.esn.length
        user_field = self.fc_dict[esz_field_name]
        temp_name = f"TEMP_NAME"
        # Alter field to temp name for analysis
        arcpy.AlterField_management(fc_path, user_field, temp_name)
        # Add ESZ
        arcpy.AddField_management(in_table=fc_path, field_name=esz_field_name, field_type=esz_field_type, field_length=esz_field_length, field_alias=esz_field_name)
        arcpy.CalculateField_management(in_table=fc_path, field=esz_field_name, expression=f'!{temp_name}!')
        # Add ESN
        arcpy.AddField_management(in_table=fc_path, field_name=esn_field_name, field_type=esn_field_type, field_length=esn_field_length, field_alias=esn_field_name)
        arcpy.CalculateField_management(in_table=fc_path, field=esn_field_name, expression=f'!{esz_field_name}!')
        # Delete temp name field
        arcpy.DeleteField_management(in_table=fc_path, drop_field=temp_name)

    def fix_user_fields_to_fc_fields(self, fc_path, fc_dict, fc):
        # Concatenated stat field analysis for user-specified fields
        diss_field_cnt_dict = {f"{val}": list(fc_dict.values()).count(val) for val in list(set(fc_dict.values()))}
        user_field_tracker = []
        for std_field_name in fc_dict.keys():
            user_field = fc_dict[std_field_name]
            current_diss_field_name = f"{self.stat_method}_{user_field}"
            total_diss_cnt = diss_field_cnt_dict[user_field]
            current_diss_cnt = user_field_tracker.count(user_field)
            if total_diss_cnt > 1 and current_diss_cnt > 0:
                diss_cnt_key = current_diss_cnt
                current_diss_field_name = f"{current_diss_field_name}_{diss_cnt_key}"
            arcpy.AddMessage(f"...Assessing dissolved field `{current_diss_field_name}` value and moving to `{std_field_name}`")
            diss_field = config.get_field_by_name(std_field_name)
            diss_type = diss_field.type
            diss_length = diss_field.length
            diss_domain = diss_field.domain if diss_field.domain else None
            diss_default = diss_field.fill_value if diss_field.fill_value else None
            diss_mandatory = diss_field.priority if diss_field.priority == 'M' else None
            arcpy.AddField_management(in_table=fc_path, field_name=std_field_name, field_type=diss_type, field_length=diss_length, field_alias=std_field_name, field_domain=diss_domain.name if diss_domain else diss_domain)
            diss_u_cursor = arcpy.da.UpdateCursor(fc_path, ['OBJECTID', current_diss_field_name, std_field_name])
            for diss_row in diss_u_cursor:
                obj_id = diss_row[0]
                field_val = diss_row[1]
                update_field_val = None
                if field_val not in ['', ' ', 'None', 'Null', None]:
                    if self.sep_string in field_val: # CONCAT VAL
                        val_list = field_val.split(self.sep_string)
                        val_list = [field_val.strip() for field_val in val_list]
                        val_unique_list = list(set(val_list))
                        if len(val_unique_list) == 1:
                            update_field_val = val_unique_list[0]
                        elif len(val_unique_list) > 1:
                            txt_str = val_unique_list[:25] if len(val_unique_list) > 25 else val_unique_list
                            arcpy.AddWarning(f"...Multiple unique values found for {current_diss_field_name} for OBJECTID `{obj_id}` in {fc.name}:\n{txt_str}...")
                            if not diss_domain:
                                if diss_type == "String":
                                    update_field_val = self.sep_string.join([str(val) for val in val_unique_list])
                                    if len(update_field_val) > diss_length:
                                        update_field_val = update_field_val[:(diss_length - 4)] + "..."
                                else:
                                    update_field_val = val_unique_list[0]
                            else:
                                values_in_domain = [val for val in val_unique_list if val in list(diss_domain.entries.keys())]
                                if values_in_domain:
                                    update_field_val = values_in_domain[0]
                                elif diss_mandatory and not values_in_domain:
                                    if diss_default in list(diss_domain.entries.keys()):
                                        update_field_val = diss_default
                        elif diss_mandatory and diss_domain:
                            if diss_default in list(diss_domain.entries.keys()):
                                update_field_val = diss_default
                    else: # SINGLE VAL
                        if diss_domain:
                            if field_val in list(diss_domain.entries.keys()):
                                update_field_val = field_val
                            elif diss_mandatory and field_val not in list(diss_domain.entries.keys()) and diss_default in list(diss_domain.entries.keys()):
                                    update_field_val = diss_default
                        else:
                            update_field_val = field_val
                if update_field_val:
                    try:
                        update_field_val = convert_attribute_value(update_field_val, diss_field)
                    except AttributeConversionError as exc:
                        exc.add_note(f"Failed to convert '{update_field_val}' value to appropriate field type '{diss_type}'. Setting to null.")
                        warning_text: str = "\n\t".join([str(exc)] + exc.__notes__)
                        arcpy.AddWarning(warning_text)
                        update_field_val = None
                    # if diss_type == "TEXT":
                    #     update_field_val = str(update_field_val)
                    # elif diss_type == "LONG":
                    #     try:
                    #         update_field_val = int(update_field_val)
                    #     except:
                    #         arcpy.AddWarning(f"...Failed to convert `{update_field_val}` value to `{diss_type}` (`int`). Returning None...")
                    #         update_field_val = None
                    # elif diss_type == "DOUBLE":
                    #     try:
                    #         update_field_val = float(update_field_val)
                    #     except:
                    #         arcpy.AddWarning(f"...Failed to convert `{update_field_val}` value to {diss_type} (`float`). Returning None...")
                    #         update_field_val = None
                diss_row[2] = update_field_val
                diss_u_cursor.updateRow(diss_row)
            # Delete concatenated dissolve field
            arcpy.DeleteField_management(in_table=fc_path, drop_field=[current_diss_field_name])
            user_field_tracker.append(user_field)

    def fix_nguid_values(self, fc_path, fc):
        # `NGUID` field analysis
        # Add `NGUID` field if it already doesn't exist in dissolved fc
        current_dissolve_field_list = [field.name for field in arcpy.Describe(fc_path).fields]
        nguid = fc.unique_id
        nguid_name = nguid.name
        nguid_type = nguid.type
        nguid_length = nguid.length
        arcpy.AddField_management(in_table=fc_path,
                                  field_name=nguid_name,
                                  field_type=nguid_type,
                                  field_length=nguid_length,
                                  field_alias=nguid_name)

        # Add `LOCAL` field if it already doesn't exist in dissolved fc
        local_id_name = self.local_id.name
        if local_id_name not in current_dissolve_field_list:
            local_id_type = self.local_id.type
            local_id_length = self.local_id.length
            arcpy.AddField_management(in_table=fc_path,
                                      field_name=local_id_name,
                                      field_type=local_id_type,
                                      field_length=local_id_length,
                                      field_alias=local_id_name)

        # Create update cursor for dissolved fc
        update_cursor_field_list = ["OBJECTID", nguid_name, self.agency_id.name, local_id_name]
        if self.assign_switch == "NGUID":
            dissolve_field_name = f"{self.stat_method}_{self.nguid_field}"
            update_cursor_field_list = update_cursor_field_list + [dissolve_field_name]

        # Use update cursor to create NGUID string based on user-specified `assign_nguid_switch` and available data
        # from std fields (`agency id`, `local`)
        blank_agency_list = []
        update_cursor = arcpy.da.UpdateCursor(fc_path, update_cursor_field_list)
        for key, row in enumerate(update_cursor):
            object_id = row[0]
            sequential_cnt = key + 1
            local911_field_value = row[3]
            if local911_field_value:
                field_val_list = local911_field_value.split(self.sep_string)
                field_val_unique = list(set(field_val_list))
                if len(field_val_unique) != 1:
                    local911_field_value = sequential_cnt
            else:
                local911_field_value = sequential_cnt
            agencyid_value = row[2]
            if self.assign_switch == "NGUID":  # "NGUID"
                dissolve_field_name = f"{self.stat_method}_{self.nguid_field}"
                nguid_field_value = row[update_cursor_field_list.index(dissolve_field_name)]
                if nguid_field_value in ['', ' ', 'None', 'Null', None]:
                    arcpy.AddWarning(f"...No `NGUID` values retrieved from dissolve ESN for OBJECTID `{object_id}`...")
                    arcpy.AddWarning(f"...using Local value or Sequential count...")
                    nguid_field_value = local911_field_value
                elif self.sep_string in nguid_field_value:
                    field_val_list = nguid_field_value.split(self.sep_string)
                    field_val_unique = list(set(field_val_list))
                    if len(field_val_unique) == 1:
                        nguid_field_value = field_val_unique[0].split(":")[-2]
                    else:
                        arcpy.AddWarning(f"...Multiple `NGUID` values retrieved from dissolve ESN for OBJECTID `{object_id}`...")
                        arcpy.AddWarning(f"...using Local value or Sequential count...")
                        nguid_field_value = local911_field_value
                local911_value = nguid_field_value
            elif self.assign_switch == "LOCAL":  # "LOCAL"
                local911_value = local911_field_value
            else:  # "SEQUENTIAL"
                local911_value = sequential_cnt
            if not local911_value:
                local911_value = sequential_cnt
            if agencyid_value in ['', ' ', 'None', 'Null', None]:
                agencyid_value = ""
                blank_agency_list.append(object_id)

            nguid_value = ":".join([str(config.fields.serviceurn.fill_value), fc.name, str(local911_value), str(agencyid_value)])
            row[1] = nguid_value
            update_cursor.updateRow(row)
        if blank_agency_list:
            arcpy.AddWarning(f"...Following `{self.agency_id.name}` appended to NGUID value used blank string...\nOBJECTID(s): {blank_agency_list}")

    def fix_missing_std_and_extra_fields(self, fc_path, fc):
        ## Assess the missing std fields and extra fields at the end of dissolve analysis
        fc_field_list = [field.name for field in fc.fields.values()]
        current_dissolve_field_list = [field.name for field in arcpy.Describe(fc_path).fields]
        missing_std_field_list = list(set(fc_field_list) - set(current_dissolve_field_list))
        if missing_std_field_list:
            arcpy.AddMessage(f"...Missing Fields in {fc.name}: {missing_std_field_list}...")
            for missing_field_name in missing_std_field_list:
                add_field = config.get_field_by_name(missing_field_name)
                add_field_type = add_field.type
                add_field_length = add_field.length
                add_field_domain = add_field.domain if add_field.domain else None
                add_field_mandatory = add_field.priority if add_field.priority == 'M' else None
                add_field_default = add_field.fill_value if add_field.fill_value else None
                arcpy.AddField_management(in_table=fc_path,
                                          field_name=missing_field_name,
                                          field_type=add_field_type,
                                          field_length=add_field_length,
                                          field_alias=missing_field_name,
                                          field_domain=add_field_domain.name if add_field_domain else add_field_domain)
                if add_field_default and add_field_mandatory and add_field_default in list(add_field_domain.entries.keys()):
                    arcpy.AddMessage(f"...Missing Fields in {fc.name}: Calculating field {missing_field_name} as default values {add_field_default}...")
                    arcpy.CalculateField_management(fc_path, missing_field_name, f"'{add_field_default}'")
        current_dissolve_field_list = [field for field in current_dissolve_field_list if 'shape' not in field.lower() and 'object' not in field.lower()]
        extra_dis_field_list = list(set(current_dissolve_field_list) - set(fc_field_list))
        if extra_dis_field_list:
            arcpy.AddMessage(f"...Extra Fields in {fc.name}: {extra_dis_field_list}...")
            for extra_field in extra_dis_field_list:
                arcpy.DeleteField_management(fc_path, extra_field)

    def execute_split(self):
        self.check_for_standard_dataset()
        ## Beginning dissolve analysis for field in dissolve list
        fc_path_dict = {}
        for diss_field in self.dissolve_field_list:
            # Getting necessary std information from config for current fc
            if 'esz' in diss_field.lower():
                fc = self.esz_fc
            elif 'fire' in diss_field.lower():
                fc = self.fire_fc
            elif 'ems' in diss_field.lower():
                fc = self.ems_fc
            elif 'law' in diss_field.lower():
                fc = self.law_fc
            else:
                raise Exception(f"...CHECK IF-STATEMENTS FOR OR VALUES IN `dissolve_field_list`.")
            fc_name = fc.name
            arcpy.AddMessage(f"\n...Creating Standard FC: `{fc_name}`...")
            fc_path = self.check_for_feature_class(fc_name)
            fc_path_dict[fc_name] = fc_path
            fc_fields = fc.fields
            fc_field_list = [field.name for field in fc_fields.values()]
            # Creating dictionary for current fc with user-specified fields for dissolve concatenate analysis
            diss_fc_dict = self.fc_dict.copy()
            diss_user_field = self.fc_dict[diss_field]
            del diss_fc_dict[diss_field]
            arcpy.AddMessage(f"...dissolving `{diss_user_field}` field...")
            for dict_std_field, dict_user_field in self.fc_dict.items():
                if dict_std_field not in diss_fc_dict.keys():
                    continue
                if dict_std_field not in fc_field_list:
                    del diss_fc_dict[dict_std_field]
                if dict_std_field == diss_field:
                    del diss_fc_dict[dict_std_field]
            self.dissolve_esn(fc_path, diss_fc_dict, diss_user_field)
            # Fix `esn`, `esz` fields for the `esz` fc
            if 'ESZ' in fc_name:
                self.fix_esz_fc(fc_path)
            self.fix_user_fields_to_fc_fields(fc_path, diss_fc_dict, fc)
            self.fix_nguid_values(fc_path, fc)
            self.fix_missing_std_and_extra_fields(fc_path, fc)
            arcpy.AddMessage(f"\nSUCCESS!\n")

