from collections.abc import Collection
from typing import TypedDict

from mahjong.constants import AKA_DORAS, CHUN, HAKU, HATSU
from mahjong.hand_calculating.divider import HandDivider
from mahjong.hand_calculating.fu import FuCalculator, FuDetail
from mahjong.hand_calculating.hand_config import HandConfig
from mahjong.hand_calculating.hand_response import HandResponse
from mahjong.hand_calculating.scores import Aotenjou, ScoresCalculator, ScoresResult
from mahjong.hand_calculating.yaku import Yaku
from mahjong.meld import Meld
from mahjong.tile import TilesConverter
from mahjong.utils import build_dora_count_map, classify_hand_suits, count_dora_for_hand, plus_dora


class _CalculatedHand(TypedDict):
    cost: ScoresResult | None
    error: str | None
    hand_yaku: list[Yaku]
    han: int
    fu: int
    fu_details: list[FuDetail]


# suit bitmask: sou=1, pin=2, man=4
_ALL_SUITS_MASK = 7

_DEFAULT_CONFIG = HandConfig()


class HandCalculator:
    """
    Hand value estimator.

    Evaluate a winning hand's han, fu, yaku, and payment amounts. Accepts tiles in
    136-format, an optional :class:`~mahjong.hand_calculating.hand_config.HandConfig` for
    win conditions and rule variants, and optional melds and dora/ura-dora indicators. When multiple
    valid decompositions exist, the highest-scoring result is returned.
    """

    # basic hand validation
    ERR_NO_WINNING_TILE = "winning_tile_not_in_hand"
    """``win_tile`` index is not present in the ``tiles`` array."""
    ERR_HAND_NOT_WINNING = "hand_not_winning"
    """No valid decomposition exists; the hand is not a winning hand."""
    ERR_NO_YAKU = "no_yaku"
    """Hand can be decomposed but has zero han (no yaku applies)."""

    # riichi constraints
    ERR_OPEN_HAND_RIICHI = "open_hand_riichi_not_allowed"
    """Riichi declared on an open hand (open melds present)."""
    ERR_OPEN_HAND_DABURI = "open_hand_daburi_not_allowed"
    """Double riichi declared on an open hand."""
    ERR_IPPATSU_WITHOUT_RIICHI = "ippatsu_without_riichi_not_allowed"
    """Ippatsu claimed without riichi or double riichi."""

    # win-condition conflicts
    ERR_CHANKAN_WITH_TSUMO = "chankan_with_tsumo_not_allowed"
    """Chankan (robbing a kan) requires a ron win."""
    ERR_RINSHAN_WITHOUT_TSUMO = "rinshan_without_tsumo_not_allowed"
    """Rinshan kaihou (win after kan) requires a tsumo win."""
    ERR_HAITEI_WITHOUT_TSUMO = "haitei_without_tsumo_not_allowed"
    """Haitei raoyue (last-tile draw) requires a tsumo win."""
    ERR_HOUTEI_WITH_TSUMO = "houtei_with_tsumo_not_allowed"
    """Houtei raoyui (last-tile discard) requires a ron win."""
    ERR_HAITEI_WITH_RINSHAN = "haitei_with_rinshan_not_allowed"
    """Haitei and rinshan are mutually exclusive (different last-tile sources)."""
    ERR_HOUTEI_WITH_CHANKAN = "houtei_with_chankan_not_allowed"
    """Houtei and chankan are mutually exclusive."""

    # tenhou (blessing of heaven) constraints
    ERR_TENHOU_NOT_AS_DEALER = "tenhou_not_as_dealer_not_allowed"
    """Tenhou is exclusive to the dealer (East player)."""
    ERR_TENHOU_WITHOUT_TSUMO = "tenhou_without_tsumo_not_allowed"
    """Tenhou requires a tsumo win on the dealer's first draw."""
    ERR_TENHOU_WITH_MELD = "tenhou_with_meld_not_allowed"
    """Tenhou requires a closed hand with no declared melds."""

    # chiihou (blessing of earth) constraints
    ERR_CHIIHOU_AS_DEALER = "chiihou_as_dealer_not_allowed"
    """Chiihou is exclusive to non-dealer players."""
    ERR_CHIIHOU_WITHOUT_TSUMO = "chiihou_without_tsumo_not_allowed"
    """Chiihou requires a tsumo win on the player's first draw."""
    ERR_CHIIHOU_WITH_MELD = "chiihou_with_meld_not_allowed"
    """Chiihou requires a closed hand with no declared melds."""

    # renhou (blessing of man) constraints
    ERR_RENHOU_AS_DEALER = "renhou_as_dealer_not_allowed"
    """Renhou is exclusive to non-dealer players."""
    ERR_RENHOU_WITH_TSUMO = "renhou_with_tsumo_not_allowed"
    """Renhou requires a ron win before the player's first draw."""
    ERR_RENHOU_WITH_MELD = "renhou_with_meld_not_allowed"
    """Renhou requires a closed hand with no declared melds."""

    @staticmethod
    def estimate_hand_value(
        tiles: Collection[int],
        win_tile: int,
        melds: Collection[Meld] | None = None,
        dora_indicators: Collection[int] | None = None,
        config: HandConfig | None = None,
        scores_calculator_factory: type[ScoresCalculator] = ScoresCalculator,
        ura_dora_indicators: Collection[int] | None = None,
    ) -> HandResponse:
        """
        Estimate the point value of a winning hand.

        Validate the hand and win conditions, decompose the hand into all possible
        block combinations, evaluate yaku and fu for each decomposition, and return
        the highest-scoring result as a :class:`~mahjong.hand_calculating.hand_response.HandResponse`.

        Basic closed ron with tanyao:

        >>> from mahjong.hand_calculating.hand import HandCalculator
        >>> from mahjong.tile import TilesConverter
        >>> tiles = TilesConverter.string_to_136_array(man="22444", pin="333567", sou="444")
        >>> win_tile = TilesConverter.string_to_136_array(sou="4")[0]
        >>> result = HandCalculator.estimate_hand_value(tiles, win_tile)
        >>> result.han
        1
        >>> result.fu
        40
        >>> result.cost["main"]
        1300

        Tsumo win with riichi and pinfu:

        >>> from mahjong.hand_calculating.hand_config import HandConfig
        >>> tiles = TilesConverter.string_to_136_array(man="234789", pin="12345666")
        >>> win_tile = TilesConverter.string_to_136_array(pin="6")[0]
        >>> config = HandConfig(is_tsumo=True, is_riichi=True)
        >>> result = HandCalculator.estimate_hand_value(tiles, win_tile, config=config)
        >>> result.han
        3
        >>> result.fu
        20
        >>> result.cost["main"]
        1300
        >>> result.cost["additional"]
        700

        Dealer tsumo with riichi and ippatsu:

        >>> from mahjong.constants import EAST
        >>> config = HandConfig(is_tsumo=True, is_riichi=True, is_ippatsu=True, player_wind=EAST)
        >>> result = HandCalculator.estimate_hand_value(tiles, win_tile, config=config)
        >>> result.error is None
        True

        Open hand with melds and dora:

        >>> from mahjong.meld import Meld
        >>> tiles = TilesConverter.string_to_136_array(man="234567", pin="22", sou="234", honors="555")
        >>> win_tile = TilesConverter.string_to_136_array(man="7")[0]
        >>> melds = [Meld(meld_type=Meld.PON, tiles=TilesConverter.string_to_136_array(honors="555"))]
        >>> dora_indicators = [TilesConverter.string_to_136_array(man="6")[0]]
        >>> result = HandCalculator.estimate_hand_value(tiles, win_tile, melds=melds, dora_indicators=dora_indicators)
        >>> result.han
        2

        Invalid hand returns an error:

        >>> tiles = TilesConverter.string_to_136_array(man="12345")
        >>> win_tile = TilesConverter.string_to_136_array(man="1")[0]
        >>> result = HandCalculator.estimate_hand_value(tiles, win_tile)
        >>> result.error == HandCalculator.ERR_HAND_NOT_WINNING
        True

        :param tiles: hand tiles in 136-format (14 tiles including the winning tile;
            15 with one kan, 16 with two, etc.)
        :param win_tile: the winning tile index in 136-format (must be present in ``tiles``)
        :param melds: declared melds (:class:`~mahjong.meld.Meld` objects for chi, pon, kan)
        :param dora_indicators: dora indicator tile indices in 136-format
        :param config: hand configuration with win conditions, wind context, and optional rules;
            defaults to a closed ron with no special conditions
        :param scores_calculator_factory: scoring calculator class; pass
            :class:`~mahjong.hand_calculating.scores.Aotenjou` for aotenjou (limitless) scoring
        :param ura_dora_indicators: ura dora indicator tile indices in 136-format
            (counted only when riichi or double riichi is declared)
        :return: :class:`~mahjong.hand_calculating.hand_response.HandResponse` with scoring
            details on success, or with :attr:`~mahjong.hand_calculating.hand_response.HandResponse.error`
            set on failure
        """
        if not melds:
            melds = []

        if not dora_indicators:
            dora_indicators = []

        if not ura_dora_indicators:
            ura_dora_indicators = []

        config = config or _DEFAULT_CONFIG

        hand_yaku = []
        scores_calculator = scores_calculator_factory()
        tiles_34 = TilesConverter.to_34_array(tiles)

        is_aotenjou = isinstance(scores_calculator, Aotenjou)

        opened_melds = [x.tiles_34 for x in melds if x.opened]
        is_open_hand = len(opened_melds) > 0

        # special situation
        if config.is_nagashi_mangan:
            hand_yaku.append(config.yaku.nagashi_mangan)
            fu = 30
            han = config.yaku.nagashi_mangan.han_closed
            cost = scores_calculator.calculate_scores(han, fu, config, False)
            return HandResponse(cost, han, fu, hand_yaku)

        if win_tile not in tiles:
            return HandResponse(error=HandCalculator.ERR_NO_WINNING_TILE)

        if config.is_riichi and not config.is_daburu_riichi and is_open_hand:
            return HandResponse(error=HandCalculator.ERR_OPEN_HAND_RIICHI)

        if config.is_daburu_riichi and is_open_hand:
            return HandResponse(error=HandCalculator.ERR_OPEN_HAND_DABURI)

        if config.is_ippatsu and not config.is_riichi and not config.is_daburu_riichi:
            return HandResponse(error=HandCalculator.ERR_IPPATSU_WITHOUT_RIICHI)

        if config.is_chankan and config.is_tsumo:
            return HandResponse(error=HandCalculator.ERR_CHANKAN_WITH_TSUMO)

        if config.is_rinshan and not config.is_tsumo:
            return HandResponse(error=HandCalculator.ERR_RINSHAN_WITHOUT_TSUMO)

        if config.is_haitei and not config.is_tsumo:
            return HandResponse(error=HandCalculator.ERR_HAITEI_WITHOUT_TSUMO)

        if config.is_houtei and config.is_tsumo:
            return HandResponse(error=HandCalculator.ERR_HOUTEI_WITH_TSUMO)

        if config.is_haitei and config.is_rinshan:
            return HandResponse(error=HandCalculator.ERR_HAITEI_WITH_RINSHAN)

        if config.is_houtei and config.is_chankan:
            return HandResponse(error=HandCalculator.ERR_HOUTEI_WITH_CHANKAN)

        # raise error only when player wind is defined (and is *not* EAST)
        if config.is_tenhou and config.player_wind and not config.is_dealer:
            return HandResponse(error=HandCalculator.ERR_TENHOU_NOT_AS_DEALER)

        if config.is_tenhou and not config.is_tsumo:
            return HandResponse(error=HandCalculator.ERR_TENHOU_WITHOUT_TSUMO)

        if config.is_tenhou and melds:
            return HandResponse(error=HandCalculator.ERR_TENHOU_WITH_MELD)

        # raise error only when player wind is defined (and is EAST)
        if config.is_chiihou and config.player_wind and config.is_dealer:
            return HandResponse(error=HandCalculator.ERR_CHIIHOU_AS_DEALER)

        if config.is_chiihou and not config.is_tsumo:
            return HandResponse(error=HandCalculator.ERR_CHIIHOU_WITHOUT_TSUMO)

        if config.is_chiihou and melds:
            return HandResponse(error=HandCalculator.ERR_CHIIHOU_WITH_MELD)

        # raise error only when player wind is defined (and is EAST)
        if config.is_renhou and config.player_wind and config.is_dealer:
            return HandResponse(error=HandCalculator.ERR_RENHOU_AS_DEALER)

        if config.is_renhou and config.is_tsumo:
            return HandResponse(error=HandCalculator.ERR_RENHOU_WITH_TSUMO)

        if config.is_renhou and melds:
            return HandResponse(error=HandCalculator.ERR_RENHOU_WITH_MELD)

        if not config.options.has_double_yakuman:
            config.yaku.daburu_kokushi.han_closed = 13
            config.yaku.suuankou_tanki.han_closed = 13
            config.yaku.daburu_chuuren_poutou.han_closed = 13
            config.yaku.daisuushi.han_closed = 13
            config.yaku.daisuushi.han_open = 13

        hand_options = HandDivider.divide_hand(tiles_34, melds)

        # precompute dora counts, invariant across all hand decompositions
        dora_count_map = build_dora_count_map(dora_indicators)
        precomputed_dora = count_dora_for_hand(tiles_34, dora_count_map)

        precomputed_aka_dora = 0
        if config.options.has_aka_dora:
            precomputed_aka_dora = sum(t in AKA_DORAS for t in tiles)

        precomputed_ura_dora = 0
        if config.is_riichi or config.is_daburu_riichi:
            ura_count_map = build_dora_count_map(ura_dora_indicators)
            precomputed_ura_dora = count_dora_for_hand(tiles_34, ura_count_map)

        yakuhai_seat_wind_yaku = (
            config.yaku.seat_wind_east,
            config.yaku.seat_wind_south,
            config.yaku.seat_wind_west,
            config.yaku.seat_wind_north,
        )
        yakuhai_round_wind_yaku = (
            config.yaku.round_wind_east,
            config.yaku.round_wind_south,
            config.yaku.round_wind_west,
            config.yaku.round_wind_north,
        )

        calculated_hands: list[_CalculatedHand] = []
        for hand in hand_options:
            is_chiitoitsu = config.yaku.chiitoitsu.is_condition_met(hand)
            valued_tiles = [HAKU, HATSU, CHUN, config.player_wind, config.round_wind]

            # precompute hand-level properties (invariant across win groups)
            # classify sets in single pass: chi (sequential), pon (triplet), kan (quad)
            chi_sets: list[list[int]] = []
            pon_sets: list[list[int]] = []
            kan_sets: list[list[int]] = []
            for x in hand:
                length = len(x)
                if length == 4:
                    kan_sets.append(x)
                elif length == 3:
                    if x[0] == x[1]:
                        pon_sets.append(x)
                    else:
                        chi_sets.append(x)

            is_tanyao_hand = config.yaku.tanyao.is_condition_met(hand)
            suit_mask, honor_count = classify_hand_suits(hand)
            has_honors = honor_count > 0

            win_groups = HandCalculator._find_win_groups(win_tile, hand, opened_melds)
            for win_group in win_groups:
                cost = None
                error = None
                hand_yaku = []
                han = 0

                fu_details, fu = FuCalculator.calculate_fu(hand, win_tile, win_group, config, valued_tiles, melds)

                is_pinfu = len(fu_details) == 1 and not is_chiitoitsu and not is_open_hand

                if config.is_tsumo and not is_open_hand:
                    hand_yaku.append(config.yaku.tsumo)

                if is_pinfu:
                    hand_yaku.append(config.yaku.pinfu)

                if is_chiitoitsu:
                    hand_yaku.append(config.yaku.chiitoitsu)

                if config.options.has_daisharin:
                    is_daisharin = config.yaku.daisharin.is_condition_met(
                        hand,
                        config.options.has_daisharin_other_suits,
                    )
                    if is_daisharin:
                        config.yaku.daisharin.rename(hand)
                        hand_yaku.append(config.yaku.daisharin)

                if config.options.has_daichisei and config.yaku.daichisei.is_condition_met(hand):
                    hand_yaku.append(config.yaku.daichisei)

                if (not is_open_hand or config.options.has_open_tanyao) and is_tanyao_hand:
                    hand_yaku.append(config.yaku.tanyao)

                if config.is_riichi and not config.is_daburu_riichi:
                    if config.is_open_riichi:
                        hand_yaku.append(config.yaku.open_riichi)
                    else:
                        hand_yaku.append(config.yaku.riichi)

                if config.is_daburu_riichi:
                    if config.is_open_riichi:
                        hand_yaku.append(config.yaku.daburu_open_riichi)
                    else:
                        hand_yaku.append(config.yaku.daburu_riichi)

                if (
                    not config.is_tsumo
                    and config.options.has_sashikomi_yakuman
                    and (config.yaku.daburu_open_riichi in hand_yaku or config.yaku.open_riichi in hand_yaku)
                ):
                    hand_yaku.append(config.yaku.sashikomi)

                if config.is_ippatsu:
                    hand_yaku.append(config.yaku.ippatsu)

                if config.is_rinshan:
                    hand_yaku.append(config.yaku.rinshan)

                if config.is_chankan:
                    hand_yaku.append(config.yaku.chankan)

                if config.is_haitei:
                    hand_yaku.append(config.yaku.haitei)

                if config.is_houtei:
                    hand_yaku.append(config.yaku.houtei)

                if config.is_renhou:
                    if config.options.renhou_as_yakuman:
                        hand_yaku.append(config.yaku.renhou_yakuman)
                    else:
                        hand_yaku.append(config.yaku.renhou)

                if config.is_tenhou:
                    hand_yaku.append(config.yaku.tenhou)

                if config.is_chiihou:
                    hand_yaku.append(config.yaku.chiihou)

                # chinitsu and honitsu are mutually exclusive
                if config.yaku.chinitsu.is_condition_met(hand):
                    hand_yaku.append(config.yaku.chinitsu)
                elif config.yaku.honitsu.is_condition_met(hand):
                    hand_yaku.append(config.yaku.honitsu)

                # tsuisou, honroto, chinroto require no chi sets (chi involves suited middle tiles)
                if not chi_sets:
                    if has_honors and config.yaku.tsuisou.is_condition_met(hand):
                        hand_yaku.append(config.yaku.tsuisou)

                    if not is_tanyao_hand:
                        if config.yaku.honroto.is_condition_met(hand):
                            hand_yaku.append(config.yaku.honroto)

                        if not has_honors and config.yaku.chinroto.is_condition_met(hand):
                            hand_yaku.append(config.yaku.chinroto)

                if config.yaku.ryuisou.is_condition_met(hand):
                    hand_yaku.append(config.yaku.ryuisou)

                if config.paarenchan > 0 and not config.options.paarenchan_needs_yaku:
                    # if no yaku is even needed to win on paarenchan and it is paarenchan condition, just add paarenchan
                    config.yaku.paarenchan.set_paarenchan_count(config.paarenchan)
                    hand_yaku.append(config.yaku.paarenchan)

                # small optimization, try to detect yaku with chi required sets only if we have chi sets in hand
                if chi_sets:
                    # chantai, junchan, ittsu require terminals, impossible with tanyao
                    if not is_tanyao_hand:
                        if config.yaku.chantai.is_condition_met(hand):
                            hand_yaku.append(config.yaku.chantai)

                        if config.yaku.junchan.is_condition_met(hand):
                            hand_yaku.append(config.yaku.junchan)

                        if config.yaku.ittsu.is_condition_met(hand):
                            hand_yaku.append(config.yaku.ittsu)

                    if not is_open_hand:
                        if config.yaku.ryanpeiko.is_condition_met(hand):
                            hand_yaku.append(config.yaku.ryanpeiko)
                        elif config.yaku.iipeiko.is_condition_met(hand):
                            hand_yaku.append(config.yaku.iipeiko)

                    # sanshoku requires same sequence in all 3 suits
                    if suit_mask == _ALL_SUITS_MASK and config.yaku.sanshoku.is_condition_met(hand):
                        hand_yaku.append(config.yaku.sanshoku)

                # small optimization, try to detect yaku with pon required sets only if we have pon sets in hand
                if pon_sets or kan_sets:
                    if config.yaku.toitoi.is_condition_met(hand):
                        hand_yaku.append(config.yaku.toitoi)

                    if config.yaku.sanankou.is_condition_met(hand, win_tile, melds, config.is_tsumo):
                        hand_yaku.append(config.yaku.sanankou)

                    # sanshoku douko requires same triplet in all 3 suits
                    if suit_mask == _ALL_SUITS_MASK and config.yaku.sanshoku_douko.is_condition_met(hand):
                        hand_yaku.append(config.yaku.sanshoku_douko)

                    # yakuhai, dragon, and wind yaku all require honor tiles
                    if has_honors:
                        if config.yaku.shosangen.is_condition_met(hand):
                            hand_yaku.append(config.yaku.shosangen)

                        if config.yaku.haku.is_condition_met(hand):
                            hand_yaku.append(config.yaku.haku)

                        if config.yaku.hatsu.is_condition_met(hand):
                            hand_yaku.append(config.yaku.hatsu)

                        if config.yaku.chun.is_condition_met(hand):
                            hand_yaku.append(config.yaku.chun)

                        for yaku in yakuhai_seat_wind_yaku:
                            if yaku.is_condition_met(hand, config.player_wind):
                                hand_yaku.append(yaku)

                        for yaku in yakuhai_round_wind_yaku:
                            if yaku.is_condition_met(hand, config.round_wind):
                                hand_yaku.append(yaku)

                        if config.yaku.daisangen.is_condition_met(hand):
                            hand_yaku.append(config.yaku.daisangen)

                        if config.yaku.shosuushi.is_condition_met(hand):
                            hand_yaku.append(config.yaku.shosuushi)

                        if config.yaku.daisuushi.is_condition_met(hand):
                            hand_yaku.append(config.yaku.daisuushi)

                    # closed kan can't be used in chuuren_poutou; requires terminals
                    if not melds and not is_tanyao_hand and config.yaku.chuuren_poutou.is_condition_met(hand):
                        if tiles_34[win_tile // 4] == 2 or tiles_34[win_tile // 4] == 4:
                            hand_yaku.append(config.yaku.daburu_chuuren_poutou)
                        else:
                            hand_yaku.append(config.yaku.chuuren_poutou)

                    if not is_open_hand and config.yaku.suuankou.is_condition_met(hand, win_tile, config.is_tsumo):
                        if tiles_34[win_tile // 4] == 2:
                            hand_yaku.append(config.yaku.suuankou_tanki)
                        else:
                            hand_yaku.append(config.yaku.suuankou)

                    if config.yaku.sankantsu.is_condition_met(hand, melds):
                        hand_yaku.append(config.yaku.sankantsu)

                    if config.yaku.suukantsu.is_condition_met(hand, melds):
                        hand_yaku.append(config.yaku.suukantsu)

                if config.paarenchan > 0 and config.options.paarenchan_needs_yaku and len(hand_yaku) > 0:
                    # we waited until here to add paarenchan yakuman only if there is any other yaku
                    config.yaku.paarenchan.set_paarenchan_count(config.paarenchan)
                    hand_yaku.append(config.yaku.paarenchan)

                # yakuman is not connected with other yaku
                yakuman_list = [x for x in hand_yaku if x.is_yakuman]
                if yakuman_list:
                    if not is_aotenjou:
                        hand_yaku = yakuman_list
                    else:
                        scores_calculator.aotenjou_filter_yaku(hand_yaku, config)  # ty: ignore[unresolved-attribute]
                        yakuman_list = []

                # calculate han
                for item in hand_yaku:
                    if is_open_hand and item.han_open:
                        han += item.han_open
                    else:
                        han += item.han_closed

                if han == 0:
                    error = HandCalculator.ERR_NO_YAKU
                    cost = None

                # dora is not added to yakuman and hands without yaku
                if not yakuman_list and not error:
                    if precomputed_dora:
                        config.yaku.dora.han_open = precomputed_dora
                        config.yaku.dora.han_closed = precomputed_dora
                        hand_yaku.append(config.yaku.dora)
                        han += precomputed_dora

                    if precomputed_aka_dora:
                        config.yaku.aka_dora.han_open = precomputed_aka_dora
                        config.yaku.aka_dora.han_closed = precomputed_aka_dora
                        hand_yaku.append(config.yaku.aka_dora)
                        han += precomputed_aka_dora

                    if precomputed_ura_dora:
                        config.yaku.ura_dora.han_closed = precomputed_ura_dora
                        hand_yaku.append(config.yaku.ura_dora)
                        han += precomputed_ura_dora

                if not is_aotenjou and (config.options.limit_to_sextuple_yakuman and han > 78):
                    han = 78

                if not error:
                    cost = scores_calculator.calculate_scores(han, fu, config, len(yakuman_list) > 0)

                calculated_hand = _CalculatedHand(
                    cost=cost,
                    error=error,
                    hand_yaku=hand_yaku,
                    han=han,
                    fu=fu,
                    fu_details=fu_details,
                )

                calculated_hands.append(calculated_hand)

        # exception hand
        if not is_open_hand and config.yaku.kokushi.is_condition_met(None, tiles_34):
            if tiles_34[win_tile // 4] == 2:
                hand_yaku.append(config.yaku.daburu_kokushi)
            else:
                hand_yaku.append(config.yaku.kokushi)

            if (
                not config.is_tsumo
                and config.options.has_sashikomi_yakuman
                and config.is_open_riichi
                and (config.is_daburu_riichi or config.is_riichi)
            ):
                hand_yaku.append(config.yaku.sashikomi)

            if config.is_renhou and config.options.renhou_as_yakuman:
                hand_yaku.append(config.yaku.renhou_yakuman)

            if config.is_tenhou:
                hand_yaku.append(config.yaku.tenhou)

            if config.is_chiihou:
                hand_yaku.append(config.yaku.chiihou)

            if config.paarenchan > 0:
                config.yaku.paarenchan.set_paarenchan_count(config.paarenchan)
                hand_yaku.append(config.yaku.paarenchan)

            # calculate han
            han = 0
            for item in hand_yaku:
                han += item.han_closed

            fu = 0
            if is_aotenjou:
                fu = 30 if config.is_tsumo else 40

                tiles_for_dora = list(tiles)

                count_of_dora = 0
                for tile in tiles_for_dora:
                    count_of_dora += plus_dora(tile, dora_indicators)

                if count_of_dora:
                    config.yaku.dora.han_open = count_of_dora
                    config.yaku.dora.han_closed = count_of_dora
                    hand_yaku.append(config.yaku.dora)
                    han += count_of_dora

                if config.is_riichi or config.is_daburu_riichi:
                    count_of_ura_dora = 0
                    for tile in tiles_for_dora:
                        count_of_ura_dora += plus_dora(tile, ura_dora_indicators)

                    if count_of_ura_dora:
                        config.yaku.ura_dora.han_closed = count_of_ura_dora
                        hand_yaku.append(config.yaku.ura_dora)
                        han += count_of_ura_dora

            cost = scores_calculator.calculate_scores(han, fu, config, len(hand_yaku) > 0)
            calculated_hands.append(
                _CalculatedHand(cost=cost, error=None, hand_yaku=hand_yaku, han=han, fu=fu, fu_details=[]),
            )

        if not calculated_hands:
            return HandResponse(error=HandCalculator.ERR_HAND_NOT_WINNING)

        # find most expensive hand
        calculated_hands = sorted(calculated_hands, key=lambda x: (x["han"], x["fu"]), reverse=True)
        # correctly sort expensive hands by fu details
        calculated_hands = [
            x
            for x in calculated_hands
            if x["han"] == calculated_hands[0]["han"] and x["fu"] == calculated_hands[0]["fu"]
        ]
        calculated_hands = sorted(calculated_hands, key=lambda x: sum([y["fu"] for y in x["fu_details"]]), reverse=True)
        calculated_hand = calculated_hands[0]

        error = calculated_hand["error"]
        if error:
            return HandResponse(error=error)

        return HandResponse(
            calculated_hand["cost"],
            calculated_hand["han"],
            calculated_hand["fu"],
            calculated_hand["hand_yaku"],
            error,
            calculated_hand["fu_details"],
            is_open_hand,
        )

    @staticmethod
    def _find_win_groups(win_tile: int, hand: list[list[int]], opened_melds: list[list[int]]) -> list[list[int]]:
        win_tile_34 = (win_tile or 0) // 4

        # track which opened melds have been matched using a consumed flags array
        consumed = [False] * len(opened_melds)

        # collect closed sets and find unique win groups in a single pass
        seen: set[tuple[int, ...]] = set()
        win_groups: list[list[int]] = []
        for x in hand:
            # check if this set matches an unconsumed opened meld
            is_opened_meld = False
            for i, meld in enumerate(opened_melds):
                if not consumed[i] and x == meld:
                    consumed[i] = True
                    is_opened_meld = True
                    break

            if is_opened_meld:
                continue

            # for forms like 45666 and ron on 6
            # we can assume that ron was on 456 form and on 66 form
            # and depends on form we will have different hand cost
            # so, we check all possible win groups
            if win_tile_34 in x:
                key = tuple(x)
                if key not in seen:
                    seen.add(key)
                    win_groups.append(x)

        return win_groups
