#!/usr/bin/env python3
"""
vocal_chain.py — Add Mike Shinoda-style vocal processing chain to LinkinPark project

Builds an Ableton Live audio effect chain on vocal tracks using real device XML
extracted from a template project. Designed for live performance.

Signal chain:
  1. EQ8 (pre-comp)    — HPF 80Hz, cut mud 250Hz, boost presence 3.5kHz
  2. Compressor2       — Tight ratio 4:1, fast attack for rap control
  3. Saturator         — Subtle analog warmth (Analog Clip mode)
  4. MultibandDynamics — De-esser (tame 5-8kHz sibilance)
  5. Compressor2       — Gentle glue compression 2:1
  6. EQ8 (post-comp)   — Presence boost 4kHz, air 12kHz, final shaping
  7. Delay             — Short slapback doubler (60ms, low mix)
  8. Reverb            — Tight room, short decay, low mix

Usage:
    python3 vocal_chain.py                    # add to track 109 (Vocals)
    python3 vocal_chain.py --track 110        # add to Backing Vocals
    python3 vocal_chain.py --track 109 110    # add to both
    python3 vocal_chain.py --dry-run          # preview without writing
"""

import re, os, sys, argparse, gzip
from xml.etree import ElementTree as ET
from copy import deepcopy

TEMPLATE_PATH = "/tmp/template.xml"
XML_PATH = "LinkinPark Project/LinkinPark.xml"

# ---------------------------------------------------------------------------
# Extract device XML from template
# ---------------------------------------------------------------------------

def extract_device(template_content, device_tag, occurrence=0):
    """Extract the Nth occurrence of a device element from template XML."""
    pattern = f'<{device_tag} '
    pos = 0
    for i in range(occurrence + 1):
        pos = template_content.find(pattern, pos)
        if pos == -1:
            return None
        if i < occurrence:
            pos += 1

    # Find matching closing tag by counting depth
    end_tag = f'</{device_tag}>'
    depth = 1
    search_from = pos + len(pattern)
    while depth > 0:
        next_open = template_content.find(f'<{device_tag} ', search_from)
        next_close = template_content.find(end_tag, search_from)
        if next_close == -1:
            return None
        if next_open != -1 and next_open < next_close:
            depth += 1
            search_from = next_open + 1
        else:
            depth -= 1
            if depth == 0:
                return template_content[pos:next_close + len(end_tag)]
            search_from = next_close + 1

    return None


def reassign_ids(device_xml, start_id):
    """Replace ALL Id attributes with sequential unique IDs.

    This includes:
    - The device element itself (e.g. <Compressor2 Id="N">)
    - AutomationTarget, ModulationTarget, Pointee, etc.
    - Any other element with an Id attribute
    """
    current_id = start_id

    def replacer(m):
        nonlocal current_id
        result = f'{m.group(1)} Id="{current_id}"'
        current_id += 1
        return result

    # Replace ALL Id="N" on known element types that need unique IDs
    new_xml = re.sub(
        r'(AutomationTarget|ModulationTarget|Pointee|VolumeModulationTarget'
        r'|TranspositionModulationTarget'
        r'|Compressor2|Eq8|Saturator|MultibandDynamics|Delay|Reverb|AutoFilter'
        r'|Gate|StereoGain|AudioEffectGroupDevice'
        r'|AutomationLane|RemoteableTimeSignature) Id="\d+"',
        replacer, device_xml)
    return new_xml, current_id


# ---------------------------------------------------------------------------
# Parameter modification helpers
# ---------------------------------------------------------------------------

def set_param(xml, param_path, value):
    """Set a parameter value. param_path like 'Threshold' or 'Bands.0/ParameterA/Freq'."""
    parts = param_path.split('/')
    tag = parts[-1]
    # Simple case: direct child parameter
    if len(parts) == 1:
        # Find <Tag>\n...<Manual Value="X" /> and replace X
        pattern = f'<{tag}>\\s*\\n\\s*<LomId Value="\\d+" />\\s*\\n\\s*<Manual Value="[^"]*"'
        def repl(m):
            return re.sub(r'<Manual Value="[^"]*"', f'<Manual Value="{value}"', m.group(0))
        xml = re.sub(pattern, repl, xml, count=1)
    return xml


def set_manual(xml, tag, value, occurrence=0):
    """Set <tag><Manual Value="X"/></tag> — more reliable approach."""
    count = 0
    pos = 0
    while True:
        idx = xml.find(f'<{tag}>', pos)
        if idx == -1:
            break
        if count == occurrence:
            # Find the Manual Value within next ~200 chars
            manual_start = xml.find('<Manual Value="', idx, idx + 300)
            if manual_start != -1:
                val_start = manual_start + len('<Manual Value="')
                val_end = xml.find('"', val_start)
                xml = xml[:val_start] + str(value) + xml[val_end:]
            return xml
        count += 1
        pos = idx + 1
    return xml


def set_eq_band(xml, band_num, param_set, is_on, mode, freq, gain, q):
    """Configure an EQ8 band.

    param_set: 'ParameterA' or 'ParameterB'
    mode: 0=LowCut48, 1=LowCut12, 2=LeftShelf, 3=Bell, 4=Notch, 5=RightShelf, 6=HighCut12, 7=HighCut48
    """
    # Find Bands.N section
    band_tag = f'<Bands.{band_num}>'
    band_start = xml.find(band_tag)
    if band_start == -1:
        return xml

    band_end_tag = f'</Bands.{band_num}>'
    band_end = xml.find(band_end_tag, band_start)
    band_section = xml[band_start:band_end]

    # Find ParameterA/B within band
    param_start = band_section.find(f'<{param_set}>')
    param_end = band_section.find(f'</{param_set}>', param_start)
    param_section = band_section[param_start:param_end]

    # Set values
    param_section = set_manual(param_section, 'IsOn', str(is_on).lower())
    param_section = set_manual(param_section, 'Mode', mode)
    param_section = set_manual(param_section, 'Freq', f'{freq:.1f}')
    param_section = set_manual(param_section, 'Gain', f'{gain:.1f}')
    param_section = set_manual(param_section, 'Q', f'{q:.4f}')

    # Reassemble
    new_band = band_section[:param_start] + param_section + band_section[param_end:]
    xml = xml[:band_start] + new_band + xml[band_end:]
    return xml


# ---------------------------------------------------------------------------
# Build the Mike Shinoda vocal chain
# ---------------------------------------------------------------------------

def build_chain(template_content, start_id):
    """Build the complete vocal effect chain. Returns (devices_xml_list, next_id)."""
    devices = []
    next_id = start_id

    # ── 1. EQ8: Pre-compression sculpting ──────────────────────────────────
    eq_pre = extract_device(template_content, 'Eq8')
    if eq_pre:
        # Band 0: High-pass 80Hz (remove rumble)
        eq_pre = set_eq_band(eq_pre, 0, 'ParameterA', True, 0, 80.0, 0.0, 0.7071)
        # Band 1: Cut mud at 250Hz (-3dB, wide Q)
        eq_pre = set_eq_band(eq_pre, 1, 'ParameterA', True, 3, 250.0, -3.0, 0.8000)
        # Band 2: Slight cut at 500Hz for clarity (-1.5dB)
        eq_pre = set_eq_band(eq_pre, 2, 'ParameterA', True, 3, 500.0, -1.5, 1.0000)
        # Band 3: Presence boost at 3.5kHz (+2.5dB, medium Q)
        eq_pre = set_eq_band(eq_pre, 3, 'ParameterA', True, 3, 3500.0, 2.5, 1.2000)
        # Bands 4-7: off
        for b in range(4, 8):
            eq_pre = set_eq_band(eq_pre, b, 'ParameterA', False, 3, 1000.0, 0.0, 0.7071)

        eq_pre, next_id = reassign_ids(eq_pre, next_id)
        # Rename
        eq_pre = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="Pre EQ"', eq_pre, count=1)
        devices.append(eq_pre)
        print("  + EQ8 (Pre EQ): HPF 80Hz, -3dB@250Hz, +2.5dB@3.5kHz")

    # ── 2. Compressor2: Tight compression for rap vocal control ────────────
    comp1 = extract_device(template_content, 'Compressor2')
    if comp1:
        comp1 = set_manual(comp1, 'Threshold', '-18')        # -18 dB threshold
        comp1 = set_manual(comp1, 'Ratio', '4')              # 4:1 ratio
        comp1 = set_manual(comp1, 'Attack', '0.5')            # 0.5ms fast attack
        comp1 = set_manual(comp1, 'Release', '80')            # 80ms release
        comp1 = set_manual(comp1, 'Gain', '4')                # +4dB makeup gain
        comp1 = set_manual(comp1, 'DryWet', '1')              # 100% wet
        comp1 = set_manual(comp1, 'Model', '0')               # Peak mode
        comp1 = set_manual(comp1, 'Knee', '6')                # medium soft knee
        comp1 = set_manual(comp1, 'LookAhead', '1')           # 1ms lookahead

        comp1, next_id = reassign_ids(comp1, next_id)
        comp1 = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="Vocal Comp"', comp1, count=1)
        devices.append(comp1)
        print("  + Compressor2 (Vocal Comp): -18dB, 4:1, 0.5ms attack")

    # ── 3. Saturator: Subtle analog warmth ─────────────────────────────────
    sat = extract_device(template_content, 'Saturator')
    if sat:
        sat = set_manual(sat, 'PreDrive', '3')                # light drive (+3dB)
        # Type: 0=AnalogClip, 1=SoftSine, 2=MediumCurve, 3=HardCurve, 4=Sinoid, 5=DigitalClip
        type_match = re.search(r'<Type>(\s*\n\s*<LomId[^/]*/>\s*\n\s*)<Manual Value="\d+"', sat)
        if type_match:
            sat = sat[:type_match.start(0)] + f'<Type>{type_match.group(1)}<Manual Value="1"' + sat[type_match.end(0):]
        sat = set_manual(sat, 'PostDrive', '-3')              # compensate gain
        sat = set_manual(sat, 'DryWet', '0.6')                # 60% wet (subtle blend)

        sat, next_id = reassign_ids(sat, next_id)
        sat = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="Warmth"', sat, count=1)
        devices.append(sat)
        print("  + Saturator (Warmth): Soft Sine, +3dB drive, 60% mix")

    # ── 4. MultibandDynamics: De-esser (high band compression) ─────────────
    mbd = extract_device(template_content, 'MultibandDynamics')
    if mbd:
        # High band crossover at 5kHz, compress above threshold
        mbd = set_manual(mbd, 'HighCrossoverFrequency', '5000')
        # High band: compress above -15dB with 4:1 ratio
        mbd = set_manual(mbd, 'HighAboveThreshold', '-15')
        mbd = set_manual(mbd, 'HighAboveRatio', '4')
        # Leave mid/low bands untouched (1:1)
        mbd = set_manual(mbd, 'MidAboveRatio', '1')
        mbd = set_manual(mbd, 'LowAboveRatio', '1')
        # Output gain
        mbd = set_manual(mbd, 'MasterOutput', '0')

        mbd, next_id = reassign_ids(mbd, next_id)
        mbd = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="De-Esser"', mbd, count=1)
        devices.append(mbd)
        print("  + MultibandDynamics (De-Esser): 5kHz crossover, 4:1 above -15dB")

    # ── 5. Compressor2: Gentle glue compression ───────────────────────────
    comp2 = extract_device(template_content, 'Compressor2')
    if comp2:
        comp2 = set_manual(comp2, 'Threshold', '-12')         # -12 dB
        comp2 = set_manual(comp2, 'Ratio', '2')               # 2:1 gentle
        comp2 = set_manual(comp2, 'Attack', '10')              # 10ms slower attack
        comp2 = set_manual(comp2, 'Release', '150')            # 150ms release
        comp2 = set_manual(comp2, 'Gain', '2')                 # +2dB makeup
        comp2 = set_manual(comp2, 'DryWet', '1')               # 100%
        comp2 = set_manual(comp2, 'Model', '1')                # RMS mode
        comp2 = set_manual(comp2, 'Knee', '10')                # soft knee

        comp2, next_id = reassign_ids(comp2, next_id)
        comp2 = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="Glue Comp"', comp2, count=1)
        devices.append(comp2)
        print("  + Compressor2 (Glue Comp): -12dB, 2:1, RMS, soft knee")

    # ── 6. EQ8: Post-compression presence & air ───────────────────────────
    eq_post = extract_device(template_content, 'Eq8')
    if eq_post:
        # Band 0: High-pass 60Hz (safety)
        eq_post = set_eq_band(eq_post, 0, 'ParameterA', True, 1, 60.0, 0.0, 0.7071)
        # Band 1: Slight warmth boost at 180Hz (+1dB)
        eq_post = set_eq_band(eq_post, 1, 'ParameterA', True, 3, 180.0, 1.0, 0.8000)
        # Band 2: Presence at 4kHz (+2dB, tight Q)
        eq_post = set_eq_band(eq_post, 2, 'ParameterA', True, 3, 4000.0, 2.0, 1.5000)
        # Band 3: Air at 12kHz (+1.5dB, wide shelf)
        eq_post = set_eq_band(eq_post, 3, 'ParameterA', True, 5, 12000.0, 1.5, 0.7071)
        # Bands 4-7: off
        for b in range(4, 8):
            eq_post = set_eq_band(eq_post, b, 'ParameterA', False, 3, 1000.0, 0.0, 0.7071)

        eq_post, next_id = reassign_ids(eq_post, next_id)
        eq_post = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="Presence EQ"', eq_post, count=1)
        devices.append(eq_post)
        print("  + EQ8 (Presence EQ): +1dB@180Hz, +2dB@4kHz, +1.5dB@12kHz air")

    # ── 7. Delay: Short slapback doubler ──────────────────────────────────
    delay = extract_device(template_content, 'Delay')
    if delay:
        # Unsynced, ~60ms both sides
        delay = set_manual(delay, 'SyncL', 'false')
        delay = set_manual(delay, 'SyncR', 'false')
        delay = set_manual(delay, 'TimeL', '60')              # 60ms left
        delay = set_manual(delay, 'TimeR', '65')              # 65ms right (slight stereo)
        delay = set_manual(delay, 'Feedback', '10')            # 10% feedback
        delay = set_manual(delay, 'DryWet', '0.12')            # 12% wet (subtle)

        delay, next_id = reassign_ids(delay, next_id)
        delay = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="Slapback"', delay, count=1)
        devices.append(delay)
        print("  + Delay (Slapback): 60/65ms, 10% FB, 12% mix")

    # ── 8. Reverb: Tight room ─────────────────────────────────────────────
    reverb = extract_device(template_content, 'Reverb')
    if reverb:
        reverb = set_manual(reverb, 'PreDelay', '10')          # 10ms predelay
        reverb = set_manual(reverb, 'DecayTime', '800')        # 0.8s decay (tight room)
        reverb = set_manual(reverb, 'RoomSize', '30')          # small room
        reverb = set_manual(reverb, 'DryWet', '0.10')          # 10% wet (very subtle)
        # Boost early reflections for intimacy
        reverb = set_manual(reverb, 'MixReflect', '0.7')       # 70% early reflections
        reverb = set_manual(reverb, 'MixDiffuse', '0.3')       # 30% diffuse tail

        reverb, next_id = reassign_ids(reverb, next_id)
        reverb = re.sub(r'<UserName Value="[^"]*"', '<UserName Value="Room"', reverb, count=1)
        devices.append(reverb)
        print("  + Reverb (Room): 0.8s decay, small room, 10% mix")

    return devices, next_id


# ---------------------------------------------------------------------------
# Insert chain into project
# ---------------------------------------------------------------------------

def insert_chain(content, track_id, devices, template_content, start_id):
    """Insert device chain into a track's DeviceChain > Devices section."""
    # Find the track
    track_start = content.find(f'<AudioTrack Id="{track_id}"')
    if track_start == -1:
        print(f"  ERROR: Track {track_id} not found")
        return content, start_id

    track_end = content.find('</AudioTrack>', track_start)

    # Find <Devices> section in DeviceChain (first occurrence within track)
    devices_start = content.find('<Devices>', track_start, track_end)
    if devices_start == -1:
        # Try <Devices /> (self-closing)
        devices_empty = content.find('<Devices />', track_start, track_end)
        if devices_empty != -1:
            # Replace self-closing with open/close
            devices_xml = '\n'.join(devices)
            replacement = f'<Devices>\n{devices_xml}\n\t\t\t\t\t\t</Devices>'
            content = content[:devices_empty] + replacement + content[devices_empty + len('<Devices />'):]
            return content, start_id
        print(f"  ERROR: No <Devices> section in track {track_id}")
        return content, start_id

    devices_end = content.find('</Devices>', devices_start, track_end)
    if devices_end == -1:
        print(f"  ERROR: No </Devices> in track {track_id}")
        return content, start_id

    # Check if devices already exist (don't double-add)
    existing = content[devices_start:devices_end]
    if 'Pre EQ' in existing or 'Vocal Comp' in existing:
        print(f"  SKIP: Track {track_id} already has vocal chain")
        return content, start_id

    # Insert devices right after <Devices> (or before </Devices>)
    devices_xml = '\n'.join(devices)
    insert_pos = devices_end
    content = content[:insert_pos] + devices_xml + '\n\t\t\t\t\t\t' + content[insert_pos:]

    return content, start_id


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(description="Add Mike Shinoda vocal chain")
    parser.add_argument('--track', type=int, nargs='+', default=[109],
                        help='Track IDs to add chain to (default: 109)')
    parser.add_argument('--dry-run', action='store_true',
                        help='Preview without writing')
    parser.add_argument('--template', default=TEMPLATE_PATH,
                        help='Path to unpacked template .als XML')
    args = parser.parse_args()

    # Load template
    print(f"Loading template: {args.template}")
    with open(args.template, 'r', encoding='utf-8') as f:
        template = f.read()

    # Load project
    print(f"Loading project: {XML_PATH}")
    with open(XML_PATH, 'r', encoding='utf-8') as f:
        content = f.read()

    # Get current NextPointeeId
    m = re.search(r'NextPointeeId Value="(\d+)"', content)
    next_id = int(m.group(1))
    original_next_id = next_id

    print(f"\nBuilding Mike Shinoda vocal chain:")
    print(f"{'='*50}")

    for track_id in args.track:
        print(f"\n  Track {track_id}:")
        devices, next_id = build_chain(template, next_id)

        if not devices:
            print("  ERROR: Failed to build chain")
            continue

        content, _ = insert_chain(content, track_id, devices, template, next_id)
        print(f"  → {len(devices)} devices added")

    # Update NextPointeeId
    content = content.replace(
        f'NextPointeeId Value="{original_next_id}"',
        f'NextPointeeId Value="{next_id}"')
    print(f"\nNextPointeeId: {original_next_id} → {next_id} ({next_id - original_next_id} IDs used)")

    # Validate
    try:
        ET.fromstring(content.encode('utf-8'))
        print("XML validation: OK")
    except ET.ParseError as e:
        print(f"XML validation FAILED: {e}")
        sys.exit(1)

    if args.dry_run:
        print("\n[DRY RUN] No changes written.")
        return

    # Write
    with open(XML_PATH, 'w', encoding='utf-8') as f:
        f.write(content)
    print(f"\nWritten to {XML_PATH}")
    print("Run ./build.sh to create .als for Ableton.")


if __name__ == '__main__':
    main()
