"""
handler.py
----------
Frame-change handler and timing utilities.

`knot_frame_handler` is the heart of the animation system.  It fires every
frame (registered to `bpy.app.handlers.frame_change_post`) and:

  1. Determines which KnotItem is active based on `frames_per_knot * cycle_rate`.
  2. Optionally interpolates geometry and material into the next knot during a
     transition window.
  3. Calls `geometry._make_torus_knot(config, ...)` with explicit resolution /
     bevel_resolution / knot_scale values (no bpy.context reads inside the
     geometry function).
  4. Drives the `_KnotBlendMix.Fac` node on the pre-built blend material to
     cross-fade between two shaders during transitions.
"""
from __future__ import annotations

import math

import bpy
from bpy.app.handlers import persistent

from .constants import KNOT_OBJ_NAME
from .geometry  import _make_torus_knot
from .materials import get_effective_material


# ---------------------------------------------------------------------------
# Playlist duration helper  (also used by operators and UI)
# ---------------------------------------------------------------------------

def compute_playlist_duration(scene) -> int:
    """Return the total number of frames for one full pass through the playlist.

    Sums each knot's effective duration (``frames_per_knot * cycle_rate``,
    minimum 1 frame per knot).  Returns 0 if the playlist is empty.
    """
    glob = scene.knot_globals
    return sum(
        max(1, int(glob.frames_per_knot * scene.knot_list[k].cycle_rate))
        for k in range(len(scene.knot_list))
    )


# ---------------------------------------------------------------------------
# Easing functions
# ---------------------------------------------------------------------------

def _apply_easing(t: float, mode: str) -> float:
    if mode == 'LINEAR':
        return t
    if mode == 'QUAD_IN_OUT':
        return 2.0 * t * t if t < 0.5 else -1.0 + (4.0 - 2.0 * t) * t
    if mode == 'SMOOTHSTEP':
        return t * t * (3.0 - 2.0 * t)
    return t


# ---------------------------------------------------------------------------
# Frame-change handler
# ---------------------------------------------------------------------------

@persistent
def knot_frame_handler(scene, depsgraph=None) -> None:
    """Called after every frame change; rebuilds the knot for the new frame."""
    if not hasattr(scene, "knot_list") or len(scene.knot_list) == 0:
        return

    glob = scene.knot_globals
    f    = scene.frame_current

    # Apply global speed and phase offset to get an effective frame counter.
    effective_f = f * glob.global_speed + glob.animation_phase

    # Reactivity scales all per-knot animation rate properties.
    reactivity = glob.reactivity_factor

    # Determine which knot slot is active based on per-knot cycle rate.
    total_knots = len(scene.knot_list)
    loop_len    = compute_playlist_duration(scene)
    if loop_len < 1:
        loop_len = 1
    ef_int = int(effective_f) % loop_len

    idx          = 0
    frames_in    = 0
    effective_fpk = glob.frames_per_knot
    accumulated  = 0
    for k in range(total_knots):
        fpk = max(1, int(glob.frames_per_knot * scene.knot_list[k].cycle_rate))
        if ef_int < accumulated + fpk:
            idx           = k
            frames_in     = ef_int - accumulated
            effective_fpk = fpk
            break
        accumulated += fpk
    else:
        # Fallback: clamp to last slot (should never happen after modulo wrap)
        idx           = total_knots - 1
        effective_fpk = max(1, int(glob.frames_per_knot * scene.knot_list[idx].cycle_rate))
        frames_in     = 0

    item        = scene.knot_list[idx]
    frames_left = effective_fpk - frames_in

    # ── Transition factor ────────────────────────────────────────────────────
    transition_active = False
    t = 0.0
    if item.transition_frames > 0 and frames_left <= item.transition_frames:
        transition_active = True
        t_raw = 1.0 - (frames_left / float(item.transition_frames))
        t     = _apply_easing(max(0.0, min(1.0, t_raw)), item.transition_easing)

    config   = item.to_dict()
    next_idx  = (idx + 1) % total_knots
    next_item = scene.knot_list[next_idx]

    if transition_active:
        next_config = next_item.to_dict()

        # Keys managed explicitly below — exclude from generic interpolation.
        _ANIMATED_KEYS = {
            "torus_sP", "torus_rP", "torus_h", "_scale_override",
            "spin_phase_rate", "rev_phase_rate", "height_rate",
            "scale_rate", "scale_amplitude",
        }

        # Interpolate all standard float properties between current and next knot.
        for key in list(config):
            if key in _ANIMATED_KEYS:
                continue
            val_a = config[key]
            val_b = next_config.get(key, val_a)
            if isinstance(val_a, float) and isinstance(val_b, float):
                config[key] = val_a + (val_b - val_a) * t
            elif t >= 0.5:
                config[key] = val_b

        # Animated spin phase
        sP_a = item.torus_sP     + effective_f * item.spin_phase_rate
        sP_b = next_item.torus_sP + effective_f * next_item.spin_phase_rate
        config["torus_sP"] = sP_a + (sP_b - sP_a) * t

        # Animated revolution phase
        rP_a = item.torus_rP     + effective_f * item.rev_phase_rate
        rP_b = next_item.torus_rP + effective_f * next_item.rev_phase_rate
        config["torus_rP"] = rP_a + (rP_b - rP_a) * t

        # Animated height pulse
        h_a = item.torus_h + (
            math.sin(effective_f * item.height_rate) * abs(item.height_rate) * 5.0 * reactivity
            if item.height_rate != 0.0 else 0.0
        )
        h_b = next_item.torus_h + (
            math.sin(effective_f * next_item.height_rate) * abs(next_item.height_rate) * 5.0 * reactivity
            if next_item.height_rate != 0.0 else 0.0
        )
        config["torus_h"] = h_a + (h_b - h_a) * t

        # Animated scale oscillation
        sc_a = (
            1.0 + item.scale_amplitude * math.sin(effective_f * item.scale_rate) * reactivity
            if item.scale_amplitude > 0.0 and item.scale_rate != 0.0 else None
        )
        sc_b = (
            1.0 + next_item.scale_amplitude * math.sin(effective_f * next_item.scale_rate) * reactivity
            if next_item.scale_amplitude > 0.0 and next_item.scale_rate != 0.0 else None
        )
        if sc_a is not None or sc_b is not None:
            sa = sc_a if sc_a is not None else 1.0
            sb = sc_b if sc_b is not None else 1.0
            config["_scale_override"] = sa + (sb - sa) * t

    else:
        # Apply animated rates with reactivity (no transition)
        if item.spin_phase_rate != 0.0:
            config["torus_sP"] += effective_f * item.spin_phase_rate

        if item.rev_phase_rate != 0.0:
            config["torus_rP"] += effective_f * item.rev_phase_rate

        if item.height_rate != 0.0:
            config["torus_h"] += (
                math.sin(effective_f * item.height_rate) * abs(item.height_rate) * 5.0 * reactivity
            )

        if item.scale_amplitude > 0.0 and item.scale_rate != 0.0:
            config["_scale_override"] = (
                1.0 + item.scale_amplitude * math.sin(effective_f * item.scale_rate) * reactivity
            )

    # ── Apply Attenuverter Modulations ───────────────────────────────────────
    # Float mods
    for key in [
        "torus_R", "torus_r", "torus_eR", "torus_iR", "torus_h",
        "mobius_width", "liss_amp", "spiral_R",
        "geo_extrude", "geo_offset", "geo_bDepth"
    ]:
        if f"mod_{key}" in config and config[f"mod_{key}"] != 0.0:
            config[key] += config[f"mod_{key}"] * reactivity

    # Integer mods (clamp to minimum 1 for topology safety)
    for key in [
        "torus_p", "torus_q", "mobius_twists",
        "liss_kx", "liss_ky", "liss_kz", "spiral_turns"
    ]:
        if f"mod_{key}" in config and config[f"mod_{key}"] != 0.0:
            config[key] = max(1, config[key] + int(config[f"mod_{key}"] * reactivity))

    # ── Cross-material blend ─────────────────────────────────────────────────
    cross_material = False
    if transition_active:
        mat_a = get_effective_material(item)
        mat_b = get_effective_material(next_item)
        cross_material = mat_a != mat_b
        if cross_material:
            config["_skip_material"] = True

    # ── Apply Global Settings & Overrides ────────────────────────────────────
    is_render = False
    if depsgraph is not None and getattr(depsgraph, "mode", 'VIEWPORT') == 'RENDER':
        is_render = True

    res       = glob.render_resolution       if is_render else glob.preview_resolution
    bevel_res = glob.render_bevel_resolution if is_render else glob.preview_bevel_resolution

    if "geo_bDepth" in config:
        config["geo_bDepth"] *= glob.global_master_thickness

    if "preset_emission_strength" in config:
        config["preset_emission_strength"] *= glob.global_emission_multiplier

    if "preset_color" in config and glob.global_hue_shift != 0.0:
        import colorsys
        color_val = config["preset_color"]
        if len(color_val) == 4:
            r, g, b, a = color_val
        else:
            r, g, b = color_val
            a = 1.0

        h_hsv, s, v = colorsys.rgb_to_hsv(r, g, b)
        h_hsv = (h_hsv + glob.global_hue_shift) % 1.0
        nr, ng, nb = colorsys.hsv_to_rgb(h_hsv, s, v)

        if len(color_val) == 4:
            config["preset_color"] = (nr, ng, nb, a)
        else:
            config["preset_color"] = (nr, ng, nb)

    # Resolve globals once and pass explicitly — no bpy.context inside geometry
    _make_torus_knot(
        config,
        resolution=res,
        bevel_resolution=bevel_res,
        knot_scale=glob.knot_scale,
        scene=scene,
        turbulence=glob.global_turbulence,
        smooth_shading=glob.smooth_shading,
    )

    if cross_material:
        blend_name = f"KnotBlend_Item_{idx}"
        blend_mat  = bpy.data.materials.get(blend_name)
        blend_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
        if blend_obj:
            if blend_mat:
                mix_node = blend_mat.node_tree.nodes.get("_KnotBlendMix")
                if mix_node:
                    mix_node.inputs["Fac"].default_value = float(t)
                if len(blend_obj.data.materials) == 0:
                    blend_obj.data.materials.append(blend_mat)
                else:
                    blend_obj.data.materials[0] = blend_mat
            elif mat_a:
                # Fallback if blend material wasn't generated
                if len(blend_obj.data.materials) == 0:
                    blend_obj.data.materials.append(mat_a)
                else:
                    blend_obj.data.materials[0] = mat_a

    # ── Post-Geometry Object Overrides ───────────────────────────────────────
    knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
    if knot_obj:
        knot_obj.display_type = 'WIRE' if glob.viewport_wireframe else 'TEXTURED'
        knot_obj.rotation_euler[2] = effective_f * glob.auto_turntable_speed

    # ── Camera Shake ─────────────────────────────────────────────────────────
    cam = scene.camera
    if cam:
        shake = glob.camera_shake_amplitude
        if shake > 0.0:
            import random
            # Deterministic noise based on frame for stable rendering
            random.seed(int(f * 1000))
            cam.delta_location = (
                random.uniform(-shake, shake),
                random.uniform(-shake, shake),
                random.uniform(-shake, shake)
            )
        else:
            cam.delta_location = (0.0, 0.0, 0.0)
