#!/usr/bin/env python3
"""Build an original procedural rock loop and its evidence manifest.

Candidate work sample for FreezingMoon/AncientBeast#1748. This program performs
no submission or network operation. Audio is streamed to disk to keep memory use
bounded.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import random
import struct
import wave
from pathlib import Path

RATE = 44_100
BPM = 132
BEAT = 60.0 / BPM
BARS = 16
DURATION = BARS * 4 * BEAT
TAU = math.tau
SEED = 1748
ROOTS = [40, 40, 43, 38, 40, 47, 43, 38] * 2


def hz(note: int) -> float:
    return 440.0 * 2.0 ** ((note - 69) / 12.0)


def envelope(t: float, length: float, attack: float, release: float) -> float:
    if t < 0.0 or t >= length:
        return 0.0
    return min(1.0, t / attack, (length - t) / release)


def guitar(freq: float, t: float, length: float) -> float:
    e = envelope(t, length, 0.006, 0.09) * math.exp(-0.38 * t)
    value = 0.0
    for detune, gain in ((0.994, 0.34), (1.0, 0.48), (1.006, 0.34)):
        phase = TAU * freq * detune * t
        value += gain * (math.sin(phase) + 0.40 * math.sin(3 * phase))
    return math.tanh(2.6 * value) * e


def bass(freq: float, t: float, length: float) -> float:
    e = envelope(t, length, 0.004, 0.07) * math.exp(-0.5 * t)
    phase = TAU * freq * t
    return math.tanh(1.6 * (math.sin(phase) + 0.3 * math.sin(2 * phase))) * e


def kick(t: float) -> float:
    if not 0.0 <= t <= 0.28:
        return 0.0
    phase = TAU * (47 * t + 55 * math.exp(-22 * t) * t)
    return math.sin(phase) * math.exp(-18 * t)


def snare(t: float, noise: float) -> float:
    if not 0.0 <= t <= 0.22:
        return 0.0
    return (0.82 * noise + 0.28 * math.sin(TAU * 185 * t)) * math.exp(-20 * t)


def hat(t: float, noise: float, opened: bool) -> float:
    limit = 0.16 if opened else 0.055
    if not 0.0 <= t <= limit:
        return 0.0
    decay = 24 if opened else 70
    metal = 0.18 * math.sin(TAU * 7113 * t) + 0.12 * math.sin(TAU * 9237 * t)
    return (0.65 * noise + metal) * math.exp(-decay * t)


def lead(freq: float, t: float, length: float) -> float:
    e = envelope(t, length, 0.018, 0.12)
    phase = TAU * freq * (1 + 0.004 * math.sin(TAU * 5.4 * t)) * t
    return math.tanh(1.5 * (math.sin(phase) + 0.25 * math.sin(2 * phase))) * e


def build_lead_events() -> list[tuple[float, int, float]]:
    motif = [64, 67, 71, 69, 67, 64, 67, 69, 71, 74, 71, 69, 64, 67, 69, 71]
    events = []
    start = 8 * BEAT
    for index in range(56):
        length = BEAT * (0.5 if index % 4 != 3 else 1.0)
        if start + length >= DURATION - 0.02:
            break
        events.append((start, motif[index % len(motif)], length * 0.9))
        start += length
    return events


def render(output: Path) -> dict:
    rng = random.Random(SEED)
    events = build_lead_events()
    frames = round(DURATION * RATE)
    output.parent.mkdir(parents=True, exist_ok=True)

    with wave.open(str(output), "wb") as wav:
        wav.setnchannels(2)
        wav.setsampwidth(2)
        wav.setframerate(RATE)
        block = bytearray()

        for index in range(frames):
            t = index / RATE
            beat_position = t / BEAT
            bar = min(int(beat_position // 4), BARS - 1)
            beat_in_bar = beat_position % 4
            eighth_start = math.floor(beat_position * 2) * BEAT / 2
            local = t - eighth_start
            root = ROOTS[bar]
            left = right = 0.0

            accent = 1.0 if int(beat_position * 2) % 8 in (0, 6) else 0.72
            for note in (root, root + 7, root + 12):
                left += 0.135 * accent * guitar(hz(note), local, BEAT * 0.43)
                right += 0.135 * accent * guitar(hz(note) * 1.0018, local + 0.003, BEAT * 0.43)

            bass_note = root if int(beat_position * 2) % 4 != 3 else root + 7
            low = 0.27 * bass(hz(bass_note), local, BEAT * 0.45)
            left += low
            right += low

            for position in (0.0, 0.5, 2.0, 3.5 if bar % 4 == 3 else 2.75):
                drum = 0.52 * kick((beat_in_bar - position) * BEAT)
                left += drum
                right += drum
            noise = rng.uniform(-1.0, 1.0)
            for position in (1.0, 3.0):
                drum = 0.34 * snare((beat_in_bar - position) * BEAT, noise)
                left += drum * 0.96
                right += drum * 1.04

            opened = bar % 4 == 3 and int(beat_position * 2) % 8 == 7
            cymbal = 0.12 * hat(local, rng.uniform(-1.0, 1.0), opened)
            left += cymbal * 0.72
            right += cymbal * 1.28

            for start, note, length in events:
                if start <= t < start + length:
                    melody = 0.19 * lead(hz(note), t - start, length)
                    pan = 0.22 * math.sin(TAU * 0.16 * t)
                    left += melody * (1 - pan)
                    right += melody * (1 + pan)
                    break

            fade = min(1.0, t / 0.012, (DURATION - t) / 0.012)
            left = math.tanh(left * 1.15) * fade * 0.92
            right = math.tanh(right * 1.15) * fade * 0.92
            block.extend(struct.pack("<hh", int(left * 32767), int(right * 32767)))
            if len(block) >= 65_536:
                wav.writeframesraw(block)
                block.clear()
        if block:
            wav.writeframesraw(block)

    digest = hashlib.sha256(output.read_bytes()).hexdigest()
    return {
        "artifact": output.name,
        "generator": Path(__file__).name,
        "candidate_for": "https://github.com/FreezingMoon/AncientBeast/issues/1748",
        "status": "work sample only; not submitted or accepted",
        "authorship": "original procedural composition and synthesis by ConstaLabs Automaton",
        "license_offer": "CC0-1.0 if accepted by the project",
        "sha256": digest,
        "bytes": output.stat().st_size,
        "channels": 2,
        "sample_width_bytes": 2,
        "sample_rate": RATE,
        "frames": frames,
        "duration_seconds": round(frames / RATE, 3),
        "seed": SEED,
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("-o", "--output", type=Path, default=Path(__file__).with_name("constalabs_iron_march.wav"))
    args = parser.parse_args()
    metadata = render(args.output)
    folder = args.output.parent
    (folder / "artifact.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
    (folder / "SHA256SUMS").write_text(f"{metadata['sha256']}  {args.output.name}\n", encoding="utf-8")
    print(json.dumps(metadata))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
