#!/usr/bin/env python
"""
LilyPond Transcriber - Convert chord data to LilyPond parts and PDF.

Usage:
    python src/transcriber.py --name compact_sets_1

Or import and use programmatically:
    from src.transcriber import transcribe
    transcribe(chords, name="my_piece")
"""

import json
import math
import subprocess
import sys
from fractions import Fraction
from pathlib import Path


NOTE_NAMES_SHARPS = [
    "c",
    "cis",
    "d",
    "dis",
    "e",
    "f",
    "fis",
    "g",
    "gis",
    "a",
    "ais",
    "b",
]
NOTE_NAMES_FLATS = [
    "c",
    "des",
    "d",
    "ees",
    "e",
    "f",
    "ges",
    "g",
    "aes",
    "a",
    "bes",
    "b",
]

OCTAVE_STRINGS = [
    ",,,,",
    ",,,",
    ",,",
    ",",
    "",
    "'",
    "''",
    "'''",
    "''''",
    "'''''",
    "''''''",
]

DURATION_MAP = {
    1: "8",
    2: "4",
    3: "4.",
    4: "1",
    6: "2.",
    8: "2",
}


def cps_to_midi(freq):
    """Convert frequency in Hz to MIDI note number."""
    if freq <= 0:
        return -1
    return 12 * math.log2(freq / 440.0) + 69


def midi_to_pitch_class(midi):
    """Get pitch class (0-11) from MIDI note number."""
    if midi < 0:
        return -1
    return round(midi) % 12


def midi_to_octave(midi):
    """Get LilyPond octave number from MIDI note number."""
    if midi < 0:
        return -1
    return (round(midi) // 12) - 1


def get_clef_for_midi(midi):
    """Determine clef based on MIDI note number.

    Two-threshold system at middle C (C4 = MIDI 60):
    - MIDI >= 60: treble clef
    - MIDI < 60: bass clef
    """
    if midi >= 60:
        return "treble"
    else:
        return "bass"


def freq_to_lilypond(freq, spelling="sharps", prev_pitch=None):
    """Convert frequency to LilyPond note name.

    Args:
        freq: Frequency in Hz
        spelling: "sharps" or "flats" (determines base preference)
        prev_pitch: Previous pitch class (for contextual spelling)

    Returns:
        LilyPond note string (e.g., "ais''", "ees'") or "r" for rest
    """
    if freq <= 0:
        return "r"

    midi = cps_to_midi(freq)
    pc = midi_to_pitch_class(midi)
    octave = midi_to_octave(midi)

    if spelling == "flats":
        note_name = NOTE_NAMES_FLATS[pc]
    else:
        note_name = NOTE_NAMES_SHARPS[pc]

    oct_str = OCTAVE_STRINGS[octave + 1] if octave >= -4 else ",," * (-octave - 4)
    return note_name + oct_str


def duration_to_lilypond(beats):
    """Convert quarter-note beats to LilyPond duration string."""
    beats = int(round(beats))
    return DURATION_MAP.get(beats, "4")


def format_cents_deviation(freq):
    """Format cent deviation from nearest equal-tempered note."""
    if freq <= 0:
        return None

    midi = cps_to_midi(freq)
    deviation = (midi - round(midi)) * 100
    deviation = round(deviation)

    if deviation > 0:
        return f"+{deviation}"
    else:
        return str(deviation)


def format_dim_diff(dim_diff, ref):
    """Format dimensional difference markup."""
    if dim_diff is None or ref is None or ref < 0 or dim_diff == 0:
        return ""

    diff_str = str(abs(dim_diff))
    if dim_diff > 1:
        diff_str += "↑"
    elif dim_diff < 0:
        diff_str += "↓"

    ref_names = ["IV", "III", "II", "I"]
    ref_name = ref_names[ref] if 0 <= ref <= 3 else ""

    return f'_\\markup {{ \\lower #3 \\pad-markup #0.2 \\concat{{ "{ref_name}"\\normal-size-super " {diff_str}" }} }}'


def generate_part(voice_data, voice_name, voice_idx, clef=None, beats_per_measure=4):
    """Generate LilyPond music string for a single voice.

    Args:
        voice_data: List of [freq, duration_beats, ref, dim_diff] events
        voice_name: Voice name (e.g., "I", "II", "III")
        voice_idx: Voice index (0=I, 1=II, 2=III)
        clef: LilyPond clef name (e.g., "treble", "alto", "bass") - optional, determined from first note if not provided
        beats_per_measure: Beats per measure (default 4 for 4/4)

    Returns:
        LilyPond music string with clef and time signature
    """
    if not voice_data:
        return "\\numericTimeSignature \\time 4/4\n"

    first_freq = voice_data[0][0]
    if clef is None:
        initial_clef = get_clef_for_midi(cps_to_midi(first_freq))
    else:
        initial_clef = clef

    prefix = f"\\clef {initial_clef}\n"
    prefix += "\\numericTimeSignature \\time 4/4\n"

    spelling = "sharps"

    notes = []
    for event in voice_data:
        freq = event[0]
        dur_beats = event[1] if len(event) > 1 else 1
        ref = event[2] if len(event) > 2 else None
        dim_diff = event[3] if len(event) > 3 else None

        is_rest = freq <= 0
        note_str = freq_to_lilypond(freq, spelling)
        dur_str = duration_to_lilypond(dur_beats)

        notes.append(
            {
                "freq": freq,
                "is_rest": is_rest,
                "note_str": note_str,
                "dur_str": dur_str,
                "dur_beats": dur_beats,
                "ref": ref,
                "dim_diff": dim_diff,
            }
        )

    measures = []
    current_measure_notes = []
    beat_in_measure = 0
    current_clef = initial_clef

    for i, note_data in enumerate(notes):
        freq = note_data["freq"]
        is_rest = note_data["is_rest"]

        midi = cps_to_midi(freq)
        required_clef = get_clef_for_midi(midi)
        clef_change = required_clef != current_clef
        if clef_change:
            current_clef = required_clef

        has_prev = i > 0
        prev_freq = notes[i - 1]["freq"] if has_prev else None
        prev_is_rest = notes[i - 1]["is_rest"] if has_prev else True
        is_tied_from_prev = (
            has_prev and not is_rest and not prev_is_rest and freq == prev_freq
        )

        cents_dev = (
            format_cents_deviation(freq)
            if not is_rest and not is_tied_from_prev
            else None
        )
        dim_markup = (
            format_dim_diff(note_data["dim_diff"], note_data["ref"])
            if not is_rest
            else ""
        )

        note_str_full = note_data["note_str"] + note_data["dur_str"]

        markup = ""
        if cents_dev or dim_markup:
            if cents_dev:
                markup += f'^\\markup {{ \\pad-markup #0.2 "{cents_dev}" }}'
            if dim_markup:
                markup += dim_markup
            note_str_full += markup

        has_next = i < len(notes) - 1
        next_freq = notes[i + 1]["freq"] if has_next else None
        next_is_rest = notes[i + 1]["is_rest"] if has_next else True
        is_tied_to_next = (
            has_next and not is_rest and not next_is_rest and freq == next_freq
        )

        if is_tied_to_next:
            note_str_full += " ~"
        else:
            note_str_full = " " + note_str_full

        if clef_change:
            note_str_full = f"\\clef {current_clef} {note_str_full}"

        current_measure_notes.append(note_str_full)

        beats_this_event = int(round(note_data["dur_beats"]))
        beat_in_measure += beats_this_event

        while beat_in_measure >= beats_per_measure:
            beat_in_measure -= beats_per_measure
            measures.append("".join(current_measure_notes))
            current_measure_notes = []

    if current_measure_notes:
        measures.append("".join(current_measure_notes))

    music_str = ""
    for i, measure in enumerate(measures):
        music_str += "{ " + measure + " }"
        if i < len(measures) - 1:
            music_str += '  \n\\bar "|"  '

    music_str += '\n\\bar "|."'

    return prefix + music_str


def generate_parts(music_data, name, output_dir="lilypond"):
    """Generate LilyPond part files.

    Args:
        music_data: List of voices, each voice is a list of events
        name: Name for the output (e.g., "compact_sets_1")
        output_dir: Base output directory
    """
    includes_dir = Path(output_dir) / name / "includes"
    includes_dir.mkdir(parents=True, exist_ok=True)

    voice_order = [
        (3, "I"),
        (2, "II"),
        (1, "III"),
        (0, "IV"),
    ]

    for voice_idx, voice_name in voice_order:
        if voice_idx >= len(music_data):
            continue
        voice_data = music_data[voice_idx]
        part_str = generate_part(voice_data, voice_name, voice_idx)

        part_file = includes_dir / f"part_{voice_name}.ly"
        with open(part_file, "w") as f:
            f.write(part_str)

        print(f"Generated: {part_file}")


def _is_adjacent(hs1: tuple, hs2: tuple) -> bool:
    """Check if two hs_arrays are adjacent (differ by ±1 in exactly one dimension, excluding dim 0)."""
    diff_count = 0
    for i in range(1, len(hs1)):
        diff = abs(hs1[i] - hs2[i])
        if diff > 1:
            return False
        if diff == 1:
            diff_count += 1
    return diff_count == 1


def _compute_dim_diff(current: tuple, prev: tuple, primes: list[int]) -> int:
    """Compute dim_diff between two hs_arrays. Returns prime * direction."""
    for i in range(1, len(primes) + 1):
        diff = current[i] - prev[i]
        if diff == 1:
            return primes[i - 1]
        if diff == -1:
            return -primes[i - 1]
    return 0


def _find_ref_and_dim_diff(
    current_hs: tuple, prev_chord: list, staying_voices: list, primes: list[int]
) -> tuple[int, int]:
    """Find ref (staying voice index) and dim_diff for a changed pitch.

    Args:
        current_hs: hs_array of current pitch
        prev_chord: list of hs_arrays from previous chord
        staying_voices: indices of voices that stay
        primes: list of primes for dimensional calculation

    Returns:
        (ref, dim_diff) tuple
    """
    if not staying_voices:
        return -1, 0

    adjacent = []
    for idx in staying_voices:
        prev_hs = prev_chord[idx]
        if _is_adjacent(current_hs, prev_hs):
            dim_diff = _compute_dim_diff(current_hs, prev_hs, primes)
            adjacent.append((idx, dim_diff))

    if not adjacent:
        return -1, 0

    adjacent.sort(key=lambda x: abs(x[1]))
    return adjacent[0]


def _find_ref_in_same_chord(
    pitch_idx: int, chord_pitches: list, primes: list[int]
) -> tuple[int, int]:
    """Find ref (other pitch index) and dim_diff within the same chord.

    Args:
        pitch_idx: index of the current pitch in the chord
        chord_pitches: list of hs_arrays for all pitches in the chord
        primes: list of primes for dimensional calculation

    Returns:
        (ref, dim_diff) tuple where ref is index of adjacent pitch in same chord
    """
    current_hs = chord_pitches[pitch_idx]
    adjacent = []

    for idx, other_hs in enumerate(chord_pitches):
        if idx == pitch_idx:
            continue
        if _is_adjacent(current_hs, other_hs):
            dim_diff = _compute_dim_diff(current_hs, other_hs, primes)
            adjacent.append((idx, dim_diff))

    if not adjacent:
        return -1, 0

    adjacent.sort(key=lambda x: abs(x[1]))
    return adjacent[0]


def output_chords_to_music_data(chords, fundamental=55, chord_duration=4, dims=None):
    """Convert output_chords.json format to generic music data.

    Args:
        chords: List of chords from output_chords.json
        fundamental: Fundamental frequency in Hz
        chord_duration: Duration of each chord in beats
        dims: Tuple of prime dimensions (optional, for computing dim_diff)

    Returns:
        List of voices, each voice is a list of [freq, duration, ref, dim_diff]
    """
    if not chords:
        return []

    # Compute primes from dims (skip dimension 0 which is the fundamental)
    if dims is not None:
        primes = list(dims[1:])  # Skip first prime (2)
    else:
        primes = [3, 5, 7, 11]  # Default fallback

    num_voices = len(chords[0])

    music_data = [[] for _ in range(num_voices)]

    prev_chord = None
    for chord in chords:
        current_hs = [tuple(p["hs_array"]) for p in chord]

        if prev_chord is None:
            staying_voices = []
        else:
            staying_voices = [
                i for i in range(num_voices) if current_hs[i] == prev_chord[i]
            ]

        for voice_idx, pitch in enumerate(chord):
            if voice_idx >= num_voices:
                break

            frac = Fraction(pitch["fraction"])
            freq = fundamental * float(frac)
            current_hs_array = current_hs[voice_idx]

            if prev_chord is None:
                ref, dim_diff = _find_ref_in_same_chord(voice_idx, current_hs, primes)
            elif current_hs_array == prev_chord[voice_idx]:
                ref = -1
                dim_diff = 0
            else:
                ref, dim_diff = _find_ref_and_dim_diff(
                    current_hs_array, prev_chord, staying_voices, primes
                )

            event = [freq, chord_duration, ref, dim_diff]
            music_data[voice_idx].append(event)

        prev_chord = current_hs

    return music_data


def generate_score(name, num_voices, output_dir="lilypond"):
    """Generate full score .ly file from template.

    Args:
        name: Name for the output (used as title)
        num_voices: Number of voices/staves to generate
        output_dir: Base output directory
    """
    template_path = Path(output_dir) / name / "score_template.ly"
    if not template_path.exists():
        print(f"Error: Template not found: {template_path}")
        return False

    score_path = Path(output_dir) / name / f"{name}.ly"
    score_text = template_path.read_text()

    score_text = score_text.replace("{NAME}", name)

    from datetime import date

    today = date.today().strftime("%d %b %Y")
    score_text = score_text.replace("{DATE}", today)

    voice_names = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII"]
    staves = ""
    for i in range(num_voices):
        v_name = voice_names[i]
        staves += f'''
        \\new Staff = "{v_name}" \\with {{ 
          instrumentName = "{v_name}" 
          shortInstrumentName = "{v_name}" 
          midiInstrument = #"clarinet" 
        }}
        {{
          \\include "includes/part_{v_name}.ly"
        }}
'''

    score_block = f"""\\score{{
  <<
    \\new SemiStaffGroup {{
      <<
{staves}
      >>
    }}
  >>
  \\layout{{}}
}}"""

    score_text = score_text.replace("{SCORE}", score_block)

    with open(score_path, "w") as f:
        f.write(score_text)

    print(f"Generated: {score_path}")
    return True


def generate_pdf(name, lilypond_dir="lilypond", output_dir="."):
    """Generate PDF from LilyPond score.

    Args:
        name: Name of the piece (LilyPond file should be {name}.ly)
        lilypond_dir: Directory containing the LilyPond file
        output_dir: Directory for PDF output

    Returns:
        Path to generated PDF, or None if failed
    """
    ly_file = Path(lilypond_dir) / name / f"{name}.ly"
    if not ly_file.exists():
        print(f"Error: LilyPond file not found: {ly_file}")
        return None

    output_base = Path(output_dir) / name
    output_base.mkdir(parents=True, exist_ok=True)

    try:
        result = subprocess.run(
            [
                "lilypond",
                "-o",
                str(output_base),
                "-f",
                "pdf",
                str(ly_file),
            ],
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            print(f"LilyPond error:\n{result.stderr}")
            return None

        pdf_path = output_base / f"{name}.pdf"
        if pdf_path.exists():
            print(f"Generated: {pdf_path}")
            return pdf_path
        else:
            print(f"Warning: LilyPond ran but PDF not found at {pdf_path}")
            print(f"Output: {result.stdout}")
            return None

    except FileNotFoundError:
        print("Error: lilypond command not found. Is LilyPond installed?")
        return None


def transcribe(
    chords,
    name,
    fundamental=55,
    output_dir="lilypond",
    generate_pdf_flag=True,
):
    """Main transcription function.

    Args:
        chords: Chord data (list from output_chords.json or music_data format)
        name: Name for the output
        fundamental: Fundamental frequency in Hz
        output_dir: Base output directory
        generate_pdf_flag: Whether to generate PDF

    Returns:
        Dictionary with paths to generated files
    """
    import shutil

    # Handle both old format (list of chords) and new format (dict with dims + chords)
    dims = None
    if isinstance(chords, dict) and "chords" in chords:
        dims = tuple(chords["dims"])
        chords = chords["chords"]

    if chords and isinstance(chords[0], list) and isinstance(chords[0][0], dict):
        music_data = output_chords_to_music_data(chords, fundamental, dims=dims)
    else:
        music_data = chords

    output_path = Path(output_dir) / name
    output_path.mkdir(parents=True, exist_ok=True)

    template_source = Path(__file__).parent.parent / "lilypond" / "score_template.ly"
    if template_source.exists():
        shutil.copy(template_source, output_path / "score_template.ly")

    generate_parts(music_data, name, output_dir)

    num_voices = len(music_data)
    generate_score(name, num_voices, output_dir)

    result = {
        "parts_dir": str(Path(output_dir) / name / "includes"),
        "score_file": str(Path(output_dir) / name / f"{name}.ly"),
    }

    if generate_pdf_flag:
        pdf_path = generate_pdf(name, output_dir, output_dir)
        if pdf_path:
            result["pdf"] = str(pdf_path)

    return result


def main():
    import argparse

    parser = argparse.ArgumentParser(description="LilyPond Transcriber")
    parser.add_argument(
        "--output-dir", default="output", help="Directory with output_chords.json"
    )
    parser.add_argument(
        "--chords-file", default=None, help="Chords file (default: output_chords.json)"
    )
    parser.add_argument(
        "--name", default="compact_sets_transcription", help="Name for output files"
    )
    parser.add_argument(
        "--fundamental", type=float, default=55, help="Fundamental frequency in Hz"
    )
    parser.add_argument(
        "--lilypond-dir", default="lilypond", help="Base LilyPond output directory"
    )
    parser.add_argument("--no-pdf", action="store_true", help="Skip PDF generation")

    args = parser.parse_args()

    chords_file = args.chords_file
    if chords_file is None:
        chords_file = Path(args.output_dir) / "output_chords.json"

    if not Path(chords_file).exists():
        print(f"Error: Chords file not found: {chords_file}")
        print("Run compact_sets.py first to generate chords.")
        sys.exit(1)

    with open(chords_file) as f:
        chords = json.load(f)

    print(f"Loaded {len(chords)} chords from {chords_file}")

    result = transcribe(
        chords,
        args.name,
        fundamental=args.fundamental,
        output_dir=args.lilypond_dir,
        generate_pdf_flag=not args.no_pdf,
    )

    print("\nGenerated files:")
    for key, path in result.items():
        print(f"  {key}: {path}")


if __name__ == "__main__":
    main()
