"""MIDI output interface."""
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Tuple, Union

import pretty_midi
from pretty_midi import PrettyMIDI, Instrument
from pretty_midi import KeySignature as PmKeySignature
from pretty_midi import TimeSignature as PmTimeSignature
from pretty_midi import Note as PmNote
from pretty_midi import Lyric as PmLyric
from mido import Message, MetaMessage, MidiFile, MidiTrack, bpm2tempo

from ..classes import KeySignature, Lyric, Note, Tempo, TimeSignature, Track

if TYPE_CHECKING:
    from ..music import Music

PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]


def to_delta_time(midi_track: MidiTrack):
    """Convert a mido MidiTrack object from absolute time to delta time.

    Parameters
    ----------
    midi_track : :class:`mido.MidiTrack` object
        mido MidiTrack object to convert.

    """
    # Sort messages by absolute time
    midi_track.sort(key=lambda x: x.time)

    # Convert to delta time
    time = 0
    for msg in midi_track:
        time_ = msg.time
        msg.time -= time
        time = time_


def to_mido_tempo(tempo: Tempo) -> MetaMessage:
    """Return a Tempo object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    return MetaMessage(
        "set_tempo", time=tempo.time, tempo=bpm2tempo(tempo.qpm),
    )


def to_mido_key_signature(
    key_signature: KeySignature,
) -> Optional[MetaMessage]:
    """Return a KeySignature object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    suffix = "m" if key_signature.mode == "minor" else ""
    if key_signature.root is None:
        return None
    return MetaMessage(
        "key_signature",
        time=key_signature.time,
        key=PITCH_NAMES[key_signature.root] + suffix,
    )


def to_mido_time_signature(time_signature: TimeSignature) -> MetaMessage:
    """Return a TimeSignature object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    return MetaMessage(
        "time_signature",
        time=time_signature.time,
        numerator=time_signature.numerator,
        denominator=time_signature.denominator,
    )


def to_mido_meta_track(music: "Music") -> MidiTrack:
    """Return a mido MidiTrack containing metadata of a Music object.

    Parameters
    ----------
    music : :class:`muspy.Music` object
        Music object to convert.

    Returns
    -------
    :class:`mido.MidiTrack` object
        Converted mido MidiTrack object.

    """
    # Create a track to store the metadata
    meta_track = MidiTrack()

    # Song title
    if music.metadata.title is not None:
        meta_track.append(MetaMessage("track_name", name=music.metadata.title))

    # Tempos
    for tempo in music.tempos:
        meta_track.append(to_mido_tempo(tempo))

    # Key signatures
    for key_signature in music.key_signatures:
        mido_key_signature = to_mido_key_signature(key_signature)
        if mido_key_signature is not None:
            meta_track.append(mido_key_signature)

    # Time signatures
    for time_signature in music.time_signatures:
        meta_track.append(to_mido_time_signature(time_signature))

    # Lyrics
    for lyric in music.lyrics:
        meta_track.append(to_mido_lyric(lyric))

    # Annotations
    for annotation in music.annotations:
        # Marker messages
        if annotation.group == "marker":
            meta_track.append(
                MetaMessage("marker", text=annotation.annotation)
            )
        # Text messages
        elif isinstance(annotation.annotation, str):
            meta_track.append(
                MetaMessage(
                    "text", time=annotation.time, text=annotation.annotation
                )
            )

    # End of track message
    meta_track.append(MetaMessage("end_of_track"))

    # Convert to delta time
    to_delta_time(meta_track)

    return meta_track


def to_mido_lyric(lyric: Lyric) -> MetaMessage:
    """Return a Lyric object as a mido MetaMessage object.

    Timing is in absolute time, NOT in delta time.

    """
    return MetaMessage("lyrics", time=lyric.time, text=lyric.lyric)


def to_mido_note_on_note_off(
    note: Note, channel: int, use_note_on_as_note_off: bool = True
) -> Tuple[Message, Message]:
    """Return a Note object as mido Message objects.

    Timing is in absolute time, NOT in delta time.

    Parameters
    ----------
    note : :class:`muspy.Note` object
        Note object to convert.
    channel : int
        Channel of the MIDI message.
    use_note_on_as_note_off : bool
        Whether to use a note on message with zero velocity instead of a
        note off message.

    Returns
    -------
    :class:`mido.Message` object
        Converted mido Message object for note on.
    :class:`mido.Message` object
        Converted mido Message object for note off.

    """
    note_on_msg = Message(
        "note_on",
        time=note.time,
        note=note.pitch,
        velocity=note.velocity,
        channel=channel,
    )
    if use_note_on_as_note_off:
        note_off_msg = Message(
            "note_on",
            time=note.end,
            note=note.pitch,
            velocity=0,
            channel=channel,
        )
    else:
        note_off_msg = Message(
            "note_off",
            time=note.end,
            note=note.pitch,
            velocity=note.velocity,
            channel=channel,
        )

    return note_on_msg, note_off_msg


def to_mido_track(
    track: Track, use_note_on_as_note_off: bool = True
) -> MidiTrack:
    """Return a Track object as a mido MidiTrack object.

    Parameters
    ----------
    track : :class:`muspy.Track` object
        Track object to convert.
    use_note_on_as_note_off : bool
        Whether to use a note on message with zero velocity instead of a
        note off message.

    Returns
    -------
    :class:`mido.MidiTrack` object
        Converted mido MidiTrack object.

    """
    # Create a new MIDI track
    midi_track = MidiTrack()

    # Track name messages
    if track.name is not None:
        midi_track.append(MetaMessage("track_name", name=track.name))

    # Program change messages
    channel = 9 if track.is_drum else 0
    midi_track.append(
        Message("program_change", program=track.program, channel=channel,)
    )

    # Note on and note off messages
    for note in track.notes:
        midi_track.extend(
            to_mido_note_on_note_off(note, channel, use_note_on_as_note_off)
        )

    # End of track message
    midi_track.append(MetaMessage("end_of_track"))

    # Convert to delta time
    to_delta_time(midi_track)

    return midi_track


def to_mido(music: "Music", use_note_on_as_note_off: bool = True):
    """Return a Music object as a MidiFile object.

    Parameters
    ----------
    music : :class:`muspy.Music` object
        Music object to convert.
    use_note_on_as_note_off : bool
        Whether to use a note on message with zero velocity instead of a
        note off message.

    Returns
    -------
    :class:`mido.MidiFile`
        Converted MidiFile object.

    """
    # Create a MIDI file object
    midi = MidiFile(type=1, ticks_per_beat=music.resolution)

    # Append meta track
    midi.tracks.append(to_mido_meta_track(music))

    # Iterate over music tracks
    for track in music.tracks:
        midi.tracks.append(to_mido_track(track, use_note_on_as_note_off))

    return midi


def write_midi_mido(
    path: Union[str, Path],
    music: "Music",
    use_note_on_as_note_off: bool = True,
):
    """Write a Music object to a MIDI file using mido as backend.

    Parameters
    ----------
    path : str or Path
        Path to write the MIDI file.
    music : :class:`muspy.Music` object
        Music object to write.
    use_note_on_as_note_off : bool
        Whether to use a note on message with zero velocity instead of a
        note off message.

    """
    midi = to_mido(music, use_note_on_as_note_off=use_note_on_as_note_off)
    midi.save(str(path))


def to_pretty_midi_key_signature(
    key_signature: KeySignature,
) -> PmKeySignature:
    """Return a KeySignature object as a pretty_midi KeySignature object."""
    return PmKeySignature(
        pretty_midi.key_name_to_key_number(
            "{} {}".format(key_signature.root, key_signature.mode)
        ),
        key_signature.time,
    )


def to_pretty_midi_time_signature(
    time_signature: TimeSignature,
) -> PmTimeSignature:
    """Return a KeySignature object as a pretty_midi TimeSignature object."""
    return PmTimeSignature(
        time_signature.numerator,
        time_signature.denominator,
        time_signature.time,
    )


def to_pretty_midi_lyric(lyric: Lyric) -> PmLyric:
    """Return a Lyric object as a pretty_midi Lyric object."""
    return PmLyric(lyric.lyric, lyric.time)


def to_pretty_midi_note(note: Note) -> PmNote:
    """Return a Note object as a pretty_midi Note object."""
    return PmNote(note.velocity, note.pitch, note.time, note.end)


def to_pretty_midi_instrument(track: Track) -> Instrument:
    """Return a Track object as a pretty_midi Instrument object."""
    instrument = pretty_midi.Instrument(
        track.program, track.is_drum, track.name
    )
    for note in track.notes:
        instrument.notes.append(to_pretty_midi_note(note))
    return instrument


def to_pretty_midi(music: "Music") -> PrettyMIDI:
    """Return a Music object as a PrettyMIDI object.

    Tempo changes are not supported yet.

    Parameters
    ----------
    music : :class:`muspy.Music` object
        Music object to convert.

    Returns
    -------
    :class:`pretty_midi.PrettyMIDI`
        Converted PrettyMIDI object.

    """
    pm = pretty_midi.PrettyMIDI()

    # Key signatures
    for key_signature in music.key_signatures:
        pm.key_signature_changes.append(
            to_pretty_midi_key_signature(key_signature)
        )

    # Time signatures
    for time_signature in music.time_signatures:
        pm.time_signature_changes.append(
            to_pretty_midi_time_signature(time_signature)
        )

    # Lyrics
    for lyric in music.lyrics:
        pm.lyrics.append(to_pretty_midi_lyric(lyric))

    # Tracks
    for track in music.tracks:
        pm.instruments.append(to_pretty_midi_instrument(track))

    return pm


def write_midi_pretty_midi(path: Union[str, Path], music: "Music"):
    """Write a Music object to a MIDI file using pretty_midi as backend.

    Tempo changes are not supported yet.

    Parameters
    ----------
    path : str or Path
        Path to write the MIDI file.
    music : :class:`muspy.Music` object
        Music object to convert.

    """
    pm = to_pretty_midi(music)
    pm.write(str(path))


def write_midi(
    path: Union[str, Path],
    music: "Music",
    backend: str = "mido",
    **kwargs: Any
):
    """Write a Music object to a MIDI file.

    Parameters
    ----------
    path : str or Path
        Path to write the MIDI file.
    music : :class:`muspy.Music` object
        Music object to write.
    backend: {'mido', 'pretty_midi'}
        Backend to use. Defaults to 'mido'.

    """
    if backend == "mido":
        return write_midi_mido(path, music, **kwargs)
    if backend == "pretty_midi":
        return write_midi_pretty_midi(path, music)
    raise ValueError("`backend` must by one of 'mido' and 'pretty_midi'.")
