#!/usr/bin/env python3
"""Generate original four-event time-gap Music diagrams and quiet PCM candidates."""

from __future__ import annotations

import argparse
import math
import os
import struct
import subprocess
import wave
import xml.etree.ElementTree as ET
from html import escape
from pathlib import Path

ROOT = Path(__file__).resolve().parent
INK = "#173a4b"
PALE = "#e7f1ee"
SAND = "#f6edcf"
BLUE = "#d8e7f4"
RATE = 22050
SECONDS = 2.25
WIDTH = {"S": 1, "M": 2, "L": 3}


def txt(x: int, y: int, value: str, size: int = 18, bold: bool = False) -> str:
    weight = "bold" if bold else "normal"
    return f'<text x="{x}" y="{y}" font-family="DejaVu Sans" font-size="{size}" font-weight="{weight}" fill="{INK}">{escape(value)}</text>'


def rect(x: int, y: int, w: int, h: int, fill: str = "white", radius: int = 12) -> str:
    return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{radius}" fill="{fill}" stroke="{INK}" stroke-width="2.5"/>'


def line(x1: int, y1: int, x2: int, y2: int, width: int = 3) -> str:
    return f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{INK}" stroke-width="{width}" stroke-linecap="round"/>'


def circle(cx: int, cy: int, r: int, fill: str = "white") -> str:
    return f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" stroke="{INK}" stroke-width="2.5"/>'


def panel(lines: tuple[str, ...], y: int = 852, height: int = 155) -> list[str]:
    parts = [rect(77, y, 641, height, PALE)]
    for index, value in enumerate(lines):
        parts.append(txt(98, y + 39 + index * 34, value, 16, index == 0))
    return parts


def spacer(x1: int, x2: int, y: int, gap: str | None) -> list[str]:
    mid = (x1 + x2) // 2
    if gap is None:
        return [rect(mid - 35, y - 5, 70, 50, "white", 7), txt(mid - 9, y + 29, "?", 23, True)]
    blocks = WIDTH[gap]
    start = mid - blocks * 13
    pieces = [txt(mid - 14, y + 27, gap, 20, True)]
    for index in range(blocks):
        pieces.append(rect(start + index * 26, y + 44, 23, 16, SAND, 2))
    return pieces


def track(y: int, gaps: tuple[str | None, ...], label: str, *, blank: bool = False) -> list[str]:
    assert len(gaps) == 3
    # For a blank track, equal event positions create room for a child-owned gap plan.
    weights = [WIDTH.get(value, 2) for value in gaps]
    total = sum(weights)
    xs = [100]
    for weight in weights:
        xs.append(xs[-1] + round(590 * weight / total))
    parts = [txt(86, y - 67, label, 19, True), rect(78, y - 46, 638, 177, PALE)]
    parts.append(line(xs[0], y + 12, xs[-1], y + 12, 3))
    for index, x in enumerate(xs, 1):
        parts.extend((circle(x, y + 12, 15, BLUE if blank else SAND), txt(x - 5, y + 19, str(index), 16, True)))
    for index, gap in enumerate(gaps):
        parts += spacer(xs[index], xs[index + 1], y + 45, gap)
    return parts


def gap_key() -> list[str]:
    parts = [txt(81, 315, "FOUR STARTS LEAVE THREE GAPS", 19, True)]
    for label, blocks, y in (("S · SHORT", 1, 357), ("M · MIDDLE", 2, 529), ("L · LONG", 3, 701)):
        parts += [rect(79, y, 635, 145, PALE), txt(101, y + 42, f"GAP CARD · {label}", 20, True)]
        for block in range(blocks):
            parts.append(rect(104 + block * 67, y + 72, 60, 29, SAND, 3))
        parts.append(txt(420, y + 94, f"{blocks} raised block{'s' if blocks > 1 else ''}", 17))
    parts += panel(("Count the four dots separately from the gaps.", "These widths are planned time, not body distance."), 877, 130)
    return parts


def one_track(gaps: tuple[str, ...], statement: tuple[str, ...]) -> list[str]:
    return [
        txt(81, 318, "ONE FOUR-EVENT MUSIC PLAN", 19, True),
        *track(487, gaps, "FOUR IDENTICAL EVENT STARTS"),
        *panel(statement, 758, 201),
    ]


def steady_four() -> list[str]:
    return one_track(("M", "M", "M"), ("M–M–M · every start-to-start gap matches.", "Four sound events remain the same L / V / source A.", "A paper line cannot prove actual steady playing."))


def gather_four() -> list[str]:
    return one_track(("L", "M", "S"), ("L–M–S · starts draw closer toward the end.", "First gap is longest; final gap is shortest.", "This is music timing, not a direction to hurry."))


def spread_four() -> list[str]:
    return one_track(("S", "M", "L"), ("S–M–L · starts spread apart toward the end.", "First gap is shortest; final gap is longest.", "This is music timing, not physical spacing."))


def two_tracks(first: tuple[str | None, ...], second: tuple[str | None, ...], notes: tuple[str, ...], *, second_blank: bool = False) -> list[str]:
    return [
        *track(414, first, "TRACK A · FIRST TIME-SHAPE"),
        *track(688, second, "TRACK B · ANOTHER TIME-SHAPE", blank=second_blank),
        *panel(notes, 856, 150),
    ]


def fixed_changed() -> list[str]:
    return two_tracks(("M", "M", "M"), ("L", "M", "S"), ("Gaps 1 and 3 change; middle gap holds.", "Four event identities stay L pitch / V level / source A.", "No real movement rule comes from this score."))


def plan_actual() -> list[str]:
    return [
        *track(414, (None, None, None), "TRACK A · BLANK SYMBOLIC SLOTS", blank=True),
        *track(688, (None, None, None), "TRACK B · BLANK SYMBOLIC SLOTS", blank=True),
        *panel(("Dots here are placeholders; move them with chosen gaps.", "Actual playback: NOT TESTED / source if observed: ______", "Listener actually said: NOT ASKED / words: ______"), 856, 150),
    ]


def check_one() -> list[str]:
    return two_tracks(("S", "M", "L"), ("M", "M", "M"), ("Which track spreads? Which stays even?", "Name the first and last gap of the spreading track.", "What holds? What can paper not prove?"))


def check_two() -> list[str]:
    return two_tracks(("S", "M", "L"), ("S", "M", "L"), ("Make each B gap shorter than the one before.", "Change only first/last; circle old, write new beside.", "Keep middle M and all four event marks the same."))


PAGES = {
    "print/gap-key": ("A · Three between-start gaps", "Four identical numbered event starts have three spaces between them; S one block, M two, L three are comparative planned-time keys.", "Use four broad fixed event discs and three wide spacer slots. S one raised block, M two, L three. Count starts and between-start gaps separately; read full words.", gap_key),
    "print/steady-four": ("B · Keep the gaps even", "A four-event track has M M M between-start gaps and identical L pitch, V planned level and invented source A at each start.", "Use four identical fixed event discs and three two-block M spacers. Child compares all three gaps by touch or words; sound is optional.", steady_four),
    "print/gather-four": ("C · Starts draw closer", "Four identical event starts have long, middle, short planned gaps: L M S; first interval longest, final shortest.", "Use four fixed event discs and 3-, 2-, 1-block spacers left to right. The discs represent sound starts, not a person's physical path.", gather_four),
    "print/spread-four": ("D · Starts spread apart", "Four identical event starts have short, middle, long planned gaps: S M L; first interval shortest, final longest.", "Use four fixed event discs and 1-, 2-, 3-block spacers left to right. Compare gap time only, not physical spacing or safety.", spread_four),
    "print/fixed-changed": ("E · What changed?", "Track A gaps M M M; Track B L M S. Gap 1 and gap 3 differ, middle gap and all four event identities hold.", "Build two broad fixed four-event rows. A uses 2/2/2 raised blocks; B uses 3/2/1. Child finds changed spacer positions and same event discs.", fixed_changed),
    "print/plan-actual": ("F · Two plans and real evidence", "Two broad symbolic four-event tracks have three empty gap slots each; dots are movable placeholders, not a timed layout. Fields distinguish child-owned plans from actual playback and actual listener words.", "Offer two rows of four identical event discs and three replaceable spacer slots each. Move discs to match the selected block widths after child directions; keep actual fields NOT TESTED/NOT ASKED until true.", plan_actual),
    "check/gap-1": ("Fresh Gap 1 · Compare tracks", "Public unfamiliar pair: Track A gaps S M L; Track B gaps M M M; four identical event marks in both. The pattern names are not printed in the answer area.", "Set two broad fixed rows: A 1/2/3 raised spacer blocks, B 2/2/2. Read all marks neutrally; child identifies which spreads, which is even, first/last evidence and one hold.", check_one),
    "check/gap-2": ("Fresh Gap 2 · Make another shape", "Public base Track A gaps S M L and identical Track B copy. Child changes only B's first and last gap so each B gap is shorter than the previous; middle and events hold.", "Make two fixed rows of four event discs with public S/M/L spacers. In B only, child replaces first and last spacers on their own direction; adult supplies no answer.", check_two),
}


def svg(title: str, description: str, body: list[str]) -> str:
    return "\n".join([
        '<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="297mm" viewBox="0 0 794 1123" role="img" aria-labelledby="title desc">',
        f'<title id="title">{escape(title)}</title>',
        f'<desc id="desc">{escape(description)}</desc>',
        rect(27, 24, 740, 1074),
        txt(57, 80, "SUBJECTNEST · FOUNDATION MUSIC", 22, True),
        txt(57, 125, "Four same sound events · change time between starts", 15),
        txt(57, 184, title, 22, True),
        txt(76, 232, "S=SHORT / M=MIDDLE / L=LONG BETWEEN STARTS", 15, True),
        txt(76, 260, "Four event marks stay L pitch / V level / invented source A.", 14),
        *body,
        txt(57, 1055, "Original resource · CC BY 4.0 · exact alternatives in pack", 13),
        "</svg>",
    ]) + "\n"


def alternatives() -> str:
    lines = [
        "# Exact text, tactile and no-print routes for eight A4 Music sheets", "",
        "Four identical sound-event starts leave three *between-start* gaps. S/M/L are short/middle/long planned time, shown by one/two/three raised blocks; they do not measure child movement or seconds. A neutral adult reads all marks and moves broad fixed cards only as directed. Colour never carries the only meaning. A no-sound route meets score-planning/comparison, not actual listening or performed timing. Candidate PCM needs human listening and device-volume approval. Fresh-check routes state only their public tasks.", "",
    ]
    for key, (title, description, tactile, draw) in PAGES.items():
        root = ET.fromstring(svg(title, description, draw()))
        printed = [node.text or "" for node in root.iter() if node.tag.rsplit("}", maxsplit=1)[-1] == "text"]
        lines.extend((f"## {title}", "", f"**File:** {key}.svg / {key}.pdf.", f"**Exact content:** {description}", "**Verbatim printed text in layout order:** " + " | ".join(printed) + ".", f"**Tactile/no-print alternative:** {tactile}", ""))
    lines.extend(("**Evidence limit:** Paper marks show planned time shapes. Actual sound, aural discrimination, comfort, listener words, community context and audience receipt require separate observations.", ""))
    return "\n".join(lines)


CUES = {
    "cue-steady": (.10, .65, 1.20, 1.75),
    "cue-gather": (.10, .90, 1.45, 1.80),
    "cue-spread": (.10, .45, 1.00, 1.80),
}


def pcm(starts: tuple[float, ...]) -> bytes:
    data = bytearray()
    for i in range(round(SECONDS * RATE)):
        instant = i / RATE
        value = 0.0
        for start in starts:
            local = instant - start
            if 0 <= local < .20:
                fade = min(1.0, local/.012, (.20-local)/.012)
                value += .018 * fade * math.sin(2 * math.pi * 330 * local)
        data.extend(struct.pack("<h", round(value * 32767)))
    return bytes(data)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--write", action="store_true", help="intentionally regenerate owned assets")
    args = parser.parse_args()
    if not args.write:
        parser.error("use --write for intentional asset regeneration")
    for key, (title, description, _tactile, draw) in PAGES.items():
        path = ROOT / f"{key}.svg"
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(svg(title, description, draw()), encoding="utf-8")
        subprocess.run(["rsvg-convert", "--format", "pdf", "--output", str(path.with_suffix(".pdf")), str(path)], check=True, env=dict(os.environ, SOURCE_DATE_EPOCH="1790640000"))
    (ROOT / "print/TEXT-ALTERNATIVES.md").write_text(alternatives(), encoding="utf-8")
    for name, starts in CUES.items():
        target = ROOT / "audio" / f"{name}.wav"
        target.parent.mkdir(parents=True, exist_ok=True)
        with wave.open(str(target), "wb") as out:
            out.setnchannels(1)
            out.setsampwidth(2)
            out.setframerate(RATE)
            out.writeframes(pcm(starts))
    licence = Path("/usr/share/doc/fonts-dejavu-core/copyright").read_text(encoding="utf-8")
    (ROOT / "print/dejavu-font-copyright.txt").write_text("\n".join(line.rstrip() for line in licence.splitlines()) + "\n", encoding="utf-8")
    print(f"Wrote {len(PAGES)} A4 SVG/PDF pairs and {len(CUES)} PCM candidates.")


if __name__ == "__main__":
    main()
