import os
import platform
import re
import csv

if platform.system() == "Windows":
    os.system("")  # 让 \033 转义在 Windows 上生效


class Mahjong:

    @staticmethod
    def hand_code_to_list(hand_code: str) -> list:
        res = re.findall(r'(\d+[mpsz])', hand_code, re.M | re.I)
        assert res, f'输入的手牌代码({hand_code})错误'
        tile_code_list = []
        for item in res:
            tile_code_list.extend([t + item[-1:] for t in item[:-1]])
        hand_list = [0] * 34
        for tile_code in tile_code_list:
            if tile_code not in ['0z', '8z', '9z']:
                index = ['m', 'p', 's', 'z'].index(tile_code[1]) * 9
                index += 4 if tile_code[0] == '0' else int(tile_code[0]) - 1
                hand_list[index] += 1
        return hand_list

    @staticmethod
    def mj_map_hand_to_count(hand_code: str) -> str:
        return ''.join(str(i) for i in Mahjong.hand_code_to_list(hand_code))

    @staticmethod
    def _save_dfs_result(walk_path: list, dfs_result: list) -> bool:
        # 注意 Python 传列表/字典时传的是引用
        temp_list = walk_path[:]  # 深拷贝 防止排序影响原来有顺序的路径
        temp_list.sort()  # 排序以便于去重 因为最后取面子时 取的是无序的*组合* 而不是有序的*排列*
        temp_str = ','.join(temp_list)  # 序列化 以便保存
        if temp_str != '' and temp_str not in dfs_result:  # 确定没有重复 添加到结果
            dfs_result.append(temp_str)  # 直接对实参进行修改
            return True
        return False

    @staticmethod
    def mj_remove_shuntsu(count_map_str: str, shuntsu_index: int) -> tuple[str, bool]:
        """取出指定的一组顺子
        - 由于 Python 不能自选是否传递引用
        - 这里使用元组返回处理后的字符串来模拟
        - @return 成功/失败
        """
        temp_list = [int(count_map_str[shuntsu_index + i])
                     for i in range(3)]
        if min(temp_list) > 0:  # 该位置存在顺子
            return (
                count_map_str[:shuntsu_index] +
                ''.join([str(n - 1) for n in temp_list]) +
                count_map_str[shuntsu_index + 3:],
                True
            )
        return (count_map_str, False)

    @staticmethod
    def mj_remove_koutsu(count_map_str: str, koutsu_index: int) -> tuple[str, bool]:
        """取出指定的一组刻子
        - 由于 Python 不能自选是否传递引用
        - 这里使用元组返回处理后的字符串来模拟
        - @return 成功/失败
        """
        if int(count_map_str[koutsu_index]) >= 3:  # 该位置存在刻子
            return (
                count_map_str[:koutsu_index] +
                str(int(count_map_str[koutsu_index]) - 3) +
                count_map_str[koutsu_index + 1:],
                True
            )
        return (count_map_str, False)

    @staticmethod
    def mj_dfs_meld(hand_code: str, allow_decrease_shanten: bool = True) -> list[str]:
        """DFS 获得所有可能的最大面子组合
        - 以及次大的面子组合(以支持向听倒退)
        """
        count_map_str = Mahjong.mj_map_hand_to_count(hand_code)

        def _get_meld_possibility(count_map_str: str) -> list:
            count_map = [int(t) for t in count_map_str]
            meld_possibility = []
            for i in range(3):
                for j in range(7):
                    index = i * 9 + j
                    if min([count_map[index], count_map[index + 1], count_map[index + 2]]) > 0:
                        meld_possibility.append(f's{index:0>2d}')
            for i in range(len(count_map)):
                if count_map[i] >= 3:
                    meld_possibility.append(f'k{i:0>2d}')
            return meld_possibility

        # 全大写变量均为模拟 Emuera 的 ERH 全局变量
        MJ_DFS_STATUS_STACK: list[str] = [count_map_str]  # 手牌状态栈
        MJ_DFS_TREE_NODE: list[str] = _get_meld_possibility(
            count_map_str)  # 树的节点(所有可能组成面子的方式)
        MJ_DFS_MAX_DEPTH: int = sum(
            [int(t)for t in count_map_str]) // 3  # 最大深度
        MJ_DFS_WALK_PATH: list[str] = []  # 遍历路径
        MJ_DFS_RESULT: list[str] = []  # DFS 结果

        def _dfs(depth: int) -> None:
            if len(MJ_DFS_WALK_PATH) >= MJ_DFS_MAX_DEPTH:  # 达到最大深度
                Mahjong._save_dfs_result(MJ_DFS_WALK_PATH, MJ_DFS_RESULT)
                return  # 直接终止本层探索

            skip_count: int = 0  # 当前层数剪掉的分支
            for tree_node in MJ_DFS_TREE_NODE:  # 遍历当前层数的所有节点
                for _ in range(len(MJ_DFS_STATUS_STACK) - depth - 1):
                    MJ_DFS_STATUS_STACK.pop()  # 舍弃本层的上一轮探索 回退到当前层数的手牌状态
                hand_status: str = MJ_DFS_STATUS_STACK[depth]  # 初始化当前层数的手牌状态
                remove_success: bool = False
                for _ in range(len(MJ_DFS_WALK_PATH) - depth):
                    MJ_DFS_WALK_PATH.pop()  # 舍弃本层的上一轮探索 回退到当前层数的节点路径
                target_index = int(tree_node[1:])  # 获取标记的下标
                if tree_node[0] == 's':  # 尝试去掉指定的顺子
                    hand_status, remove_success = Mahjong.mj_remove_shuntsu(
                        hand_status, target_index)
                elif tree_node[0] == 'k':  # 尝试去掉指定的刻子
                    hand_status, remove_success = Mahjong.mj_remove_koutsu(
                        hand_status, target_index)
                if remove_success:  # 成功
                    MJ_DFS_WALK_PATH.append(tree_node)  # 添加到路径
                else:  # 失败
                    skip_count += 1  # 剪掉在此基础上的分支
                    if skip_count >= len(MJ_DFS_TREE_NODE):  # 本层所有分支都被剪掉了
                        Mahjong._save_dfs_result(
                            MJ_DFS_WALK_PATH, MJ_DFS_RESULT)
                    continue
                MJ_DFS_STATUS_STACK.append(hand_status)  # 保存更新后的手牌状态
                _dfs(depth + 1)  # 进入下一层递归

            return

        _dfs(0)  # 启动 DFS

        if allow_decrease_shanten:
            # 尝试向听倒退(为了更广的进张)
            # 比如     234 6  进张只有 6 (3枚)
            # 可以拆成 23 46  进张变成 1/4/5 (11枚)
            meld_max_count = 0
            for walk_path_str in MJ_DFS_RESULT:
                item = walk_path_str.split(',')
                count = len(item)
                if count > meld_max_count:
                    meld_max_count = count
            for walk_path_str in MJ_DFS_RESULT:
                item = walk_path_str.split(',')
                if len(item) == meld_max_count:
                    for i in range(len(item)):
                        # 遍历无序组合 C(max_got_depth, max_got_depth - 1)
                        temp = item[:]
                        temp.pop(i)
                        Mahjong._save_dfs_result(temp, MJ_DFS_RESULT)

        return MJ_DFS_RESULT

    @staticmethod
    def mj_remove_tatsu_a(count_map_str: str, tatsu_index: int) -> tuple[str, bool]:
        temp_list = [int(count_map_str[tatsu_index + i]) for i in range(2)]
        if min(temp_list) > 0:
            return (
                count_map_str[:tatsu_index] +
                ''.join([str(n - 1) for n in temp_list]) +
                count_map_str[tatsu_index + 2:],
                True
            )
        return (count_map_str, False)

    @staticmethod
    def mj_remove_tatsu_b(count_map_str: str, tatsu_index: int) -> tuple[str, bool]:
        temp_list = [int(count_map_str[tatsu_index + i]) for i in range(3)]
        if min(temp_list[0], temp_list[2]) > 0:
            return (
                count_map_str[:tatsu_index] +
                f'{temp_list[0] - 1}{count_map_str[tatsu_index + 1]}{temp_list[2] - 1}' +
                count_map_str[tatsu_index + 3:],
                True
            )
        return (count_map_str, False)

    @staticmethod
    def mj_remove_toitsu(count_map_str: str, jantou_index: int) -> tuple[str, bool]:
        if int(count_map_str[jantou_index]) >= 2:
            return (
                count_map_str[:jantou_index] +
                str(int(count_map_str[jantou_index]) - 2) +
                count_map_str[jantou_index + 1:],
                True
            )
        return (count_map_str, False)

    @staticmethod
    def mj_dfs_tatsu_and_toitsu(count_map_str: str) -> list:

        def _get_tatsu_and_toitsu_possibility(count_map_str: str) -> list:
            count_map = [int(t) for t in count_map_str]
            tatsu_and_toitsu_possibility = []
            for i in range(3):
                for j in range(8):
                    index = i * 9 + j
                    if min([count_map[index], count_map[index + 1]]) > 0:
                        tatsu_and_toitsu_possibility.append(f'a{index:0>2d}')
            for i in range(3):
                for j in range(7):
                    index = i * 9 + j
                    if min([count_map[index], count_map[index + 2]]) > 0:
                        tatsu_and_toitsu_possibility.append(f'b{index:0>2d}')
            for i in range(len(count_map)):
                if count_map[i] >= 2:
                    tatsu_and_toitsu_possibility.append(f'c{i:0>2d}')
            return tatsu_and_toitsu_possibility

        MJ_DFS_STATUS_STACK: list[str] = [count_map_str]
        MJ_DFS_TREE_NODE: list[str] = _get_tatsu_and_toitsu_possibility(
            count_map_str)
        MJ_DFS_MAX_DEPTH: int = sum([int(t)for t in count_map_str]) // 2
        MJ_DFS_WALK_PATH: list[str] = []
        MJ_DFS_RESULT: list[str] = []

        def _dfs(depth):
            if len(MJ_DFS_WALK_PATH) >= MJ_DFS_MAX_DEPTH:
                Mahjong._save_dfs_result(MJ_DFS_WALK_PATH, MJ_DFS_RESULT)
                return

            skip_count: int = 0
            for tree_node in MJ_DFS_TREE_NODE:
                for _ in range(len(MJ_DFS_STATUS_STACK) - depth - 1):
                    MJ_DFS_STATUS_STACK.pop()
                hand_status: str = MJ_DFS_STATUS_STACK[depth]
                remove_success: bool = False
                for _ in range(len(MJ_DFS_WALK_PATH) - depth):
                    MJ_DFS_WALK_PATH.pop()
                target_index: int = int(tree_node[1:])
                if tree_node[0] == 'a':
                    hand_status, remove_success = Mahjong.mj_remove_tatsu_a(
                        hand_status, target_index)
                elif tree_node[0] == 'b':
                    hand_status, remove_success = Mahjong.mj_remove_tatsu_b(
                        hand_status, target_index)
                elif tree_node[0] == 'c':
                    hand_status, remove_success = Mahjong.mj_remove_toitsu(
                        hand_status, target_index)
                if remove_success:
                    MJ_DFS_WALK_PATH.append(tree_node)
                else:
                    skip_count += 1
                    if skip_count >= len(MJ_DFS_TREE_NODE):
                        Mahjong._save_dfs_result(
                            MJ_DFS_WALK_PATH, MJ_DFS_RESULT)
                    continue
                MJ_DFS_STATUS_STACK.append(hand_status)
                _dfs(depth + 1)
        _dfs(0)
        return MJ_DFS_RESULT

    @staticmethod
    def mj_count_shanten_for_regular(
        melds_count: int,  # 面子数量
        tatsu_and_toitsu_count: int,  # 搭子和对子数量
        jantou_flag: int,  # 是否有雀头
    ) -> int:
        overload = max(melds_count + tatsu_and_toitsu_count - 5, 0)
        if melds_count + tatsu_and_toitsu_count <= 4:
            jantou_flag = 1
        return 9 - (melds_count * 2) - tatsu_and_toitsu_count + overload - jantou_flag

    @staticmethod
    def _count_map_to_hand_code(count_map_str: str) -> str:
        hand_code = []
        for i in range(len(count_map_str)):
            tile_code = str(i % 9 + 1) + ['m', 'p', 's', 'z'][i // 9]
            for _ in range(int(count_map_str[i])):
                hand_code.append(tile_code)
        return ''.join(hand_code)

    @staticmethod
    def _get_melds(melds_str: str) -> str:
        if melds_str == '':
            return ''
        suit = ['m', 'p', 's', 'z']
        hand_code = []
        for meld in melds_str.split(','):
            i = int(meld[1:])
            melds = [str(i % 9 + 1) + suit[i // 9]]
            if meld[0] == 's':
                for j in [1, 2]:
                    index = i + j
                    melds.append(str(index % 9 + 1) + suit[index // 9])
            elif meld[0] == 'k':
                melds = melds * 3
            hand_code.append(''.join(melds))
        return ','.join(hand_code)

    @staticmethod
    def _get_tatsu_and_toitsu(tatsu_and_toitsu_str: str) -> str:
        if tatsu_and_toitsu_str == '':
            return ''
        suit = ['m', 'p', 's', 'z']
        hand_code = []
        for tt in tatsu_and_toitsu_str.split(','):
            i = int(tt[1:])
            c1 = str(i % 9 + 1) + suit[i // 9]
            if tt[0] == 'a':
                i += 1
                c2 = str(i % 9 + 1) + suit[i // 9]
            elif tt[0] == 'b':
                i += 2
                c2 = str(i % 9 + 1) + suit[i // 9]
            elif tt[0] == 'c':
                c2 = str(i % 9 + 1) + suit[i // 9]
            hand_code.append(f'{c1}{c2}')
        return ','.join(hand_code)

    def analyse(
        hand_code: str,
        melds_str: str,
        hand_map_str_without_melds: str,
        tatsu_and_toitsu_str: str,
        hand_map_str_without_tatsu_and_toitsu: str,
        shanten: int,
    ) -> None:
        # print(
        #     f'- {Mahjong._get_melds(melds_str):28}',
        #     f'- {Mahjong._get_tatsu_and_toitsu(tatsu_and_toitsu_str):28}',
        # )
        print(
            f'{Mahjong._count_map_to_hand_code(hand_map_str_without_melds):30}',
            f'{Mahjong._count_map_to_hand_code(hand_map_str_without_tatsu_and_toitsu):30}',
            shanten,
        )
        return

    @staticmethod
    def mj_calculate_shanten_for_regular(hand_code: str, analyse_result: bool = False) -> int:

        # TODO 临时处理小于 13 张的手牌
        fuuro_count = (14 - sum(Mahjong.hand_code_to_list(hand_code))) // 3
        hand_code += '7z7z7z' * fuuro_count

        hand_map_str = Mahjong.mj_map_hand_to_count(hand_code)

        def _clear_melds(count_map_str: str, melds_str: str) -> str:
            if melds_str == '':
                return count_map_str
            melds = melds_str.split(',')
            for meld in melds:
                if meld[0] == 's':
                    count_map_str, res = Mahjong.mj_remove_shuntsu(
                        count_map_str, int(meld[1:]))
                    assert res, f'{Mahjong._count_map_to_hand_code(count_map_str)} 去掉顺子 {Mahjong._get_melds(meld)} 失败'
                elif meld[0] == 'k':
                    count_map_str, res = Mahjong.mj_remove_koutsu(
                        count_map_str, int(meld[1:]))
                    assert res, f'{Mahjong._count_map_to_hand_code(count_map_str)} 去掉刻子 {Mahjong._get_melds(meld)} 失败'
            return count_map_str

        def _clear_tatsu_and_toitsu(count_map_str: str, tatsu_and_toitsu_str: str) -> str:
            tatsu_and_toitsu = tatsu_and_toitsu_str.split(',')
            for tt in tatsu_and_toitsu:
                if tt[0] == 'a':
                    count_map_str, res = Mahjong.mj_remove_tatsu_a(
                        count_map_str, int(tt[1:]))
                    assert res, f'{Mahjong._count_map_to_hand_code(count_map_str)} 去掉 11 型搭子 {Mahjong._get_tatsu_and_toitsu(tt)} 失败'
                elif tt[0] == 'b':
                    count_map_str, res = Mahjong.mj_remove_tatsu_b(
                        count_map_str, int(tt[1:]))
                    assert res, f'{Mahjong._count_map_to_hand_code(count_map_str)} 去掉 101 型搭子 {Mahjong._get_tatsu_and_toitsu(tt)} 失败'
                elif tt[0] == 'c':
                    count_map_str, res = Mahjong.mj_remove_toitsu(
                        count_map_str, int(tt[1:]))
                    assert res, f'{Mahjong._count_map_to_hand_code(count_map_str)} 去掉对子 {Mahjong._get_tatsu_and_toitsu(tt)} 失败'
            return count_map_str

        shanten_list = []
        all_melds = Mahjong.mj_dfs_meld(hand_code)
        if len(all_melds) == 0:
            all_melds = ['']

        if analyse_result:
            print(hand_code, '(原手牌)')
            print(f'{"去除面子后":25} {"进一步去除搭子和对子后":19} 向听数')

        for melds_str in all_melds:
            args = [melds_str.count(',') + 1, 0, 0]
            hand_map_str_without_melds = _clear_melds(hand_map_str, melds_str)
            all_tatsu_and_toitsu = Mahjong.mj_dfs_tatsu_and_toitsu(
                hand_map_str_without_melds)
            if len(all_tatsu_and_toitsu) == 0:
                shanten = Mahjong.mj_count_shanten_for_regular(args[0], 0, 0)
                shanten_list.append(shanten)
                if analyse_result:
                    Mahjong.analyse(
                        hand_code,
                        melds_str,
                        hand_map_str_without_melds,
                        '',
                        hand_map_str_without_melds,
                        shanten,
                    )
            for tatsu_and_toitsu_str in all_tatsu_and_toitsu:
                temp = hand_map_str_without_melds
                tatsu_and_toitsu = tatsu_and_toitsu_str.split(',')
                args[1] = len(tatsu_and_toitsu)
                for tt in tatsu_and_toitsu:
                    if tt[0] == 'c':
                        args[2] = 1
                        break
                temp = _clear_tatsu_and_toitsu(temp, tatsu_and_toitsu_str)
                shanten = Mahjong.mj_count_shanten_for_regular(*args)
                shanten_list.append(shanten)
                if analyse_result:
                    Mahjong.analyse(
                        hand_code,
                        melds_str,
                        hand_map_str_without_melds,
                        tatsu_and_toitsu_str,
                        temp,
                        shanten,
                    )
        return min(shanten_list)

    @staticmethod
    def mj_calculate_shanten_for_chiitoitsu(hand_code: str) -> int:
        return 6 - len([n for n in Mahjong.hand_code_to_list(hand_code) if n >= 2])

    @staticmethod
    def mj_calculate_shanten_for_kokushi(hand_code: str) -> int:
        hand_count = Mahjong.hand_code_to_list(hand_code)
        yaochuu_index = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33]
        jantou_completed = len(
            [i for i in yaochuu_index if hand_count[i] >= 2]) > 0
        yaochuu_count = 0
        for i in yaochuu_index:
            yaochuu_count += hand_count[i] > 0
        return 13 - yaochuu_count - jantou_completed

    @staticmethod
    def mj_count_shanten(hand_code: str) -> int:
        assert sum(Mahjong.hand_code_to_list(
            hand_code)) % 3 == 1, f'输入的手牌({hand_code})数量错误'
        return min(
            Mahjong.mj_calculate_shanten_for_regular(hand_code, False),
            Mahjong.mj_calculate_shanten_for_chiitoitsu(hand_code),
            Mahjong.mj_calculate_shanten_for_kokushi(hand_code),
        )


class UnitTest:

    def load_test_case(self, file_name: str = r'shanten.csv') -> list:
        file_path = os.path.join(os.path.split(
            os.path.realpath(__file__))[0], r'unit_test\test_case', file_name)
        csv_rows = []
        try:
            with open(file_path, encoding='UTF-8-SIG') as f:
                csv_reader = csv.reader(f)
                for row in csv_reader:
                    if len(row) == 3:
                        csv_rows.append({
                            'name': row[2],
                            'hand': row[0],
                            'shanten': int(row[1]),
                        })
                    if len(row) == 2:
                        csv_rows.append(
                            {'hand': row[0], 'shanten': int(row[1])})
                    elif len(row) == 1:
                        csv_rows.append(row[0])
        except FileNotFoundError as e:
            print(f'{e}\n测试用例文件.csv 不存在')
            exit(2)
        return csv_rows

    def __init__(self) -> None:
        self.test_case = self.load_test_case()
        print('更多测试用例参见 https://tenhou.net/2/ (天凤牌理)')
        return

    def run(self) -> None:
        pass_count = 0
        for i, v in enumerate(self.test_case):
            test_name = v['name'] if 'name' in v else f'第 {i + 1} 次单元测试'
            print(f'\033[1;36m{i + 1 :>2}. {test_name}\033[0m')
            res = Mahjong.mj_count_shanten(v['hand'])
            test_result = f'    \033[37m{v["hand"]:28} 预期={v["shanten"]} 实际={res}\033[0m    '
            if res == v['shanten']:
                pass_count += 1
                test_result += '\033[1;32m' u'\u2713' + ' 测试通过' + '\033[0m'
            else:
                test_result += '\033[1;31m' + u'\u2717' + ' 测试未通过' + '\033[0m'
            print(test_result)
            # break  # debug
        print(
            f'所有单元测试完成, 总计通过 {pass_count}/{len(self.test_case)},',
            f'通过率为 {pass_count * 100 / len(self.test_case) :.2f}%'
        )
        return


def main() -> None:
    unit_test = UnitTest()
    unit_test.run()
    return


if __name__ == '__main__':
    main()
