from pathlib import Path
from tempfile import NamedTemporaryFile
from suno_utils.audio import Audio
from ..utils.s3 import _download_s3_file
from ..web.harvest import get_filename
from typing import Literal

import numpy as np
import tempfile
import os
import copy

try:
    import pretty_midi
except ImportError:
    print("pretty_midi not found")


try:
    from midi_player import MIDIPlayer
    from midi_player.stylers import dark
except ImportError:
    print("midi_player not found")

try:
    from miditok import REMI, TokenizerConfig, MusicTokenizer
except ImportError:
    print("miditok not found")

try:
    import symusic
except ImportError:
    print("symusic not found")


class Midi:
    """
    A wrapper around pretty_midi.PrettyMIDI that provides a more user-friendly interface.
    """

    def __init__(self, pmidi=None):
        if pmidi is None:
            pmidi = pretty_midi.PrettyMIDI()
        assert isinstance(pmidi, pretty_midi.PrettyMIDI), "pmidi must be a pretty_midi.PrettyMIDI object"
        self.pmidi = pmidi
        self.pmidi.remove_invalid_notes()
        # remove unused instruments
        self.pmidi.instruments = [
            instrument for instrument in self.pmidi.instruments if len(instrument.notes) > 0
        ]

    @classmethod
    def from_path(cls, path: Path | str):
        if isinstance(path, Path):
            path = str(path)
        import warnings

        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            return cls(pretty_midi.PrettyMIDI(path))

    @classmethod
    def from_s3(cls, path: str):
        assert path.startswith("s3://")
        with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
            tmp_filepath = os.path.join(temp_dir, get_filename(path, keep_extension=True))
            _ = _download_s3_file(path, tmp_filepath, allow_errors=False)
            return cls.from_path(tmp_filepath)

    @staticmethod
    def load_tokenizer(path: str = "/app2/suno/victor/midi_tokenizer_v1.json"):
        if path:
            return REMI(params=path)
        config = TokenizerConfig(
            pitch_range=(0, 127),
            use_rests=False,
            use_programs=True,
            one_token_stream_for_programs=True,
            use_tempos=False,
            num_velocities=32,
        )
        return REMI(config)

    def show(self, viz_type: Literal["piano-roll", "waterfall", "staff"] = "piano-roll"):
        with NamedTemporaryFile(suffix=".mid") as f:
            self.pmidi.write(f.name)
            mp = MIDIPlayer(f.name, 500, styler=dark, viz_type=viz_type)
            from IPython import display

            display.display(mp)

    def __len__(self):
        return self.num_notes

    def __add__(self, other: "Midi"):
        if not isinstance(other, Midi):
            return NotImplemented
        new_pmidi = pretty_midi.PrettyMIDI()
        new_pmidi.instruments.extend(copy.deepcopy(self.pmidi.instruments))
        new_pmidi.instruments.extend(copy.deepcopy(other.pmidi.instruments))
        return Midi(new_pmidi)

    def __radd__(self, other):
        if other == 0:
            return self
        return self.__add__(other)

    @property
    def num_instruments(self):
        return len(self.pmidi.instruments)

    @property
    def num_notes(self):
        return sum(len(instrument.notes) for instrument in self.pmidi.instruments)

    @property
    def all_notes(self):
        return [note for instrument in self.pmidi.instruments for note in instrument.notes]

    def duration_s(self) -> float:
        return self.pmidi.get_end_time()

    def to_symusic(self):
        """symusic is much faster than pretty_midi, but I can't get the code examples to work"""
        # write to a temp file
        with NamedTemporaryFile(suffix=".mid") as f:
            self.pmidi.write(f.name)
            return symusic.Score(f.name)

    def to_audio(
        self,
        sample_rate: int = 48000,
        sf_path: str
        | None = "/home/victor/neon/sunoData/src/sunodata/midi/soundfonts/FatBoy-v0.786.sf2",
    ):
        if sf_path is not None and not os.path.exists(sf_path):
            raise FileNotFoundError(f"Soundfont file not found: {sf_path}")
        audio = self.pmidi.fluidsynth(sample_rate, sf2_path=sf_path)
        return Audio.from_array_float(audio, sample_rate)
        # synth = symusic.Synthesizer(sample_rate=sample_rate, sf_path=sf_path)

        # # audio is a 2D numpy array of float32, [channels, time]
        # audio = synth.render(self.to_symusic(), stereo=False)
        # if audio.shape[-1] == 0:
        #     return Audio.from_silence(1.0, sample_rate)
        # audio = Audio.from_array_float(audio, sample_rate, max_allowed_val=100)
        # return audio

    def make_stereo_comparison(self, audio: Audio, sf_path: str | None = None):
        # resample to 48k so its a supported format
        audio = audio.resample(48000)
        midi_audio = self.to_audio(sample_rate=audio.sample_rate, sf_path=sf_path)

        midi_array = midi_audio.stereo().normalize_volume().mono().array_float
        audio_array = audio.stereo().normalize_volume().mono().array_float

        max_len = max(len(midi_array), len(audio_array))
        midi_array = np.pad(midi_array, (0, max_len - len(midi_array)))
        audio_array = np.pad(audio_array, (0, max_len - len(audio_array)))

        stereo_array = np.stack([midi_array, audio_array], axis=0)
        return Audio.from_array_float(stereo_array, audio.sample_rate)

    def write(self, path: Path | str):
        self.pmidi.write(path)

    @classmethod
    def from_scale(cls, n_notes: int, duration: float = 1.0):
        pmidi = pretty_midi.PrettyMIDI()
        instrument = pretty_midi.Instrument(program=0)
        for i in range(n_notes):
            note = pretty_midi.Note(
                pitch=60 + (i % 12),
                velocity=80,
                start=i * duration,
                end=(i + 1) * duration,
            )
            instrument.notes.append(note)
        pmidi.instruments.append(instrument)
        return cls(pmidi)

    @property
    def program_ids(self):
        """Returns a list of program IDs for all instruments in the MIDI file."""
        return [(int(instrument.program), instrument.is_drum) for instrument in self.pmidi.instruments]

    def filter_instruments(self, instrument_ids: list[int] | int):
        if isinstance(instrument_ids, int):
            instrument_ids = [instrument_ids]
        filtered_midi = pretty_midi.PrettyMIDI()
        filtered_midi.instruments = [
            instrument for instrument in self.pmidi.instruments if instrument.program in instrument_ids
        ]
        return Midi(filtered_midi)

    def filter_instruments_by_name(self, instrument_names: list[str] | str):
        if isinstance(instrument_names, str):
            instrument_names = [instrument_names]
        filtered_midi = pretty_midi.PrettyMIDI()
        filtered_midi.instruments = [
            instrument
            for instrument in self.pmidi.instruments
            if instrument.name.lower() in [name.lower() for name in instrument_names]
        ]
        return Midi(filtered_midi)

    def transpose(self, semitones: int):
        pmidi = pretty_midi.PrettyMIDI()
        instruments = copy.deepcopy(self.pmidi.instruments)
        for instrument in instruments:
            for note in instrument.notes:
                note.pitch += semitones
            pmidi.instruments.append(instrument)
        return Midi(pmidi)

    def shift_time(self, shift_time: float):
        pmidi = copy.deepcopy(self.pmidi)
        for instrument in pmidi.instruments:
            for note in instrument.notes:
                note.start += shift_time
                note.end += shift_time
        return Midi(pmidi).clean_pmidi()

    def apply_sustain_pedals(self):
        """
        Extends notes based on sustain pedal events (CC 64).
        A note's duration will be extended if it ends while the sustain pedal is on.
        The note will be extended until the pedal is released or until a new note
        of the same pitch is played.
        Returns a new Midi object.
        """
        new_pmidi = copy.deepcopy(self.pmidi)
        for instrument in new_pmidi.instruments:
            # Get sustain pedal control changes
            sustain_pedals = [cc for cc in instrument.control_changes if cc.number == 64]
            if not sustain_pedals:
                continue

            sustain_pedals.sort(key=lambda cc: cc.time)

            # Find sustain intervals
            sustain_intervals = []
            pedal_on_time = None
            for cc in sustain_pedals:
                if cc.value >= 64:
                    if pedal_on_time is None:
                        pedal_on_time = cc.time
                elif pedal_on_time is not None:
                    sustain_intervals.append((pedal_on_time, cc.time))
                    pedal_on_time = None

            # If pedal is still on at the end
            if pedal_on_time is not None:
                sustain_intervals.append((pedal_on_time, new_pmidi.get_end_time()))

            if not sustain_intervals:
                continue

            # Sort notes by start time to find the next note of the same pitch easily
            instrument.notes.sort(key=lambda n: n.start)

            # Extend notes
            for i, note in enumerate(instrument.notes):
                # Find which sustain interval the note end falls into
                for sustain_start, sustain_end in sustain_intervals:
                    if sustain_start <= note.end < sustain_end:
                        # This note is sustained. Find its new end time.
                        new_end_time = sustain_end

                        # Find the start time of the next note with the same pitch
                        for next_note in instrument.notes[i + 1 :]:
                            if next_note.pitch == note.pitch:
                                # stop sustain at the start of the next note of same pitch
                                new_end_time = min(new_end_time, next_note.start)
                                break

                        note.end = new_end_time
                        break

        return Midi(new_pmidi)

    def clean_pmidi(self):
        pmidi = copy.deepcopy(self.pmidi)
        pmidi.remove_invalid_notes()
        # remove unused instruments
        pmidi.instruments = [instrument for instrument in pmidi.instruments if len(instrument.notes) > 0]
        return Midi(pmidi).merge_overlapping_notes()

    def merge_overlapping_notes(self):
        """
        Merges overlapping notes of the same pitch for each instrument.
        Returns a new Midi object.
        """
        new_pmidi = copy.deepcopy(self.pmidi)
        for instrument in new_pmidi.instruments:
            if len(instrument.notes) < 2:
                continue

            # Sort notes by pitch, then by start time
            instrument.notes.sort(key=lambda n: (n.pitch, n.start))

            merged_notes = []
            current_merge_note = copy.copy(instrument.notes[0])

            for i in range(1, len(instrument.notes)):
                next_note = instrument.notes[i]
                # Check for same pitch and overlap
                if (
                    next_note.pitch == current_merge_note.pitch
                    and next_note.start < current_merge_note.end
                ):
                    # Merge: extend the end time and take max velocity
                    current_merge_note.end = max(current_merge_note.end, next_note.end)
                    current_merge_note.velocity = max(current_merge_note.velocity, next_note.velocity)
                else:
                    # No merge, add the last merged note to the list
                    merged_notes.append(current_merge_note)
                    # Start a new merge candidate
                    current_merge_note = copy.copy(next_note)

            # Add the last merge candidate
            merged_notes.append(current_merge_note)
            instrument.notes = merged_notes

        return Midi(new_pmidi)

    def consolidate_programs(self):
        instrument_map = {}
        for instrument in self.pmidi.instruments:
            # check if it exists in pmidi
            if (instrument.program, instrument.is_drum) in instrument_map:
                instrument_map[(instrument.program, instrument.is_drum)].notes.extend(instrument.notes)
            else:
                instrument_map[(instrument.program, instrument.is_drum)] = instrument
        pmidi = copy.deepcopy(self.pmidi)
        pmidi.instruments = list(instrument_map.values())
        return Midi(pmidi)

    def to_tokens(self, tokenizer):
        """
        Tokenizes the MIDI file using the Structured tokenizer from miditok.
        """

        with NamedTemporaryFile(suffix=".mid") as f:
            # we simplify time to force bars to be 2s
            self.simplify_time().pmidi.write(f.name)
            tokens = tokenizer(f.name)
        return tokens

    @classmethod
    def from_tokens(cls, tokens: list[int], tokenizer):
        """
        Creates a MIDI file from a list of tokens using the Structured tokenizer from miditok.
        """
        if len(tokens) == 0:
            return cls(pretty_midi.PrettyMIDI())
        with NamedTemporaryFile(suffix=".mid") as f:
            midi = tokenizer.decode(tokens)
            midi.dump_midi(f.name)
            return cls.from_path(f.name)

    def get_segment(self, start_time: float = 0, end_time: float | None = None):
        """
        Returns a new MIDI object with only the notes between start_time and end_time.
        """
        if end_time is None:
            end_time = self.duration_s()

        pmidi = pretty_midi.PrettyMIDI()
        instruments = copy.deepcopy(self.pmidi.instruments)
        for instrument in instruments:
            instrument.notes = [
                note for note in instrument.notes if note.end >= start_time and note.start <= end_time
            ]
            for note in instrument.notes:
                note.start = max(note.start, start_time) - start_time
                note.end = min(note.end, end_time) - start_time
            pmidi.instruments.append(instrument)
        return Midi(pmidi).clean_pmidi()

    def filter_notes_by_onset(self, start_time: float = 0, end_time: float | None = None):
        if end_time is None:
            end_time = self.duration_s()

        pmidi = pretty_midi.PrettyMIDI()
        instruments = copy.deepcopy(self.pmidi.instruments)
        for instrument in instruments:
            instrument.notes = [
                note for note in instrument.notes if note.start >= start_time and note.start <= end_time
            ]
            pmidi.instruments.append(instrument)
        return Midi(pmidi)

    def simplify_time(self):
        """Makes a new midi with the same notes but without time signature. Default is 120 bpm, 4/4."""
        pmidi = pretty_midi.PrettyMIDI()
        pmidi.instruments = copy.deepcopy(self.pmidi.instruments)
        assert (
            abs(pmidi.get_end_time() - self.duration_s()) < 1e-3
        ), f"{pmidi.get_end_time()} != {self.duration_s()}"
        return Midi(pmidi)
