#!/usr/bin/env python3
"""Generate original three-lane two-take Music sheets and 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.4
Triple = tuple[str, str, str]


def txt(x: int, y: int, value: str, size: int = 17, bold: bool = False) -> str:
    return (
        f'<text x="{x}" y="{y}" font-family="DejaVu Sans" '
        f'font-size="{size}" font-weight="{"bold" if bold else "normal"}" '
        f'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}" '
        f'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 icon(cx: int, cy: int, lane: str, mark: str) -> list[str]:
    if mark == "?":
        return [circle(cx, cy, 17, "white"), txt(cx - 6, cy + 6, "?", 18, True)]
    if lane == "P":
        y = cy + 6 if mark == "L" else cy - 6
        return [line(cx - 15, cy + 13, cx + 15, cy + 13, 1), circle(cx, y, 6, SAND)]
    if lane == "SRC":
        if mark == "A":
            return [circle(cx, cy, 12, BLUE)]
        return [rect(cx - 12, cy - 12, 24, 24, BLUE, 2), *[line(cx + d, cy - 10, cx + d, cy + 10, 1) for d in (-7, 0, 7)]]
    if mark == "V":
        return [circle(cx, cy, 6, INK)]
    return [circle(cx - 7, cy, 6, INK), circle(cx + 7, cy, 6, INK)]


def card(x: int, y: int, number: int, values: Triple | None) -> list[str]:
    p, s, d = values or ("?", "?", "?")
    parts = [rect(x, y, 134, 225, PALE), txt(x + 10, y + 27, f"PLACE {number}", 15, True)]
    for offset, lane, mark in ((66, "P", p), (119, "SRC", s), (172, "LVL", d)):
        parts += [rect(x + 8, y + offset - 31, 118, 45, "white", 5)]
        parts.append(txt(x + 16, y + offset - 1, f"{lane}: {mark}", 15, True))
        parts += icon(x + 107, y + offset - 8, lane, mark)
    return parts


def row(values: tuple[Triple | None, ...], y: int) -> list[str]:
    xs = (74, 246, 418, 590)
    return [part for number, (x, value) in enumerate(zip(xs, values, strict=True), 1) for part in card(x, y, number, value)]


def panel(lines: tuple[str, ...], y: int = 897, height: int = 115) -> list[str]:
    parts = [rect(76, y, 642, height, PALE)]
    for index, value in enumerate(lines):
        parts.append(txt(95, y + 37 + 29 * index, value, 15, index == 0))
    return parts


def legend() -> list[str]:
    parts = [txt(79, 310, "ONE EVENT HAS THREE SEPARATE MARKS", 19, True)]
    for y, title, left, right, lane in (
        (343, "PITCH", "L = LOW", "H = HIGH", "P"),
        (520, "SOURCE", "A = CIRCLE", "B = STRIPED SQUARE", "SRC"),
        (697, "LEVEL", "V = VERY SOFT", "S = SOFT", "LVL"),
    ):
        parts += [rect(78, y, 638, 151, PALE), txt(101, y + 35, title, 18, True)]
        parts += [rect(107, y + 53, 276, 78, SAND), rect(412, y + 53, 276, 78, BLUE)]
        parts += [txt(128, y + 83, left, 17, True), txt(433, y + 83, right, 17, True)]
        parts += icon(343, y + 91, lane, left[0])
        parts += icon(653, y + 91, lane, right[0])
    parts += panel(("Choose one mark from each lane for one event.", "Cards show a score plan, not actual hearing."), 894, 112)
    return parts


BASE: tuple[Triple, ...] = (("L", "A", "V"),) * 4
TAKE_LEVEL: tuple[Triple, ...] = (("L", "A", "V"), ("L", "A", "S"), ("L", "A", "V"), ("L", "A", "V"))
TAKE_SOURCE: tuple[Triple, ...] = (("L", "A", "V"), ("L", "A", "V"), ("L", "A", "V"), ("L", "B", "V"))
Y_FIRST: tuple[Triple, ...] = (("L", "A", "V"), ("H", "A", "V"), ("L", "B", "V"), ("H", "B", "S"))
Y_SECOND: tuple[Triple, ...] = (("L", "A", "V"), ("H", "A", "V"), ("H", "B", "V"), ("H", "B", "S"))
Z_BASE: tuple[Triple, ...] = (("H", "A", "V"), ("L", "A", "V"), ("L", "B", "S"), ("H", "B", "S"))


def two_rows(top: tuple[Triple, ...], bottom: tuple[Triple, ...], top_label: str, bottom_label: str, notes: tuple[str, ...]) -> list[str]:
    return [
        txt(79, 301, top_label, 18, True),
        *row(top, 322),
        txt(79, 591, bottom_label, 18, True),
        *row(bottom, 612),
        *panel(notes, 888, 124),
    ]


def two_takes() -> list[str]:
    return two_rows(BASE, TAKE_LEVEL, "FIRST TAKE · KEEP ALL MARKS", "SECOND TAKE · ONE LEVEL EDIT", ("Only place 2 level changes V to S.", "Pitch L and source A hold at every place.", "Actual sound needs a separate safe trial."))


def hold_change() -> list[str]:
    return two_rows(BASE, TAKE_SOURCE, "FIRST TAKE · KEEP ALL MARKS", "SECOND TAKE · ONE SOURCE EDIT", ("Only place 4 source changes A to B.", "Pitch L and level V hold at every place.", "A/B are invented electronic recipe labels."))


def pair(y: int, number: int, before: Triple, after: Triple, label: str) -> list[str]:
    parts = [rect(77, y, 640, 181, PALE), txt(98, y + 36, f"PAIR {number} · {label}", 18, True)]
    parts += [rect(104, y + 60, 247, 91, SAND), rect(442, y + 60, 247, 91, BLUE)]
    parts += [txt(118, y + 88, "BEFORE", 15, True), txt(456, y + 88, "AFTER", 15, True)]
    parts += [txt(118, y + 125, " · ".join(before), 21, True), txt(456, y + 125, " · ".join(after), 21, True)]
    parts += [line(376, y + 106, 414, y + 106, 4)]
    return parts


def choose_edit() -> list[str]:
    return [
        txt(80, 303, "MATCH EACH ONE-MARK EDIT TO ITS LANE", 18, True),
        *pair(338, 1, ("L", "A", "V"), ("H", "A", "V"), "PITCH"),
        *pair(538, 2, ("L", "A", "V"), ("L", "B", "V"), "SOURCE"),
        *pair(738, 3, ("L", "A", "V"), ("L", "A", "S"), "LEVEL"),
        *panel(("Every pair changes one mark in one event.", "The other two marks stay the same."), 939, 69),
    ]


def plan_actual() -> list[str]:
    parts = [txt(79, 303, "PAPER PLAN AND REAL EVENT HAVE DIFFERENT EVIDENCE", 17, True)]
    for y, heading, prompt in (
        (337, "1 · PLANNED ONE-MARK EDIT", "Lane/place/old/new: __________________________"),
        (525, "2 · ACTUALLY HEARD OR MADE", "NOT TRIED / child's exact words: ______________"),
        (713, "3 · COMFORT AND CHOICE", "NOT TRIED / child's exact words: ______________"),
    ):
        parts += [rect(77, y, 640, 163, PALE), txt(101, y + 47, heading, 19, True), txt(101, y + 111, prompt, 16)]
    parts += panel(("A written edit is not an actual sound result.", "A child may choose no sound or stop."), 907, 101)
    return parts


def make_teach() -> list[str]:
    return two_rows((None,) * 4, (None,) * 4, "MY FIRST TAKE · THREE MARKS PER PLACE", "MY SECOND TAKE · CHANGE ONE MARK", ("My one edit / intended idea: __________________", "Actual trial, if any: ______  Actual reader, if any: ______"))


def fresh_y() -> list[str]:
    return two_rows(Y_FIRST, Y_SECOND, "FIRST TAKE · READ EVERY PLACE", "REVISED TAKE · FIND THE ONE EDIT", ("Name lane, place and old/new mark.", "Name something that stayed the same.", "What can paper not prove about real sound?"))


def fresh_z() -> list[str]:
    return two_rows(Z_BASE, Z_BASE, "FIRST TAKE · GIVEN SCORE", "SECOND TAKE · CHANGE ONE MARK YOURSELF", ("Circle ONE second-take mark; write its new letter beside it.", "Name its lane/place and tell your musical idea.", "Check the other eleven marks stay the same."))


PAGES = {
    "print/three-keys": ("A · Three independent Music keys", "Pitch L/H, invented source A/B, planned level V/S have separate words, icons and one-event lanes.", "Prepare three labelled fixed lanes. Low/high has a raised baseline with dot low/high; A is circle, B striped square; V has one raised dot, S two. Child selects one per lane.", legend),
    "print/two-takes": ("B · One seed, two takes", "Four L/A/V first-take cards; second take is identical except place 2 level V becomes S.", "Build two fixed four-place raised strips with three lanes per place. Preserve first row L/A/V; in second row replace only place 2 one-dot V with two-dot S.", two_takes),
    "print/hold-change": ("C · Hold and change", "Four L/A/V first-take cards; second take is identical except place 4 source A becomes B.", "Keep two fixed raised four-place strips. In second row replace only place 4 A circle with B striped square; trace pitch and level lanes unchanged.", hold_change),
    "print/choose-edit": ("D · Which lane changed?", "Three single-event pairs: LAV to HAV, LAV to LBV, LAV to LAS; one pitch, one source, one level edit.", "Use three small before/after raised pairs. Child compares the three lanes in each and identifies the single changed lane without needing colour.", choose_edit),
    "print/plan-actual": ("E · Paper plan versus real event", "Three separate empty fields: planned one-mark edit, actually heard or made, comfort and choice; NOT TRIED allowed.", "Provide three large labelled fields. Child can mark the planned edit; keep real-event and comfort fields NOT TRIED unless a safe actual event occurred.", plan_actual),
    "print/make-teach": ("F · My two-take score", "Two blank four-event rows with pitch/source/level spaces and fields for one edit, intention, actual trial and actual reader.", "Offer two broad four-place raised strips, three labelled slots per place. Child builds first, copies to second, then changes one selected mark; adult moves cards only as directed.", make_teach),
    "check/fresh-y": ("Fresh Y · Find one unfamiliar edit", "First LAV/HAV/LBV/HBS; revised LAV/HAV/HBV/HBS. Child finds the one differing mark independently.", "Fix two raised four-place strips. First triplets LAV, HAV, LBV, HBS; revised LAV, HAV, HBV, HBS. Child independently compares all corresponding lanes.", fresh_y),
    "check/fresh-z": ("Fresh Z · Choose one deliberate edit", "Two identical printed H A V / L A V / L B S / H B S four-event rows; child changes one mark in second row.", "Make two identical raised four-place strips with the listed triplets. Child chooses one mark in the second strip to replace; keep the other eleven fixed.", fresh_z),
}


def svg(title: str, description: str, body: list[str]) -> str:
    parts = [
        '<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, 81, "SUBJECTNEST · FOUNDATION MUSIC", 22, True),
        txt(57, 127, "Original three-lane score · no-sound route", 15),
        txt(57, 183, title, 22, True),
        txt(76, 230, "PITCH L/H    SOURCE A/B    PLANNED LEVEL V/S", 16, True),
        txt(76, 259, "One mark in one place may change; the rest stay.", 15),
        *body,
        txt(57, 1056, "Original resource · CC BY 4.0 · exact alternatives in pack", 13),
        "</svg>",
    ]
    return "\n".join(parts) + "\n"


def alternatives() -> str:
    lines = [
        "# Exact text, tactile and no-print routes for eight A4 Music aids",
        "",
        "The invented lanes are **pitch L/H (low/high), electronic source A/B (circle/striped square), and planned level V/S (very soft/soft, one/two dots)**. Large fixed numbered places and raised labelled marks make the score accessible without colour, speaker, screen or printer. A neutral adult reads or moves cards only as the child directs. A written score meets planning/reading targets, not actual hearing, performance or comfort. Candidate sound has a separate human-listening/device-volume gate. Fresh-check descriptions provide the public score but not the worked answer.",
        "",
    ]
    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:** The paper/raised score shows intended and revised marks. Actual sound, aural perception, comfort, community context and audience receipt need their own observations.", ""))
    return "\n".join(lines)


CUES: dict[str, tuple[Triple, Triple, Triple, Triple]] = {
    "cue-base": BASE,
    "cue-pitch-2": (("L", "A", "V"), ("H", "A", "V"), ("L", "A", "V"), ("L", "A", "V")),
    "cue-source-4": TAKE_SOURCE,
    "cue-level-1": (("L", "A", "S"), ("L", "A", "V"), ("L", "A", "V"), ("L", "A", "V")),
}


def pcm(values: tuple[Triple, Triple, Triple, Triple]) -> bytes:
    events = [(0.1 + i * 0.5, 0.20, value) for i, value in enumerate(values)]
    data = bytearray()
    for index in range(round(SECONDS * RATE)):
        now = index / RATE
        value = 0.0
        for start, duration, (pitch, source, level) in events:
            local = now - start
            if 0 <= local < duration:
                fade = min(1.0, local / 0.012, (duration - local) / 0.012)
                hz = 330 if pitch == "L" else 392
                fundamental = math.sin(2 * math.pi * hz * local)
                tone = fundamental if source == "A" else 0.8 * fundamental + 0.6 * math.sin(2 * math.pi * 3 * hz * local)
                amp = 0.018 if level == "V" else 0.050
                value += amp * fade * tone
        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 media")
    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():
        target = ROOT / f"{key}.svg"
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(svg(title, description, draw()), encoding="utf-8")
        subprocess.run(
            ["rsvg-convert", "--format", "pdf", "--output", str(target.with_suffix(".pdf")), str(target)],
            check=True,
            env=dict(os.environ, SOURCE_DATE_EPOCH="1790640000"),
        )
    (ROOT / "print/TEXT-ALTERNATIVES.md").write_text(alternatives(), encoding="utf-8")
    for name, values 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(values))
    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)} original PCM candidates.")


if __name__ == "__main__":
    main()
