#!/usr/bin/env python3
"""Reproduce original A4 two-line texture scores and PCM cue candidates."""
from __future__ import annotations
import argparse
from html import escape
import math
import os
from pathlib import Path
import struct
import subprocess
import wave
import xml.etree.ElementTree as ET

ROOT = Path(__file__).resolve().parent
INK = '#173a4b'
PALE = '#e7f1ee'
SAND = '#f6edcf'
BLUE = '#d8e7f4'
RATE = 22050
DURATION = 3.2
PULSE_STARTS = (.2, .7, 1.2, 1.7, 2.2, 2.7)
CUES = {'cue-together': ((1,3,5),(1,3,5)), 'cue-turns': ((1,3,5),(2,4,6)), 'cue-mix': ((1,3,5),(1,4,6))}


def text(x: int, y: int, value: str, size: int = 18, bold: bool = False) -> str:
    """Return escaped searchable SVG text."""
    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 = 10) -> str:
    """Return an outlined shape with redundant colour."""
    return f'<rect x="{x}" y="{y}" width="{w}" height="{h}" rx="{radius}" fill="{fill}" stroke="{INK}" stroke-width="2.5"/>'


def mark(x: int, y: int, line: str) -> str:
    """Draw distinct circle/square line identities."""
    if line == 'A':
        return f'<circle cx="{x}" cy="{y}" r="17" fill="{SAND}" stroke="{INK}" stroke-width="3"/>'
    return rect(x-17, y-17, 34, 34, BLUE, 0)


def grid(y: int, label: str, a: tuple[int, ...], b: tuple[int, ...], blank: tuple[str, ...] = ()) -> list[str]:
    """Draw a six-place paired score with explicit wait marks."""
    out = [text(75,y,label,19,True), text(76,y+68,'A',20,True),text(76,y+148,'B',20,True)]
    for i in range(1,7):
        x = 115+(i-1)*96
        out.extend([text(x+34,y+31,str(i),18,True),rect(x,y+40,84,64,PALE),rect(x,y+120,84,64,PALE)])
        for line, active, cy in (('A',a,y+72),('B',b,y+152)):
            out.append(mark(x+42,cy,line) if i in active else text(x+25,cy+6,'?' if line in blank else '—',24,True))
    return out


def panel(y: int, lines: tuple[str, ...]) -> list[str]:
    """Make a generous text panel."""
    out = [rect(75,y,644,40+len(lines)*35,PALE)]
    out.extend(text(94,y+36+i*35,s,17,i==0) for i,s in enumerate(lines))
    return out


def key() -> list[str]:
    """Draw the line and shared-place key."""
    out = [text(77,317,'TWO PARTS SHARE ONE TIME LINE',19,True)]
    for y,line,word in ((355,'A','A · CIRCLE LINE'),(500,'B','B · SQUARE LINE')):
        out += [rect(75,y,644,125,PALE),mark(117,y+62,line),text(170,y+51,word,20,True),text(170,y+88,'A mark = this line sounds here.',18)]
    out += [rect(75,645,644,115,PALE),text(105,710,'—',30,True),text(170,692,'DASH = THIS LINE WAITS',19,True),text(170,727,'Wait is silence for this line at this place.',16)]
    out += panel(799,('Read each column from A to B, then go right.','Same column = same planned start.','Six numbered places stay evenly spaced.','A score is a plan; sound remains optional.'))
    return out


def one(a: tuple[int, ...], b: tuple[int, ...], notes: tuple[str, ...]) -> list[str]:
    """Make a practice grid and short interpretation prompts."""
    return grid(350,'SIX SHARED PLACES',a,b)+panel(662,notes)


def two(a: tuple[int, ...], b: tuple[int, ...], c: tuple[int, ...], d: tuple[int, ...], notes: tuple[str, ...], labels: tuple[str,str], blank: tuple[str, ...]=(), first_blank: tuple[str, ...]=()) -> list[str]:
    """Make two equal-size comparable six-place grids."""
    return grid(332,labels[0],a,b,first_blank)+grid(600,labels[1],c,d,blank)+panel(833,notes)


PAGES = {
 'print/line-key': ('A · Two lines, shared places','Circle A and square B name separate musical parts. A dash is this line waiting, not a new sound. Read A then B within each of six equal places.','Two raised horizontal strips, A one round tag and B one square tag. Make six broad vertically aligned columns with separators; round/square tokens mean a sound, a flat dash means wait. Read each column A then B before advancing.',key),
 'print/together-grid': ('B · Two parts together','A circles at 1,3,5; B squares at 1,3,5. Both sound in places 1,3,5. Neither sounds in 2,4,6.','Build A 1,3,5 and B 1,3,5 with raised round/square tokens. Flat dashes at all other positions. Touch both rows in each column; no-sound composition comparison is complete.',lambda:one((1,3,5),(1,3,5),('A: sound / wait / sound / wait / sound / wait.','B: sound / wait / sound / wait / sound / wait.','Which places have both? Which have neither?','Planned together is not proof of actual playing.'))),
 'print/turn-grid': ('C · Two parts take turns','A circles at 1,3,5; B squares at 2,4,6. Exactly one part sounds in each place. There is no simultaneous place.','Build A 1,3,5 and B 2,4,6 with round/square tokens. Dashes fill other cells. Feel/read each column and identify its one active line.',lambda:one((1,3,5),(2,4,6),('A: sound / wait / sound / wait / sound / wait.','B: wait / sound / wait / sound / wait / sound.','Name the line for each numbered place.','No place has both; one line sounds at each place.'))),
 'print/mix-grid': ('D · Meet, then separate','A circles at 1,3,5; B squares at 1,4,6. Place 1 both; place 2 neither; place 3 A; place 4 B; place 5 A; place 6 B.','Raised rows A 1,3,5 and B 1,4,6; dash elsewhere. Read column-by-column: both, neither, A, B, A, B. No physical movement is requested.',lambda:one((1,3,5),(1,4,6),('One place meets; later places separate.','An empty column means both lines wait.','All six place starts stay evenly spaced.','Ask the maker what their idea means.'))),
 'print/hold-change': ('E · Keep A, move B','First score A 1,2,5 and B 1,2,5. Second score A 1,2,5 and B 1,4,6. A holds; B removes 2,5 and adds 4,6, keeping three marks.','Build both A rows identically at 1,2,5. Build first B 1,2,5 and second B 1,4,6. Find each removed and added B token separately; do not infer listener meaning.',lambda:two((1,2,5),(1,2,5),(1,2,5),(1,4,6),('A stays; B still has three marks.','B moves from 2 to 4 and from 5 to 6.','Maker intention / actual response are separate.'),('FIRST PLAN','REVISED PLAN'))),
 'print/plan-actual': ('F · My two-line plan','Two blank six-place grids labelled First plan and Another plan. Question marks are empty planning cells, not wait decisions. Actual playback and recipient fields are unset.','Two paired raised six-column strips with blank removable cells. Learner directs A round and B square tokens or dash waits; copy first work before revision. Leave actual fields unset until real.',lambda:two((),(),(),(),('Actual sound: NOT TESTED / device: ______','Actual recipient: NOT SHARED / words: ______','Paper marks show a planned music texture.'),('FIRST PLAN · CHILD CHOOSES','ANOTHER PLAN · CHILD CHOOSES'),('A','B'),('A','B'))),
 'check/texture-1': ('Fresh Texture 1 · Read two plans','Plan T has A and B marks at 2,4,6. Plan U has A at 2,4,6 and B at 1,3,5. All other cells are dashes. Public questions give no answer.', 'Raised T: A round 2,4,6; B square 2,4,6. Raised U: A round 2,4,6; B square 1,3,5. Flat dashes elsewhere. Read every mark neutrally; do not name either texture for the learner.',lambda:two((2,4,6),(2,4,6),(2,4,6),(1,3,5),('Which has both lines at the same places?','Which takes turns? Give one place as evidence.','What holds? What can paper not prove?'),('PLAN T','PLAN U'))),
 'check/texture-2': ('Fresh Texture 2 · Move only B','Given score A 2,3,5 and B 2,3,5. Response A stays at 2,3,5; every B cell is blank with a question mark. Learner must keep three B marks, make one sound in every place and no simultaneous place.', 'Raised given A round 2,3,5; B square 2,3,5. Response A fixed 2,3,5 with dash waits; B six blank slots. Child directs exactly three B placements; adult supplies no solution or rhythm cue.',lambda:two((2,3,5),(2,3,5),(2,3,5),(),('Keep A. Put exactly three B marks in your plan.','Make one line sound at each of the six places.','No place has both. Other B cells must wait.'),('GIVEN PLAN','YOUR PLAN · KEEP A, CHOOSE B'),('B',))),
}


def svg(title: str, description: str, body: list[str]) -> str:
    """Return standalone A4 accessible SVG."""
    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),text(57,80,'SUBJECTNEST · FOUNDATION MUSIC',22,True),text(57,125,'Two musical lines · together or taking turns',17),text(57,185,title,22,True),text(76,239,'A = CIRCLE / B = SQUARE / DASH = WAIT',17,True),text(76,270,'Columns 1–6 are six equal planned pulse places.',16),*body,text(57,1055,'Original resource · CC BY 4.0 · exact alternatives in pack',13),'</svg>'])+'\n'


def alternatives() -> str:
    """Supply all printed text plus exact structural/tactile descriptions."""
    out=['# Exact linear, tactile and no-sound equivalents','', 'All pages read top to bottom. Within a score read numbered place 1 A then B, place 2 A then B, through place 6. Circle and square name musical parts; colour is redundant. A dash means this line waits. A question mark means an undecided cell. The no-sound route meets score-planning/texture reasoning; it cannot prove hearing or performed simultaneity. An adult moves broad tokens only as directed. Keep layouts away from mouth; use large school-approved parts fixed to a stable surface.','']
    for path,(title,desc,tactile,draw) in PAGES.items():
        root=ET.fromstring(svg(title,desc,draw()))
        printed=[n.text or '' for n in root.iter() if n.tag.rsplit('}',1)[-1]=='text']
        out += [f'## {title}','',f'**File:** {path}.svg / {path}.pdf.',f'**Exact linear content:** {desc}','**Verbatim printed text in layout order:** '+' | '.join(printed)+'.',f'**Tactile/no-print/no-sound:** {tactile}','']
    return '\n'.join(out)


def pcm(a: tuple[int,...], b: tuple[int,...]) -> bytes:
    """Synthesize two quiet original timbres on one pitch anchor."""
    data=bytearray()
    for i in range(round(DURATION*RATE)):
        t=i/RATE
        value=0.0
        for line,slots in (('A',a),('B',b)):
            for slot in slots:
                local=t-PULSE_STARTS[slot-1]
                if 0 <= local < .2:
                    fade=min(1.0,local/.012,(.2-local)/.012)
                    tone=.016*math.sin(2*math.pi*330*local) if line=='A' else .010*(math.sin(2*math.pi*330*local)+.5*math.sin(2*math.pi*660*local))
                    value += fade*tone
        data += struct.pack('<h',round(value*32767))
    return bytes(data)


def main() -> None:
    """Regenerate only this pack on explicit --write."""
    parser=argparse.ArgumentParser(); parser.add_argument('--write',action='store_true'); args=parser.parse_args()
    if not args.write: parser.error('use --write for intentional regeneration')
    for path,(title,desc,_tactile,draw) in PAGES.items():
        target=ROOT/f'{path}.svg'; target.parent.mkdir(exist_ok=True)
        target.write_text(svg(title,desc,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,(a,b) in CUES.items():
        with wave.open(str(ROOT/'audio'/f'{name}.wav'),'wb') as out:
            out.setnchannels(1); out.setsampwidth(2); out.setframerate(RATE); out.writeframes(pcm(a,b))

if __name__ == '__main__': main()
