from __future__ import annotations

import math
from dataclasses import dataclass
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw

import generate_symmetry_wave_paths_explainer as wave


ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = ROOT / "content" / "drafts" / "animations"
NAME = "symmetry-many-slit-paths-phasors-interference"

WIDTH = wave.WIDTH
HEIGHT = wave.HEIGHT
SCALE = wave.SCALE
FPS = wave.FPS
DURATION = 18.5

BG = wave.BG
PANEL = wave.PANEL
INK = wave.INK
MUTED = wave.MUTED
FAINT = wave.FAINT
BLUE = wave.BLUE
GOLD = wave.GOLD
GREEN = wave.GREEN

TITLE = wave.TITLE
SUBTITLE = wave.SUBTITLE
PANE_TITLE = wave.PANE_TITLE
LABEL = wave.LABEL
LABEL_BOLD = wave.LABEL_BOLD
SMALL = wave.SMALL

# Physical diagram coordinates. A and the detector are equally far from the
# aperture plane. The opening spacing is much smaller than the wavelength, so
# the 49 samples behave like a finely divided aperture rather than a grating.
SOURCE_DISTANCE = 4.5
DETECTOR_DISTANCE = 4.5
APERTURE_HALF_HEIGHT = 2.8
DETECTOR_HALF_HEIGHT = 3.2
OPENING_COUNT = 49
WAVELENGTH = 0.50
WAVE_NUMBER = 2.0 * math.pi / WAVELENGTH
OPENING_YS = np.linspace(APERTURE_HALF_HEIGHT, -APERTURE_HALF_HEIGHT, OPENING_COUNT)
SELECTED_B = 0.94

# Animation timing.
INTRO_END = 0.9
BUILD_END = 7.0
CENTER_HOLD_END = 7.9
MOVE_TO_TOP_END = 8.7
SCAN_END = 15.0
SETTLE_END = 16.0

# Panel geometry in output pixels.
ROUTE_PANEL = (35.0, 126.0, 585.0, 620.0)
PHASOR_PANEL = (602.0, 126.0, 1015.0, 620.0)
DETECTOR_PANEL = (1032.0, 126.0, 1245.0, 620.0)

ROUTE_A = (82.0, 379.0)
ROUTE_SCREEN_X = 306.0
ROUTE_DETECTOR_X = 553.0
ROUTE_TOP = 186.0
ROUTE_BOTTOM = 572.0
ROUTE_CENTER_Y = 379.0
ROUTE_Y_SCALE = (ROUTE_BOTTOM - ROUTE_TOP) / (2.0 * DETECTOR_HALF_HEIGHT)

PHASOR_BOUNDS = (625.0, 183.0, 991.0, 555.0)

DETECTOR_BASE_X = 1065.0
DETECTOR_MAX_X = 1218.0
DETECTOR_TOP = 190.0
DETECTOR_BOTTOM = 568.0


@dataclass(frozen=True)
class Contribution:
    opening_y: float
    length: float
    phase: float
    weight: float
    value: complex


def s(value: float) -> int:
    return wave.s(value)


def rgba(color: tuple[int, int, int], alpha: float) -> tuple[int, int, int, int]:
    return wave.rgba(color, alpha)


def smoothstep(value: float) -> float:
    value = max(0.0, min(1.0, value))
    return value * value * (3.0 - 2.0 * value)


def lerp(start: float, end: float, amount: float) -> float:
    return start + (end - start) * amount


def route_y(value: float) -> float:
    return ROUTE_CENTER_Y - ROUTE_Y_SCALE * value


def detector_y(value: float) -> float:
    amount = (DETECTOR_HALF_HEIGHT - value) / (2.0 * DETECTOR_HALF_HEIGHT)
    return DETECTOR_TOP + amount * (DETECTOR_BOTTOM - DETECTOR_TOP)


def stationary_opening_y(b_value: float) -> float:
    return SOURCE_DISTANCE * b_value / (SOURCE_DISTANCE + DETECTOR_DISTANCE)


def reference_length(b_value: float) -> float:
    return math.hypot(SOURCE_DISTANCE + DETECTOR_DISTANCE, b_value)


def build_contributions(b_value: float) -> tuple[Contribution, ...]:
    stationary_y = stationary_opening_y(b_value)
    reference = reference_length(b_value)
    result: list[Contribution] = []
    for opening_y in OPENING_YS:
        first = math.hypot(SOURCE_DISTANCE, float(opening_y))
        second = math.hypot(DETECTOR_DISTANCE, b_value - float(opening_y))
        length = first + second
        phase = WAVE_NUMBER * (length - reference)
        # This geometric-spreading weight makes longer routes visibly, but only
        # moderately, shorter than routes near the stationary opening.
        weight = math.sqrt((SOURCE_DISTANCE * DETECTOR_DISTANCE) / (first * second))
        value = weight * complex(math.cos(phase), math.sin(phase))
        result.append(
            Contribution(
                opening_y=float(opening_y),
                length=length,
                phase=phase,
                weight=weight,
                value=value,
            )
        )
    return tuple(result)


def cumulative_values(contributions: tuple[Contribution, ...]) -> tuple[complex, ...]:
    values = [0j]
    for contribution in contributions:
        values.append(values[-1] + contribution.value)
    return tuple(values)


DETECTOR_B_VALUES = np.linspace(DETECTOR_HALF_HEIGHT, -DETECTOR_HALF_HEIGHT, 193)
DETECTOR_TOTALS = tuple(sum(item.value for item in build_contributions(float(b))) for b in DETECTOR_B_VALUES)
DETECTOR_INTENSITIES = np.asarray([abs(total) ** 2 for total in DETECTOR_TOTALS])
MAX_INTENSITY = float(DETECTOR_INTENSITIES.max())


def make_phasor_mapper():
    values: list[complex] = [0j]
    for b_value in np.linspace(DETECTOR_HALF_HEIGHT, -DETECTOR_HALF_HEIGHT, 101):
        values.extend(cumulative_values(build_contributions(float(b_value))))
    min_x = min(value.real for value in values)
    max_x = max(value.real for value in values)
    min_y = min(value.imag for value in values)
    max_y = max(value.imag for value in values)
    left, top, right, bottom = PHASOR_BOUNDS
    span_x = max(1.0, max_x - min_x)
    span_y = max(1.0, max_y - min_y)
    scale = min((right - left - 24.0) / span_x, (bottom - top - 24.0) / span_y)
    offset_x = (left + right) / 2.0 - scale * (min_x + max_x) / 2.0
    offset_y = (top + bottom) / 2.0 + scale * (min_y + max_y) / 2.0

    def mapper(value: complex) -> tuple[float, float]:
        return offset_x + scale * value.real, offset_y - scale * value.imag

    return mapper


MAP_PHASOR = make_phasor_mapper()


def draw_text(
    draw: ImageDraw.ImageDraw,
    point: tuple[float, float],
    text: str,
    fill=INK,
    font_obj=LABEL,
    anchor: str | None = None,
) -> None:
    wave.draw_text(draw, point, text, fill=fill, font_obj=font_obj, anchor=anchor)


def draw_small_arrow(
    draw: ImageDraw.ImageDraw,
    start: tuple[float, float],
    end: tuple[float, float],
    color,
    width: int = 3,
    head: float = 4.2,
) -> None:
    draw.line((s(start[0]), s(start[1]), s(end[0]), s(end[1])), fill=color, width=s(width))
    angle = math.atan2(end[1] - start[1], end[0] - start[0])
    left = (
        end[0] - head * math.cos(angle - math.pi / 6.0),
        end[1] - head * math.sin(angle - math.pi / 6.0),
    )
    right = (
        end[0] - head * math.cos(angle + math.pi / 6.0),
        end[1] - head * math.sin(angle + math.pi / 6.0),
    )
    draw.polygon(
        [(s(end[0]), s(end[1])), (s(left[0]), s(left[1])), (s(right[0]), s(right[1]))],
        fill=color,
    )


def draw_route(
    draw: ImageDraw.ImageDraw,
    opening_y: float,
    b_value: float,
    color,
    width: int,
) -> None:
    opening = (ROUTE_SCREEN_X, route_y(opening_y))
    target = (ROUTE_DETECTOR_X, route_y(b_value))
    draw.line(
        (s(ROUTE_A[0]), s(ROUTE_A[1]), s(opening[0]), s(opening[1])),
        fill=color,
        width=s(width),
    )
    draw.line(
        (s(opening[0]), s(opening[1]), s(target[0]), s(target[1])),
        fill=color,
        width=s(width),
    )


def draw_aperture_screen(draw: ImageDraw.ImageDraw) -> None:
    draw.line(
        (s(ROUTE_SCREEN_X), s(ROUTE_TOP), s(ROUTE_SCREEN_X), s(ROUTE_BOTTOM)),
        fill=rgba(INK, 0.86),
        width=s(5),
    )
    for opening_y in OPENING_YS:
        point = (ROUTE_SCREEN_X, route_y(float(opening_y)))
        wave.circle(draw, point, 2.45, PANEL)


def draw_detector_line(draw: ImageDraw.ImageDraw, b_value: float) -> None:
    draw.line(
        (s(ROUTE_DETECTOR_X), s(ROUTE_TOP), s(ROUTE_DETECTOR_X), s(ROUTE_BOTTOM)),
        fill=rgba(MUTED, 0.56),
        width=s(2),
    )
    for sample in np.linspace(DETECTOR_HALF_HEIGHT, -DETECTOR_HALF_HEIGHT, 25):
        y = route_y(float(sample))
        wave.circle(draw, (ROUTE_DETECTOR_X, y), 1.7, rgba(MUTED, 0.50))
    target = (ROUTE_DETECTOR_X, route_y(b_value))
    wave.circle(draw, target, 7.5, INK)
    draw_text(draw, (target[0] + 13, target[1]), "B", fill=INK, font_obj=LABEL_BOLD, anchor="lm")


def draw_route_panel(
    draw: ImageDraw.ImageDraw,
    b_value: float,
    visible_count: int,
    active_index: int | None,
    active_fraction: float,
    complete_fan: bool,
) -> None:
    wave.panel(draw, ROUTE_PANEL)
    draw_text(draw, (58, 145), "routes through 49 openings", font_obj=PANE_TITLE)
    draw_text(draw, (58, 172), "one opening C_j selects one route A to C_j to B", fill=MUTED, font_obj=SMALL)

    contributions = build_contributions(b_value)
    count = OPENING_COUNT if complete_fan else visible_count
    for index in range(count):
        alpha = 0.12 if complete_fan else 0.10
        draw_route(draw, contributions[index].opening_y, b_value, rgba(BLUE, alpha), 2)

    draw_aperture_screen(draw)
    draw_detector_line(draw, b_value)

    stationary_index = min(
        range(OPENING_COUNT),
        key=lambda index: abs(contributions[index].opening_y - stationary_opening_y(b_value)),
    )
    if complete_fan:
        draw_route(draw, contributions[stationary_index].opening_y, b_value, rgba(GOLD, 0.92), 4)
        opening = (ROUTE_SCREEN_X, route_y(contributions[stationary_index].opening_y))
        wave.circle(draw, opening, 5.2, GOLD)

    if active_index is not None:
        active = contributions[active_index]
        color = rgba(GOLD, 0.35 + 0.65 * active_fraction)
        draw_route(draw, active.opening_y, b_value, color, 5)
        opening = (ROUTE_SCREEN_X, route_y(active.opening_y))
        wave.circle(draw, opening, 5.7, GOLD)
        draw_text(
            draw,
            (ROUTE_SCREEN_X + 12, opening[1]),
            f"C{active_index + 1}",
            fill=INK,
            font_obj=SMALL,
            anchor="lm",
        )

    wave.circle(draw, ROUTE_A, 7.5, INK)
    draw_text(draw, (ROUTE_A[0] - 12, ROUTE_A[1]), "A", fill=INK, font_obj=LABEL_BOLD, anchor="rm")
    draw_text(draw, (ROUTE_SCREEN_X, 591), "49 openings C_j", fill=INK, font_obj=SMALL, anchor="ma")
    draw_text(draw, (ROUTE_DETECTOR_X, 591), "detector", fill=INK, font_obj=SMALL, anchor="ma")

    if active_index is not None:
        draw_text(
            draw,
            (560, 145),
            f"route {active_index + 1} of 49",
            fill=GOLD,
            font_obj=LABEL_BOLD,
            anchor="ra",
        )
    elif complete_fan:
        draw_text(draw, (560, 145), "all 49 routes", fill=GREEN, font_obj=LABEL_BOLD, anchor="ra")


def draw_phasor_panel(
    draw: ImageDraw.ImageDraw,
    b_value: float,
    visible_count: int,
    active_index: int | None,
    active_fraction: float,
    complete_chain: bool,
) -> complex:
    wave.panel(draw, PHASOR_PANEL)
    draw_text(draw, (625, 145), "complex contributions at B", font_obj=PANE_TITLE)
    draw_text(draw, (625, 172), "top opening to bottom opening", fill=MUTED, font_obj=SMALL)

    contributions = build_contributions(b_value)
    cumulative = cumulative_values(contributions)
    count = OPENING_COUNT if complete_chain else visible_count
    origin = MAP_PHASOR(0j)
    draw.line(
        (s(PHASOR_BOUNDS[0]), s(origin[1]), s(PHASOR_BOUNDS[2]), s(origin[1])),
        fill=rgba(MUTED, 0.20),
        width=s(1),
    )
    draw.line(
        (s(origin[0]), s(PHASOR_BOUNDS[1]), s(origin[0]), s(PHASOR_BOUNDS[3])),
        fill=rgba(MUTED, 0.20),
        width=s(1),
    )
    draw_text(draw, (PHASOR_BOUNDS[2], origin[1] - 6), "Re", fill=MUTED, font_obj=SMALL, anchor="ra")
    draw_text(draw, (origin[0] + 7, PHASOR_BOUNDS[1]), "Im", fill=MUTED, font_obj=SMALL)

    for index in range(count):
        start = MAP_PHASOR(cumulative[index])
        finish = MAP_PHASOR(cumulative[index + 1])
        draw_small_arrow(draw, start, finish, rgba(BLUE, 0.76), width=2, head=4.0)

    current = cumulative[count]
    if active_index is not None:
        start_value = cumulative[active_index]
        current = start_value + active_fraction * contributions[active_index].value
        draw_small_arrow(
            draw,
            MAP_PHASOR(start_value),
            MAP_PHASOR(current),
            GOLD,
            width=5,
            head=7.0,
        )

    if count > 0 or active_index is not None:
        wave.dashed_line(draw, origin, MAP_PHASOR(current), rgba(GREEN, 0.34), width=2, dash=6, gap=5)
        wave.circle(draw, MAP_PHASOR(current), 3.5, GOLD if not complete_chain else GREEN)

    if complete_chain:
        total = cumulative[-1]
        wave.draw_arrow(draw, origin, MAP_PHASOR(total), rgba(GREEN, 0.94), 7)
        draw_text(draw, (810, 582), "green arrow = total amplitude", fill=GREEN, font_obj=LABEL_BOLD, anchor="ma")
        return total

    draw_text(draw, (810, 582), "arrow angle = phase, arrow length = route weight", fill=MUTED, font_obj=SMALL, anchor="ma")
    return current


def detector_intensity_x(intensity: float) -> float:
    return DETECTOR_BASE_X + (DETECTOR_MAX_X - DETECTOR_BASE_X) * min(1.0, max(0.0, intensity / MAX_INTENSITY))


def draw_detector_panel(
    draw: ImageDraw.ImageDraw,
    b_value: float,
    current_total: complex,
    reveal_fraction: float,
    scanning: bool,
    final_hold: bool,
    complete: bool,
) -> None:
    wave.panel(draw, DETECTOR_PANEL)
    draw_text(draw, (1052, 145), "intensity at detector", font_obj=PANE_TITLE)
    detector_caption = (
        "intensity = |total amplitude|²"
        if complete
        else "running |partial sum|²"
    )
    draw_text(draw, (1052, 172), detector_caption, fill=MUTED, font_obj=SMALL)
    draw.line(
        (s(DETECTOR_BASE_X), s(DETECTOR_TOP), s(DETECTOR_BASE_X), s(DETECTOR_BOTTOM)),
        fill=rgba(MUTED, 0.50),
        width=s(2),
    )

    if scanning or final_hold:
        visited = len(DETECTOR_B_VALUES) if final_hold else max(1, round(reveal_fraction * len(DETECTOR_B_VALUES)))
        points = []
        for index, (sample_b, intensity) in enumerate(zip(DETECTOR_B_VALUES, DETECTOR_INTENSITIES)):
            y = detector_y(float(sample_b))
            if index < visited:
                amount = float(intensity / MAX_INTENSITY)
                x = detector_intensity_x(float(intensity))
                points.append((s(x), s(y)))
                wave.circle(draw, (DETECTOR_BASE_X, y), 2.2, rgba(GOLD, 0.24 + 0.68 * amount))
            else:
                wave.circle(draw, (DETECTOR_BASE_X, y), 1.15, rgba(MUTED, 0.18))
        if len(points) > 1:
            draw.line(points, fill=rgba(BLUE, 0.82), width=s(3))

        full_total = sum(item.value for item in build_contributions(b_value))
        intensity = abs(full_total) ** 2
    else:
        intensity = abs(current_total) ** 2

    marker = (detector_intensity_x(intensity), detector_y(b_value))
    draw.line(
        (s(DETECTOR_BASE_X), s(marker[1]), s(marker[0]), s(marker[1])),
        fill=rgba(GOLD, 0.68),
        width=s(3),
    )
    wave.circle(draw, marker, 5.5, GREEN if final_hold else GOLD)
    draw_text(draw, (DETECTOR_BASE_X - 7, DETECTOR_TOP), "+", fill=MUTED, font_obj=SMALL, anchor="ra")
    draw_text(draw, (DETECTOR_BASE_X - 7, (DETECTOR_TOP + DETECTOR_BOTTOM) / 2.0), "0", fill=MUTED, font_obj=SMALL, anchor="ra")
    draw_text(draw, (DETECTOR_BASE_X - 7, DETECTOR_BOTTOM), "-", fill=MUTED, font_obj=SMALL, anchor="ra")
    draw_text(draw, ((DETECTOR_BASE_X + DETECTOR_MAX_X) / 2.0, 590), "brighter to the right", fill=MUTED, font_obj=SMALL, anchor="ma")


def animation_state(seconds: float):
    if seconds < INTRO_END:
        return 0.0, 0, None, 0.0, False, 0.0, False, "Start at the center point B."
    if seconds < BUILD_END:
        raw = (seconds - INTRO_END) / (BUILD_END - INTRO_END) * OPENING_COUNT
        active = min(OPENING_COUNT - 1, int(math.floor(raw)))
        fraction = raw - math.floor(raw)
        return 0.0, active, active, fraction, False, 0.0, False, "Each route adds one complex arrow in slit order."
    if seconds < CENTER_HOLD_END:
        return 0.0, OPENING_COUNT, None, 0.0, True, 0.0, False, "All 49 arrows give the amplitude at center B."
    if seconds < MOVE_TO_TOP_END:
        amount = smoothstep((seconds - CENTER_HOLD_END) / (MOVE_TO_TOP_END - CENTER_HOLD_END))
        b_value = lerp(0.0, DETECTOR_HALF_HEIGHT, amount)
        return b_value, OPENING_COUNT, None, 0.0, True, 0.0, False, "Now scan B down the detector."
    if seconds < SCAN_END:
        amount = (seconds - MOVE_TO_TOP_END) / (SCAN_END - MOVE_TO_TOP_END)
        b_value = lerp(DETECTOR_HALF_HEIGHT, -DETECTOR_HALF_HEIGHT, amount)
        return b_value, OPENING_COUNT, None, 0.0, True, amount, True, "Every B changes the routes, phases, and intensity."
    if seconds < SETTLE_END:
        amount = smoothstep((seconds - SCAN_END) / (SETTLE_END - SCAN_END))
        b_value = lerp(-DETECTOR_HALF_HEIGHT, SELECTED_B, amount)
        return b_value, OPENING_COUNT, None, 0.0, True, 1.0, True, "The scan leaves the complete interference pattern."
    return SELECTED_B, OPENING_COUNT, None, 0.0, True, 1.0, True, "A bright detector point has a long resultant arrow."


def draw_frame(frame: int) -> Image.Image:
    seconds = min(DURATION - 1.0 / FPS, frame / FPS)
    (
        b_value,
        visible_count,
        active_index,
        active_fraction,
        complete,
        reveal_fraction,
        scanning,
        subtitle,
    ) = animation_state(seconds)
    final_hold = seconds >= SETTLE_END

    image = Image.new("RGBA", (WIDTH * SCALE, HEIGHT * SCALE), BG + (255,))
    draw = ImageDraw.Draw(image, "RGBA")
    draw_text(draw, (42, 27), "Many openings, one amplitude at each detector point", font_obj=TITLE)
    draw_text(draw, (42, 67), subtitle, fill=GREEN if final_hold else MUTED, font_obj=SUBTITLE)

    draw_route_panel(draw, b_value, visible_count, active_index, active_fraction, complete)
    current_total = draw_phasor_panel(draw, b_value, visible_count, active_index, active_fraction, complete)
    draw_detector_panel(draw, b_value, current_total, reveal_fraction, scanning, final_hold, complete)

    return image.convert("RGB").resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS)


def main() -> None:
    # The canonical desktop movie now uses the complete propagation model,
    # amplitude-scaled arrows, and one fixed display phase making the central
    # sum horizontal, with a four-second wave-and-intensity intro. Keep the old
    # helper API above for existing derivatives;
    # changing build_contributions/draw_frame would silently change the reels.
    # The original source is preserved in scripts/iterations/*-v1-original.py.
    # Import lazily to avoid a cycle: the new renderer reuses our geometry.
    from generate_symmetry_desktop_wave_bookends import render_canonical_desktop

    render_canonical_desktop()


if __name__ == "__main__":
    main()
