# This file is part of xenharmlib.
#
# xenharmlib is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# xenharmlib is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with xenharmlib. If not, see <https://www.gnu.org/licenses/>.

from __future__ import annotations

import math
from functools import total_ordering
from typing import *
from .frequencies import Frequency
from .protocols import PeriodicPitchLike
from .constants import CENTS_PRECISION
from ..exc import IncompatibleTunings
from ..exc import InvalidPitchIndex
from ..exc import InvalidPitchClassIndex
from ..exc import InvalidBaseIntervalIndex
from ..exc import InvalidGenerator


@total_ordering
class Pitch:
    """
    In its most basic form, a Pitch is a tuple of a pitch index
    (an integer value) and a tuning that interprets this index
    as a frequency.

    Pitch creates a total ordering on all pitches according
    to their frequency. This means you can sort pitches in lists
    (e.g. from lowest frequency to highest frequency). You can
    also compare pitches, even across different tunings:

    >>> from xenharmlib import EDOTuning
    >>> edo12 = EDOTuning(12)
    >>> edo31 = EDOTuning(31)
    >>> edo31.pitch(1) < edo12.pitch(1)
    True
    >>> edo31.pitch(31) == edo12.pitch(12)
    True

    :param tuning: The tuning to which this pitch belongs
    :param pitch_index: An integer denoting the pitch (with
        0 being the first pitch, 1 being the second, etc)
    """

    def __init__(self, tuning, pitch_index: int):

        self.tuning = tuning
        self._pitch_index = pitch_index

    @property
    def pitch_index(self) -> int:
        """
        The index of this pitch as an integer
        """
        return self._pitch_index

    @property
    def frequency(self) -> Frequency:
        """
        Frequency of this pitch
        """
        return self.tuning.get_frequency(self)

    def __eq__(self, other):
        return self.frequency == other.frequency

    def __lt__(self, other):
        return self.frequency < other.frequency

    # arithmetic

    def __add__(self, other):
        if self.tuning is not other.tuning:
            raise IncompatibleTunings(
                'Pitches must originate from the same tuning context'
            )
        return self.tuning.pitch(self.pitch_index + other.pitch_index)

    def __sub__(self, other):
        if self.tuning is not other.tuning:
            raise IncompatibleTunings(
                'Pitches must originate from the same tuning context'
            )
        return self.tuning.pitch(self.pitch_index - other.pitch_index)

    def __mul__(self, factor: int):
        return self.tuning.pitch(self.pitch_index * factor)

    def __rmul__(self, factor: int):
        return self.tuning.pitch(self.pitch_index * factor)

    def __repr__(self):
        return (
            f'{self.__class__.__name__}('
            f'{self.pitch_index}, '
            f'{self.tuning.name})'
        )

    def transpose(self, diff: Union[int, PitchInterval]) -> Pitch:
        """
        Transposes the pitch to a different one

        :param diff: The difference from this pitch. Can be
            either an integer (positive for upward movement,
            negative for downward movement) or a pitch
            interval
        """

        if isinstance(diff, PitchInterval):
            transposed_index = self.pitch_index + diff.pitch_diff
        else:
            transposed_index = self.pitch_index + diff

        return self.tuning.pitch(transposed_index)

    def retune(self, tuning) -> Pitch:
        """
        Approximates this pitch in a different
        tuning
        """

        return tuning.get_approx_pitch(self.frequency)

    def interval(self, other: Pitch) -> PitchInterval:
        """
        Returns an interval between this pitch and
        another pitch of the same tuning.

        :param other: The other pitch (can be higher
            or lower than this pitch)
        """

        return self.tuning.pitch_interval(self, other)


class PeriodicPitch(Pitch):
    """
    The pitch type for periodic tunings. Depending on the period
    length it will classify the pitch into a 'pitch class index'
    (:attr:`pc_index` attribute) and a 'base interval index'
    (:attr:`bi_index`)

    :param tuning: The tuning to which this pitch belongs
    :param pitch_index: An integer denoting the pitch (with
        0 being the first pitch, 1 being the second, etc)
    """

    def __init__(self, tuning, pitch_index: int):

        super().__init__(tuning, pitch_index)
        tuning_len = len(tuning)

        self._pc_index = pitch_index % tuning_len
        self._bi_index = pitch_index // tuning_len

    @property
    def pitch_index(self) -> int:
        """
        The index of this pitch as an integer
        """
        return self._pitch_index

    @property
    def pc_index(self):
        """
        The pitch class index of this pitch
        """
        return self._pc_index

    @property
    def bi_index(self):
        """
        The base interval index of this pitch
        """
        return self._bi_index

    def transpose_bi_index(self, bi_diff: int) -> Self:
        """
        Returns a pitch with the same pitch class index
        but a transposed base interval

        :param bi_diff: The difference in base interval
            between this pitch and the resulting one
        """

        tuning_len = len(self.tuning)
        bi_index = self._bi_index + bi_diff
        pitch_index = self._pc_index + bi_index * tuning_len
        return self.tuning.pitch(pitch_index)

    def pcs_normalized(self) -> Self:
        """
        Returns the equivalent of this pitch in the first base interval
        """
        return self.tuning.pitch(self.pc_index)

    def is_equivalent(self, other: PeriodicPitchLike) -> bool:
        """
        Returns True if this pitch has the same frequency as the
        other object when normalized to the first base interval

        :param other: Another periodic pitch or note
        """

        n_self = self.pcs_normalized()
        n_other = other.pcs_normalized()

        return n_self == n_other

    def get_generator_index(self, generator_pitch: Self):
        """
        Calculates the number of steps needed to reach this pitch
        when iteratively adding the given generator to the zero
        pitch of this tuning

        :param generator_pitch: A generator pitch. Will be normalized
            to the equivalent pitch in the first base interval if its
            pitch index exceeds the period length of the tuning.

        :raises IncompatibleTunings: If pitches come
            from different tuning systems

        :raises InvalidGenerator: If the given generator pitch is not in
            fact a generator in the tuning of this pitch
        """

        if generator_pitch.tuning is not self.tuning:
            raise IncompatibleTunings(
                'Pitches must originate from the same tuning context'
            )

        generator_pitch = generator_pitch.pcs_normalized()

        if generator_pitch not in self.tuning.generator_pitches:
            raise InvalidGenerator(
                f'{generator_pitch} is not a valid generator '
                f'in tuning {self.tuning.name}'
            )

        gen_pc = generator_pitch.pitch_index

        pc_index = 0
        g_index = 0

        while True:

            if pc_index == self.pc_index:
                break

            g_index += 1
            pc_index = (pc_index + gen_pc) % len(self.tuning)

        return g_index


class EDPitch(PeriodicPitch):
    """
    The pitch type for equal division tunings

    :param tuning: The tuning to which this pitch belongs
    :param pitch_index: An integer denoting the pitch (with
        0 being the first pitch, 1 being the second, etc)
    """

    def __init__(self, tuning, pitch_index: int):

        super().__init__(tuning, pitch_index)


class EDOPitch(EDPitch):
    """
    The pitch type for 'equal division of the octave' tunings

    :param tuning: The tuning to which this pitch belongs
    :param pitch_index: An integer denoting the pitch (with
        0 being the first pitch, 1 being the second, etc)
    """


PitchT = TypeVar('PitchT', bound=Pitch)


@total_ordering
class PitchInterval(Generic[PitchT]):
    """
    The most abstract form of an interval of two pitches.
    Implements conversion functions to frequency ratios
    and a total ordering based on the calculated ratios:

    >>> from xenharmlib import EDOTuning
    >>> edo31 = EDOTuning(31)
    >>> pitch_a = edo31.pitch(4)
    >>> pitch_b = edo31.pitch(8)
    >>> pitch_c = edo31.pitch(10)
    >>> i_ab = pitch_a.interval(pitch_b)
    >>> i_ac = pitch_a.interval(pitch_c)
    >>> i_ab < i_ac
    True

    **A caveat**: Intervals are considered directional in xenharmlib
    so the order of pitches from which the interval is created
    is important

    :param ref_pitch: A reference pitch for the pitch
        difference. This is necessary for tunings that
        are not equal step. In just intonation tunings
        frequency ratios may vary depending on the
        original pitches used to construct the interval,
        even if their pitch index difference is the same
    :param pitch_diff: An integer that defines the
        number of steps this interval encompasses
        (a positive integer means 'upward steps',
        while a negative one means 'downward steps')
    :param tuning: The tuning associated with this
        interval
    """

    def __init__(self, ref_pitch: PitchT, pitch_diff: int, tuning):

        self.ref_pitch = ref_pitch
        self.pitch_diff = pitch_diff
        self.tuning = tuning

    def __abs__(self) -> Self:
        """
        Returns the absolute of this pitch interval. On downwards
        interval it returns an upwards interval of the same absolute
        size. On upwards intervals it acts as the identity function.
        """

        if self.pitch_diff >= 0:
            return self

        target_pitch = self.ref_pitch.transpose(self.pitch_diff)

        return self.tuning.pitch_interval(target_pitch, self.ref_pitch)

    @classmethod
    def from_pitches(cls, pitch_a: PitchT, pitch_b: PitchT) -> Self:
        """
        Constructs an interval out of two pitches of the same tuning.
        If the second pitch is lower than the first pitch the Interval
        will have a negative pitch difference

        :raises IncompatibleTunings: If pitches come
            from different tuning systems

        :param pitch_a: The first (or reference) pitch
        :param pitch_b: The second (or target) pitch
        """

        if pitch_a.tuning is not pitch_b.tuning:
            raise IncompatibleTunings(
                'Pitches must originate from the same tuning context'
            )

        tuning = pitch_a.tuning
        pitch_diff = pitch_b.pitch_index - pitch_a.pitch_index

        return cls(
            pitch_a,
            pitch_diff,
            tuning,
        )

    # methods necessary for total ordering

    def __eq__(self, other):
        return self.frequency_ratio == other.frequency_ratio

    def __lt__(self, other):
        return self.frequency_ratio < other.frequency_ratio

    @property
    def frequency_ratio(self) -> Frequency:
        """
        Returns the frequency ratio between the pitches (e.g.
        2 for an octave)
        """

        pitch_b = self.tuning.pitch(
            self.ref_pitch.pitch_index + self.pitch_diff
        )

        freq_a = self.ref_pitch.frequency
        freq_b = pitch_b.frequency

        return freq_b / freq_a

    @property
    def cents(self) -> float:
        """
        The interval in cents (e.g. 1200 for an octave)
        """

        return round(1200 * self.frequency_ratio.log(2), CENTS_PRECISION)

    def __repr__(self):
        return (
            f'{self.__class__.__name__}({self.pitch_diff}, {self.tuning.name})'
        )


PeriodicPitchT = TypeVar('PeriodicPitchT', bound=PeriodicPitch)


class PeriodicPitchInterval(PitchInterval[PeriodicPitchT]):
    """
    The pitch interval class for periodic tunings.
    """

    def get_generator_distance(self, generator_pitch: PeriodicPitchT) -> int:
        """
        Calculates the minimum number of steps needed to reach
        one pitch from the other when iteratively adding a
        generator pitch.

        A typical application in 12EDO is to calculate the minimum
        distance of the two pitches on the circle of fifths, hence
        the generator distance can be a good measure for consonance
        of an interval given the right generator pitch.

        >>> from xenharmlib import EDOTuning
        >>> edo12 = EDOTuning(12)
        >>> M3 = edo12.pitch(0).interval(edo12.pitch(4))
        >>> M3.get_generator_distance(edo12.pitch(7))
        4

        :param generator_pitch: A generator pitch. Will be normalized
            to the equivalent pitch in the first base interval if its
            pitch index exceeds the period length of the tuning.

        :raises InvalidGenerator: If the pitch is not a generator
            in the tuning attached to the interval
        """

        zero = self.tuning.pitch(0)
        target = self.tuning.pitch(abs(self).pitch_diff)

        i_zero = zero.get_generator_index(generator_pitch)
        i_target = target.get_generator_index(generator_pitch)
        i_diff = i_target - i_zero

        return min(i_diff, len(self.tuning) - i_diff)


class EDPitchInterval(PeriodicPitchInterval[EDPitch]):
    """
    Pitch interval class for equal division tunings
    """

    @classmethod
    def from_pitches(cls, pitch_a: EDPitch, pitch_b: EDPitch) -> Self:
        """
        Constructs an interval out of two pitches of the same tuning.
        If the second pitch is lower than the first pitch the Interval
        will have a negative pitch difference

        :raises IncompatibleTunings: If pitches come
            from different tuning systems

        :param pitch_a: The first (or reference) pitch
        :param pitch_b: The second (or target) pitch
        """

        if pitch_a.tuning is not pitch_b.tuning:
            raise IncompatibleTunings(
                'Pitches must originate from the same tuning context'
            )

        tuning = pitch_a.tuning

        pitch_diff = pitch_b.pitch_index - pitch_a.pitch_index

        # since in equal step tunings all intervals
        # of the same pitch difference are the same
        # in ratio we can just construct an arbitrary
        # pitch as a reference pitch as long as the
        # interval does not go over the zero pitch
        # threshold

        ref_pitch = tuning.pitch(abs(pitch_diff))

        return cls(
            ref_pitch,
            pitch_diff,
            tuning,
        )


class EDOPitchInterval(EDPitchInterval):
    """
    Pitch intervals class for 'equal division of the octave'
    pitches
    """
