"""
Decision-variable representation for the optimizer's search.

Two modes, controlled purely by whether a BuildSkeleton has any
`optional_powersets`:

  - Slotting-only: `fixed_powers` lists every power in the build (already
    chosen, e.g. from an imported Mids build or hand-specified), each with a
    slot count. The only decision variables are which enhancement (or none)
    goes in each slot.
  - Powers + slotting: on top of the above, `optional_powersets` lists
    powerset/pick-budget pairs (e.g. "choose up to 2 powers from Pool.Fighting")
    the search can also choose from. This is intentionally scoped to
    OPTIONAL/pool-style choices, not full primary/secondary power-pick
    optimization - modeling every powerset's real prerequisite/pick-order
    rules is a much larger data-modeling project than this phase's budget;
    see build-model/optimizer README for the honest scope note.
"""
import random
from dataclasses import dataclass, field

from enhancement_pool import candidate_pool_for_power
from build_model import CharacterBuild, PowerData, SlotData, EnhancementData


@dataclass
class FixedPowerSpec:
    power_name: str
    num_slots: int


@dataclass
class OptionalPowersetSpec:
    powerset_full_name: str
    max_picks: int
    slots_per_pick: int = 3


@dataclass
class BuildSkeleton:
    class_name: str
    fixed_powers: list        # list[FixedPowerSpec]
    optional_powersets: list = field(default_factory=list)  # list[OptionalPowersetSpec]
    character_level: int = 50
    origin: str = "Science"
    alignment: str = "Hero"
    name: str = "Optimizer candidate"

    @property
    def is_powers_and_slotting(self):
        return len(self.optional_powersets) > 0


def _flat_candidates(db, power, character_level):
    """One flat list of enhancement UIDs valid for this power (sets flattened
    in, generic IOs appended), for SA to pick a single slot value from."""
    pool = candidate_pool_for_power(db, power, character_level)
    uids = []
    for set_uids in pool["sets"].values():
        uids.extend(set_uids)
    uids.extend(pool["generic"])
    return uids


class SearchSpace:
    def __init__(self, db, skeleton):
        self.db = db
        self.skeleton = skeleton

        self.candidates_by_power = {}
        for spec in skeleton.fixed_powers:
            power = db.power(spec.power_name)
            if power is None:
                raise ValueError(f"Unknown power in fixed_powers: {spec.power_name!r}")
            self.candidates_by_power[spec.power_name] = _flat_candidates(db, power, skeleton.character_level)

        self.optional_power_names = {}  # powerset_full_name -> [power_name, ...]
        for opt in skeleton.optional_powersets:
            names = [p["full_name"] for p in db.powers_in_powerset(opt.powerset_full_name)]
            self.optional_power_names[opt.powerset_full_name] = names
            for name in names:
                power = db.power(name)
                self.candidates_by_power[name] = _flat_candidates(db, power, skeleton.character_level)

    def random_assignment(self, rng=random):
        """An Assignment is:
          {"fixed": {power_name: [uid_or_None, ...]},
           "optional": {powerset_full_name: {power_name: [uid_or_None, ...]}}}  # only SELECTED optional powers appear
        """
        fixed = {}
        for spec in self.skeleton.fixed_powers:
            candidates = self.candidates_by_power[spec.power_name]
            fixed[spec.power_name] = [self._random_slot_value(candidates, rng) for _ in range(spec.num_slots)]

        optional = {}
        for opt in self.skeleton.optional_powersets:
            available = self.optional_power_names[opt.powerset_full_name]
            k = rng.randint(0, min(opt.max_picks, len(available)))
            chosen = rng.sample(available, k) if k > 0 else []
            optional[opt.powerset_full_name] = {}
            for power_name in chosen:
                candidates = self.candidates_by_power[power_name]
                optional[opt.powerset_full_name][power_name] = [
                    self._random_slot_value(candidates, rng) for _ in range(opt.slots_per_pick)
                ]
        return {"fixed": fixed, "optional": optional}

    @staticmethod
    def _random_slot_value(candidates, rng, empty_weight=0.15):
        if not candidates or rng.random() < empty_weight:
            return None
        return rng.choice(candidates)

    def propose_move(self, assignment, rng=random):
        """Returns a NEW assignment (shallow-copied, mutation-safe) with one
        small random change applied - either re-rolling one slot's enhancement,
        or (powers+slotting mode) toggling one optional power in/out."""
        import copy
        new = copy.deepcopy(assignment)

        move_choices = ["reslot"]
        if self.skeleton.optional_powersets:
            move_choices.append("toggle_optional")
        move = rng.choice(move_choices)

        if move == "reslot":
            all_power_names = list(new["fixed"].keys())
            for opt_powers in new["optional"].values():
                all_power_names.extend(opt_powers.keys())
            if not all_power_names:
                return new
            power_name = rng.choice(all_power_names)
            slots = new["fixed"].get(power_name)
            if slots is None:
                for opt_powers in new["optional"].values():
                    if power_name in opt_powers:
                        slots = opt_powers[power_name]
                        break
            if not slots:
                return new
            slot_idx = rng.randrange(len(slots))
            candidates = self.candidates_by_power[power_name]
            slots[slot_idx] = self._random_slot_value(candidates, rng)

        elif move == "toggle_optional":
            opt = rng.choice(self.skeleton.optional_powersets)
            available = self.optional_power_names[opt.powerset_full_name]
            current = new["optional"].setdefault(opt.powerset_full_name, {})
            if current and (len(current) >= opt.max_picks or rng.random() < 0.5):
                # drop one currently-selected optional power
                drop = rng.choice(list(current.keys()))
                del current[drop]
            else:
                not_chosen = [p for p in available if p not in current]
                if not_chosen:
                    add = rng.choice(not_chosen)
                    candidates = self.candidates_by_power[add]
                    current[add] = [self._random_slot_value(candidates, rng) for _ in range(opt.slots_per_pick)]

        return new

    def assignment_to_build(self, assignment):
        power_entries = []
        power_sets = []

        for spec in self.skeleton.fixed_powers:
            power_entries.append(self._power_data(spec.power_name, assignment["fixed"][spec.power_name]))

        for powerset_full_name, powers in assignment["optional"].items():
            if powers:
                power_sets.append(powerset_full_name)
            for power_name, slots in powers.items():
                power_entries.append(self._power_data(power_name, slots))

        return CharacterBuild(
            class_name=self.skeleton.class_name,
            level=str(self.skeleton.character_level),
            origin=self.skeleton.origin,
            alignment=self.skeleton.alignment,
            name=self.skeleton.name,
            power_sets=power_sets,
            power_entries=power_entries,
        )

    @staticmethod
    def _power_data(power_name, slot_values):
        slot_entries = [
            SlotData(level=1, enhancement=EnhancementData(uid=uid) if uid else None)
            for uid in slot_values
        ]
        return PowerData(power_name=power_name, level=1, stat_include=True, slot_entries=slot_entries)
