import bpy
import typing, enum, re
from . import UTIL_functions
from . import PROP_virtools_group

#region Rename Error Reporter

class _RenameErrorType(enum.IntEnum):
    ERROR = enum.auto()
    WARNING = enum.auto()
    INFO = enum.auto()

class _RenameErrorItem():
    mErrType: _RenameErrorType
    mDescription: str

    def __init__(self, err_t: _RenameErrorType, description: str):
        self.mErrType = err_t
        self.mDescription = description

class RenameErrorReporter():
    """
    A basic 'rename error report' using simple prints in console.

    This object can be used as a context manager.

    It supports multiple levels of 'substeps' - you shall always enter at least one substep (because level 0
    has only one single step, representing the whole 'area' of the progress stuff).

    You should give the object renaming of substeps each time you enter a new one.

    Leaving a substep automatically steps by one the parent level.

    ```
    with RenameErrorReporter() as reporter:
        progress.enter_object(obj)

        # process for object with reporter
        reporter.add_error('fork!')

        progress.leave_object()
    ```
    """
    mAllObjCounter: int
    mFailedObjCounter: int

    mErrList: list[_RenameErrorItem]
    mOldName: str
    mHasError: bool

    def __init__(self):
        self.mAllObjCounter = 0
        self.mFailedObjCounter = 0
        
        self.mErrList = []
        self.mOldName = ""
        self.mHasError = False

    def add_error(self, description: str):
        self.mHasError = True
        self.mErrList.append(_RenameErrorItem(_RenameErrorType.ERROR, description))
    def add_warning(self, description: str):
        self.mErrList.append(_RenameErrorItem(_RenameErrorType.WARNING, description))
    def add_info(self, description: str):
        self.mErrList.append(_RenameErrorItem(_RenameErrorType.INFO, description))

    def get_all_objs_count(self) -> int: return self.mAllObjCounter
    def get_failed_objs_count(self) -> int: return self.mFailedObjCounter

    def __enter__(self):
        # print console report header
        print('============')
        print('Rename Report')
        print('------------')
        # return self as context
        return self
    
    def __exit__(self, exc_type, exc_value, traceback):
        # print console report tail
        print('------------')
        print(f'All / Failed - {self.mAllObjCounter} / {self.mFailedObjCounter}')
        print('============')
        # reset variables
        self.mAllObjCounter = 0
        self.mFailedObjCounter = 0
    
    def enter_object(self, obj: bpy.types.Object) -> None:
        # inc all counter
        self.mAllObjCounter += 1
        # assign old name
        self.mOldName = obj.name
    def leave_object(self, obj:bpy.types.Object) -> None:
        # if error list is empty, no need to report
        if len(self.mErrList) == 0: return

        # inc failed if necessary
        if self.mHasError:
            self.mFailedObjCounter += 1

        # output header
        # if new name is different with old name, output both of them
        new_name: str = obj.name
        if self.mOldName == new_name:
            print(f'For object "{new_name}"')
        else:
            print(f'For object "{new_name}" (Old name: "{self.mOldName}")')

        # output error list with indent
        for item in self.mErrList:
            print('\t' + RenameErrorReporter.__erritem_to_string(item))

        # clear error list for next object
        self.mErrList.clear()
        self.mHasError = False

    @staticmethod
    def __errtype_to_string(err_v: _RenameErrorType) -> str:
        match(err_v):
            case _RenameErrorType.ERROR: return 'ERROR'
            case _RenameErrorType.WARNING: return 'WARN'
            case _RenameErrorType.INFO: return 'INFO'
            case _: raise UTIL_functions.BBPException("Unknown error type.")
    @staticmethod
    def __erritem_to_string(item: _RenameErrorItem) -> str:
        return f'[{RenameErrorReporter.__errtype_to_string(item.mErrType)}]\t{item.mDescription}'

#endregion

#region Naming Convention Used Types

class BallanceObjectType(enum.IntEnum):
    COMPONENT = enum.auto()

    FLOOR = enum.auto()
    RAIL = enum.auto()
    WOOD = enum.auto()
    STOPPER = enum.auto()

    LEVEL_START = enum.auto()
    LEVEL_END = enum.auto()
    CHECKPOINT = enum.auto()
    RESETPOINT = enum.auto()

    DEPTH_CUBE = enum.auto()
    SKYLAYER = enum.auto()

    DECORATION = enum.auto()

class BallanceObjectInfo():
    mBasicType: BallanceObjectType

    ## Only available for COMPONENT basic type
    mComponentType: str | None
    ## Only available for COMPONENT, CHECKPOINT, RESETPOINT basic type
    #  For COMPONENT, it indicate which sector this component belong to.
    #  For CHECKPOINT, RESETPOINT, it indicate the index of this object.
    #  In CHECKPOINT, RESETPOINT mode, the sector actually is the suffix number of these objects' name. So checkpoint starts with 1, not 0.
    mSector: int | None

    def __init__(self, basic_type: BallanceObjectType):
        self.mBasicType = basic_type

    @classmethod
    def create_from_component(cls, comp_type: str, sector: int):
        inst = cls(BallanceObjectType.COMPONENT)
        inst.mComponentType = comp_type
        inst.mSector = sector
        return inst
    
    @classmethod
    def create_from_checkpoint(cls, sector: int):
        inst = cls(BallanceObjectType.CHECKPOINT)
        inst.mSector = sector
        return inst
    @classmethod
    def create_from_resetpoint(cls, sector: int):
        inst = cls(BallanceObjectType.RESETPOINT)
        inst.mSector = sector
        return inst

    @classmethod
    def create_from_others(cls, basic_type: BallanceObjectType):
        return cls(basic_type)

#endregion

#region Naming Convention Declaration

_g_BlcNormalComponents: set[str] = set((
    "P_Extra_Life",
    "P_Extra_Point",
    "P_Trafo_Paper",
    "P_Trafo_Stone",
    "P_Trafo_Wood",
    "P_Ball_Paper",
    "P_Ball_Stone",
    "P_Ball_Wood",
    "P_Box",
    "P_Dome",
    "P_Modul_01",
    "P_Modul_03",
    "P_Modul_08",
    "P_Modul_17",
    "P_Modul_18",
    "P_Modul_19",
    "P_Modul_25",
    "P_Modul_26",
    "P_Modul_29",
    "P_Modul_30",
    "P_Modul_34",
    "P_Modul_37",
    "P_Modul_41"
))
_g_BlcUniqueComponents: set[str] = set((
    "PS_Levelstart",
    "PE_Levelende",
    "PC_Checkpoints",
    "PR_Resetpoints"
))
_g_BlcFloor: set[str] = set((
    "Sound_HitID_01",
    "Sound_RollID_01"
))
_g_BlcWood: set[str] = set((
    "Sound_HitID_02",
    "Sound_RollID_02"
))

class VirtoolsGroupConvention():
    cRegexGroupSector: typing.ClassVar[re.Pattern] = re.compile('^Sector_(0[1-8]|[1-9][0-9]{1,2}|9)$')
    cRegexComponent: typing.ClassVar[re.Pattern] = re.compile('^(' + '|'.join(_g_BlcNormalComponents) + ')_(0[1-9]|[1-9][0-9])_.*$')
    cRegexPC: typing.ClassVar[re.Pattern] = re.compile('^PC_TwoFlames_(0[1-7])$')
    cRegexPR: typing.ClassVar[re.Pattern] = re.compile('^PR_Resetpoint_(0[1-8])$')

    @staticmethod
    def __get_pcpr_from_name(name: str, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None:
        regex_result = VirtoolsGroupConvention.cRegexPC.match(name)
        if regex_result is not None:
            return BallanceObjectInfo.create_from_checkpoint(
                int(regex_result.group(1))
            )
        regex_result = VirtoolsGroupConvention.cRegexPR.match(name)
        if regex_result is not None:
            return BallanceObjectInfo.create_from_resetpoint(
                int(regex_result.group(1))
            )
        
        if reporter: reporter.add_error("PC_Checkpoints or PR_Resetpoints detected. But couldn't get sector from name.")
        return None
    
    @staticmethod
    def __get_sector_from_groups(gps: typing.Iterator[str]) -> int | None:
        # this counter is served for stupid 
        # multi-sector-grouping accident.
        counter: int = 0
        last_matched_sector: int = 0
        for i in gps:
            regex_result = VirtoolsGroupConvention.cRegexGroupSector.match(i)
            if regex_result is not None:
                last_matched_sector = int(regex_result.group(1))
                counter += 1
                
        if counter != 1: return None
        else: return last_matched_sector

    @staticmethod
    def parse_from_object(obj: bpy.types.Object, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None:
        # create visitor
        with PROP_virtools_group.VirtoolsGroupsHelper(obj) as gp:
            # if no group, we should consider it is decoration or skylayer
            if gp.get_count() == 0:
                if obj.name == 'SkyLayer': return BallanceObjectInfo.create_from_others(BallanceObjectType.SKYLAYER)
                else: return BallanceObjectInfo.create_from_others(BallanceObjectType.DECORATION)

            # try to filter unique elements first
            inter_gps: set[str] = gp.intersect_groups(_g_BlcUniqueComponents)
            if len(inter_gps) == 1:
                # get it
                match((tuple(inter_gps))[0]):
                    case 'PS_Levelstart':
                        return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_START)
                    case 'PE_Levelende':
                        return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_END)
                    case 'PC_Checkpoints' | 'PR_Resetpoints':
                        # these type's data should be gotten from its name
                        return VirtoolsGroupConvention.__get_pcpr_from_name(obj.name, reporter)
                    case _:
                        if reporter: reporter.add_error("The match of Unique Component lost.")
                        return None
            elif len(inter_gps) != 0:
                if reporter: reporter.add_error("A Multi-grouping Unique Component.")
                return None
            
            # distinguish normal elements
            inter_gps = gp.intersect_groups(_g_BlcNormalComponents)
            if len(inter_gps) == 1:
                # get it
                # now try get its sector
                gotten_elements: str = (tuple(inter_gps))[0]
                gotten_sector: int | None = VirtoolsGroupConvention.__get_sector_from_groups(gp.iterate_groups())
                if gotten_sector is None:
                    # fail to get sector
                    if reporter: reporter.add_error("Component detected. But couldn't get sector from CKGroup data.")
                    return None
                return BallanceObjectInfo.create_from_component(
                    gotten_elements,
                    gotten_sector
                )
            elif len(inter_gps) != 0:
                # must be a weird grouping, report it
                if reporter: reporter.add_error("A Multi-grouping Component.")
                return None

            # distinguish road
            if gp.contain_group('Phys_FloorRails'):
                # rail
                return BallanceObjectInfo.create_from_others(BallanceObjectType.RAIL)
            elif gp.contain_group('Phys_Floors'):
                # distinguish it between Floor and Wood
                floor_result = gp.intersect_groups(_g_BlcFloor)
                rail_result = gp.intersect_groups(_g_BlcWood)
                if len(floor_result) > 0 and len(rail_result) == 0:
                    return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR)
                elif len(floor_result) == 0 and len(rail_result) > 0:
                    return BallanceObjectInfo.create_from_others(BallanceObjectType.WOOD)
                else:
                    if reporter: reporter.add_warning("Can't distinguish object between Floors and Rails. Suppose it is Floors.")
                    return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR)
            elif gp.contain_group('Phys_FloorStopper'):
                return BallanceObjectInfo.create_from_others(BallanceObjectType.STOPPER)
            elif gp.contain_group('DepthTestCubes'):
                return BallanceObjectInfo.create_from_others(BallanceObjectType.DEPTH_CUBE)

            # no matched
            if reporter: reporter.add_error("Group match lost.")
            return None

    @staticmethod
    def set_to_object(obj: bpy.types.Object, info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> bool:
        # create visitor
        with PROP_virtools_group.VirtoolsGroupsHelper(obj) as gp:
            # match by basic type
            match(info.mBasicType):
                case BallanceObjectType.DECORATION: pass    # decoration do not need group
                case BallanceObjectType.SKYLAYER: pass  # sky layer do not need group
                
                case BallanceObjectType.LEVEL_START:
                    gp.add_group('PS_Levelstart')
                case BallanceObjectType.LEVEL_END:
                    gp.add_group('PE_Levelende')
                case BallanceObjectType.CHECKPOINT:
                    gp.add_group('PC_Checkpoints')
                case BallanceObjectType.RESETPOINT:
                    gp.add_group('PR_Resetpoints')

                case BallanceObjectType.DEPTH_CUBE:
                    gp.add_group('PE_Levelende')

                case BallanceObjectType.FLOOR:
                    gp.add_group('Phys_Floors')
                    gp.add_group('Sound_HitID_01')
                    gp.add_group('Sound_RollID_01')
                case BallanceObjectType.RAIL:
                    gp.add_group('Phys_FloorRails')
                    gp.add_group('Sound_HitID_02')
                    gp.add_group('Sound_RollID_02')
                case BallanceObjectType.WOOD:
                    gp.add_group('Phys_Floors')
                    gp.add_group('Sound_HitID_03')
                    gp.add_group('Sound_RollID_03')
                case BallanceObjectType.STOPPER:
                    gp.add_group('Phys_FloorStopper')

                case BallanceObjectType.COMPONENT:
                    # group into component type
                    # use typing.cast() to force linter accept it because None is impossible
                    gp.add_group(typing.cast(str, info.mComponentType))

                    # group to sector
                    if info.mSector == 9:
                        gp.add_group('Sector_9')
                    else:
                        gp.add_group(f'Sector_{info.mSector:0>2d}')

                case _:
                    if reporter is not None:
                        reporter.add_error('No matched info.')
                    return False
                
        return True

class YYCToolchainConvention():
    @staticmethod
    def parse_from_name(name: str, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None:
        # check component first
        regex_result = VirtoolsGroupConvention.cRegexComponent.match(name)  # use vt one because they are same
        if regex_result is not None:
            return BallanceObjectInfo.create_from_component(
                regex_result.group(1),
                int(regex_result.group(2))
            )

        # check PC PR elements
        regex_result = VirtoolsGroupConvention.cRegexPC.match(name) # use vt one because they are same
        if regex_result is not None:
            return BallanceObjectInfo.create_from_checkpoint(
                int(regex_result.group(1))
            )
        regex_result = VirtoolsGroupConvention.cRegexPR.match(name) # use vt one because they are same
        if regex_result is not None:
            return BallanceObjectInfo.create_from_resetpoint(
                int(regex_result.group(1))
            )

        # check other unique elements
        if name == "PS_FourFlames_01":
            return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_START)
        if name == "PE_Balloon_01":
            return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_END)

        # process floors
        if name.startswith("A_Floor"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR)
        if name.startswith("A_Rail"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.RAIL)
        if name.startswith("A_Wood"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.WOOD)
        if name.startswith("A_Stopper"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.STOPPER)

        # process others
        if name.startswith("DepthCubes"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.DEPTH_CUBE)
        if name.startswith("D_"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.DECORATION)
        if name == 'SkyLayer':
            return BallanceObjectInfo.create_from_others(BallanceObjectType.SKYLAYER)

        if reporter is not None:
            reporter.add_error("Name match lost.")
        return None


    @staticmethod
    def parse_from_object(obj: bpy.types.Object, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None:
        return YYCToolchainConvention.parse_from_name(obj.name, reporter)
        
    @staticmethod
    def set_to_name(info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> str | None:
        match(info.mBasicType):
            case BallanceObjectType.DECORATION:
                return 'D_'
            case BallanceObjectType.SKYLAYER:
                return 'SkyLayer'
            
            case BallanceObjectType.LEVEL_START:
                return 'PS_FourFlames_01'
            case BallanceObjectType.LEVEL_END:
                return 'PE_Balloon_01'
            case BallanceObjectType.CHECKPOINT:
                return f'PR_Resetpoint_{info.mSector:0>2d}'
            case BallanceObjectType.RESETPOINT:
                return f'PC_TwoFlames_{info.mSector:0>2d}'

            case BallanceObjectType.DEPTH_CUBE:
                return 'DepthCubes_'

            case BallanceObjectType.FLOOR:
                return 'A_Floor_'
            case BallanceObjectType.RAIL:
                return 'A_Wood_'
            case BallanceObjectType.WOOD:
                return 'A_Rail_'
            case BallanceObjectType.STOPPER:
                return 'A_Stopper_'

            case BallanceObjectType.COMPONENT:
                return '{}_{:0>2d}_'.format(
                    info.mComponentType, info.mSector)
                
            case _:
                if reporter is not None:
                    reporter.add_error('No matched info.')
                return None
            
    @staticmethod
    def set_to_object(obj: bpy.types.Object, info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> bool:
        expect_name: str | None = YYCToolchainConvention.set_to_name(info, reporter)
        if expect_name is None: return False

        obj.name = expect_name
        return True

class ImengyuConvention():
    cRegexComponent: typing.ClassVar[re.Pattern] = re.compile('^(' + '|'.join(_g_BlcNormalComponents) + '):[^:]*:([1-9]|[1-9][0-9])$')
    cRegexPC: typing.ClassVar[re.Pattern] = re.compile('^PC_CheckPoint:([0-9]+)$')
    cRegexPR: typing.ClassVar[re.Pattern] = re.compile('^PR_ResetPoint:([0-9]+)$')

    @staticmethod
    def parse_from_name(name: str, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None:
        # check component first
        regex_result = ImengyuConvention.cRegexComponent.match(name)
        if regex_result is not None:
            return BallanceObjectInfo.create_from_component(
                regex_result.group(1),
                int(regex_result.group(2))
            )

        # check PC PR elements
        regex_result = ImengyuConvention.cRegexPC.match(name)
        if regex_result is not None:
            return BallanceObjectInfo.create_from_checkpoint(
                int(regex_result.group(1))
            )
        regex_result = ImengyuConvention.cRegexPR.match(name)
        if regex_result is not None:
            return BallanceObjectInfo.create_from_resetpoint(
                int(regex_result.group(1))
            )

        # check other unique elements
        if name == "PS_LevelStart":
            return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_START)
        if name == "PE_LevelEnd":
            return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_END)

        # process floors
        if name.startswith("S_Floors"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR)
        if name.startswith("S_FloorRails"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.RAIL)
        if name.startswith("S_FloorWoods"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.WOOD)
        if name.startswith("S_FloorStopper"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.STOPPER)

        # process others
        if name.startswith("DepthTestCubes"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.DEPTH_CUBE)
        if name.startswith("O_"):
            return BallanceObjectInfo.create_from_others(BallanceObjectType.DECORATION)
        if name == 'SkyLayer':
            return BallanceObjectInfo.create_from_others(BallanceObjectType.SKYLAYER)

        if reporter is not None:
            reporter.add_error("Name match lost.")
        return None

    @staticmethod
    def parse_from_object(obj: bpy.types.Object, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None:
        return ImengyuConvention.parse_from_name(obj.name, reporter)

    @staticmethod
    def set_to_name(info: BallanceObjectInfo, oldname: str | None, reporter: RenameErrorReporter | None) -> str | None:
        match(info.mBasicType):
            case BallanceObjectType.DECORATION:
                return 'O_'
            case BallanceObjectType.SKYLAYER:
                return 'SkyLayer'
            
            case BallanceObjectType.LEVEL_START:
                return 'PS_LevelStart'
            case BallanceObjectType.LEVEL_END:
                return 'PE_LevelEnd'
            case BallanceObjectType.CHECKPOINT:
                return f'PR_ResetPoint:{info.mSector:d}'
            case BallanceObjectType.RESETPOINT:
                return f'PC_CheckPoint:{info.mSector:d}'

            case BallanceObjectType.DEPTH_CUBE:
                return 'DepthTestCubes'

            case BallanceObjectType.FLOOR:
                return 'S_Floors'
            case BallanceObjectType.RAIL:
                return 'S_FloorWoods'
            case BallanceObjectType.WOOD:
                return 'S_FloorRails'
            case BallanceObjectType.STOPPER:
                return 'S_FloorStopper'

            case BallanceObjectType.COMPONENT:
                return '{}:{}:{:d}'.format(
                    info.mComponentType, 
                    oldname.replace(':', '_') if oldname is not None else '', 
                    info.mSector
                )
                
            case _:
                if reporter is not None:
                    reporter.add_error('No matched info.')
                return None
            
    @staticmethod
    def set_to_object(obj: bpy.types.Object, info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> bool:
        expect_name: str | None = ImengyuConvention.set_to_name(info, obj.name, reporter)
        if expect_name is None: return False

        obj.name = expect_name
        return True


#endregion
