"""
Goal presets: pluggable objective functions the optimizer's simulated
annealing search maximizes. Each Goal takes the dict computed by
build_stats.compute_build_stats() and returns a scalar fitness (higher is
better) - the search never looks at anything else, so a new goal preset is
just a new class here.

All three goals below share one design: a hard target (or budget) drives a
big step in the score once satisfied, with a smooth gradient below/above it
so simulated annealing has something to climb even far from feasible, plus a
secondary term (fewer slots, more DPS, etc.) that only matters once the
primary target is met.
"""
from proc_rate import recharge_time_from_bonus


class Goal:
    name = "goal"

    def score(self, stats):
        raise NotImplementedError

    def describe(self, stats):
        """Human-readable one-liner for reporting a candidate build's score."""
        return f"score={self.score(stats):.3f}"


class SoftcapDefenseGoal(Goal):
    """'Get <defense_type> defense to at least <target> using the fewest slots.'
    defense_type is one of the damage_type strings build_stats.defense uses
    (e.g. "Ranged", "Melee", "AoE", or a specific damage type like "Fire")."""

    def __init__(self, defense_type, target=0.45, slot_penalty=1.0):
        self.name = f"softcap_{defense_type}_defense"
        self.defense_type = defense_type
        self.target = target
        self.slot_penalty = slot_penalty

    def score(self, stats):
        value = stats["defense"].get(self.defense_type, 0.0)
        if value >= self.target:
            # Target met: reward stays clearly above any sub-target score, and
            # ties are broken by using fewer slots.
            return 1000.0 - self.slot_penalty * stats["total_slots_used"]
        # Below target: climb toward it. Scaled so even a small improvement
        # in defense moves the score more than losing/gaining a slot would.
        return value * 500.0

    def describe(self, stats):
        value = stats["defense"].get(self.defense_type, 0.0)
        met = "MET" if value >= self.target else "not met"
        return (f"{self.defense_type} defense = {value*100:.2f}% "
                f"(target {self.target*100:.1f}%, {met}), "
                f"{stats['total_slots_used']} slots used")


class RechargeTargetGoal(Goal):
    """'Reach at least <target> global recharge bonus (e.g. 0.7 for
    perma-Hasten-ish territory) using the fewest slots.'"""

    def __init__(self, target=0.7, slot_penalty=1.0):
        self.name = "recharge_target"
        self.target = target
        self.slot_penalty = slot_penalty

    def score(self, stats):
        value = stats["global_recharge_bonus"]
        if value >= self.target:
            return 1000.0 - self.slot_penalty * stats["total_slots_used"]
        return value * 500.0

    def describe(self, stats):
        value = stats["global_recharge_bonus"]
        met = "MET" if value >= self.target else "not met"
        return (f"global recharge = +{value*100:.2f}% "
                f"(target +{self.target*100:.1f}%, {met}), "
                f"{stats['total_slots_used']} slots used")


class MaxDPSGoal(Goal):
    """'Maximize sustained single-target DPS across <attack_chain_powers>
    within a <slot_budget> total slots.'

    SIMPLIFICATION (documented, not hidden): this sums each attack's own
    damage/interval independently - damage_per_cast / max(cast_time,
    recharge_time_after_buffs) - rather than modeling a real interleaved
    attack chain (animation-locked sequencing, gap-filling with filler
    attacks, whether the chain is even recharge-feasible without gaps).
    That's a materially harder problem (chain scheduling, not just slotting)
    and is flagged as follow-up work rather than silently approximated as
    something it isn't. This estimate is still useful as a search signal: it
    correctly rewards more damage%, more accuracy (fewer wasted misses is
    NOT modeled either - see below), and enough recharge to keep each power
    up, which is most of what set-bonus/slotting choices actually affect.

    Accuracy is folded in as an expected-value multiplier (accuracy% capped
    at 95% hit chance, CoH's usual cap) since a whiffed attack deals no
    damage on average.
    """

    def __init__(self, db, attack_chain_power_names, slot_budget=None, slot_penalty=2.0):
        self.name = "max_dps"
        self.db = db
        self.attack_chain_power_names = list(attack_chain_power_names)
        self.slot_budget = slot_budget
        self.slot_penalty = slot_penalty

    def _power_dps(self, power_name, agg, global_recharge_bonus):
        power = self.db.power(power_name)
        if power is None:
            return 0.0

        damage_mult = 1.0 + agg.get((2, -1), {}).get("ed_value", 0.0)
        acc_mult = 1.0 + agg.get((1, -1), {}).get("ed_value", 0.0)
        local_recharge_bonus = agg.get((14, -1), {}).get("ed_value", 0.0)

        base_recharge = power.get("base_recharge_time", 0.0) or 0.0
        recharge_time = recharge_time_from_bonus(base_recharge, local_recharge_bonus + global_recharge_bonus)
        cast_time = power.get("cast_time", 0.0) or 0.0
        interval = max(cast_time, recharge_time)
        if interval <= 0:
            return 0.0

        base_damage = 0.0
        for eff in power.get("effects", []):
            if eff.get("effect_type") != "Damage":
                continue
            if eff.get("pv_mode") not in ("Any", "PvE"):
                continue
            base_damage += eff.get("scale", 0.0) * eff.get("magnitude", 1.0) if eff.get("magnitude") else eff.get("scale", 0.0)

        base_accuracy = power.get("accuracy", 1.0) or 1.0
        hit_chance = min(0.95, max(0.0, base_accuracy * acc_mult))

        damage_per_cast = base_damage * damage_mult * hit_chance
        return damage_per_cast / interval

    def _total_dps(self, stats):
        total = 0.0
        for name in self.attack_chain_power_names:
            agg = stats["per_power"].get(name, {})
            total += self._power_dps(name, agg, stats["global_recharge_bonus"])
        return total

    def score(self, stats):
        dps = self._total_dps(stats)
        if self.slot_budget is not None and stats["total_slots_used"] > self.slot_budget:
            over = stats["total_slots_used"] - self.slot_budget
            return dps - self.slot_penalty * 50 * over
        return dps

    def describe(self, stats):
        dps = self._total_dps(stats)
        budget_note = ""
        if self.slot_budget is not None:
            budget_note = f", {stats['total_slots_used']}/{self.slot_budget} slots"
        return f"estimated DPS (simplified, see MaxDPSGoal docstring) = {dps:.2f}{budget_note}"
