from typing import List, NamedTuple, Optional, Union

from backports.cached_property import cached_property

from neoscore.core.directions import DirectionX, DirectionY
from neoscore.core.point import ORIGIN, Point
from neoscore.core.positioned_object import PositionedObject
from neoscore.core.rect import Rect
from neoscore.core.units import ZERO, Unit
from neoscore.western import notehead_tables
from neoscore.western.accidental import Accidental
from neoscore.western.duration import Duration, DurationDef
from neoscore.western.flag import Flag
from neoscore.western.ledger_line import LedgerLine
from neoscore.western.notehead import Notehead
from neoscore.western.notehead_tables import NoteheadTable
from neoscore.western.pitch import PitchDef
from neoscore.western.rest import Rest
from neoscore.western.rhythm_dot import RhythmDot
from neoscore.western.staff import Staff
from neoscore.western.staff_object import StaffObject
from neoscore.western.stem import Stem


class PitchAndGlyph(NamedTuple):
    """Used to define individual notes with one-off SMuFL glyphs."""

    pitch: PitchDef
    """The pitch for the notehead"""

    notehead_glyph: str
    """The SMuFL glyph name for the notehead"""


class Chordrest(PositionedObject, StaffObject):

    """A chord or a rest.

    This is a unified interface for conventionally notated
    musical notes/chords/rests. It can be given any number of pitches
    to be used as notes in the chord, or ``None`` for a rest.

    It will automatically generate and lay out:

    * :obj:`.Notehead`\ s if pitches are given
    * a :obj:`.Stem` if pitches are given and required by the given :obj:`.Duration`
    * a :obj:`.Flag` if pitches are given and required by the given ``Duration``
    * :obj:`.LedgerLine`\ s as needed (taking into consideration the given
      pitches and their location on the :obj:`.Staff`)
    * :obj:`.Accidental`\ s as needed by any given pitches
    * a :obj:`.Rest` if no pitches are given
    * :obj:`.RhythmDot`\ s if needed by the given ``Duration``

    Any accidentals given in pitches will be unconditionally drawn
    regardless of context and key signature.
    """

    # All cached properties which change on rebuilds *must* be registered here.
    _CACHED_PROPS = [
        "ledger_line_positions",
        "rhythm_dot_positions",
        "furthest_notehead",
        "highest_notehead",
        "lowest_notehead",
        "leftmost_notehead",
        "rightmost_notehead",
        "widest_notehead",
        "extra_attachment_point",
        "tremolo_attachment_point",
        "notehead_column_bounding_rect",
        "noteheads_outside_staff",
        "leftmost_notehead_outside_staff",
        "rightmost_notehead_outside_staff",
        "notehead_column_outside_staff_width",
        "stem_height",
    ]

    def __init__(
        self,
        pos_x: Unit,
        staff: Staff,
        notes: Optional[List[Union[PitchDef, PitchAndGlyph]]],
        duration: DurationDef,
        rest_y: Optional[Unit] = None,
        stem_direction: Optional[DirectionY] = None,
        beam_break_depth: Optional[int] = None,
        beam_hook_dir: Optional[DirectionX] = None,
        table: NoteheadTable = notehead_tables.STANDARD,
    ):
        """
        Args:
            pos_x: The horizontal position in the staff
            staff: The staff the object is attached to
            notes: A list of pitches and optional notehead-specific data. If ``None``
                this indicates a rest. For simple notes and chords, this can typically
                be a list of pitch string shorthands (see :obj:`.Pitch.from_str`). Pitches
                with extended accidentals can be given by passing fully constructed
                ``Pitch`` objects. Individual notehead glyphs (by default taken from the
                given ``table``) can be overridden by passing a tuple of a pitch
                and a SMuFL glyph name string.
            duration: The written duration for the object.
            rest_y: The vertical position used by rests. This defaults to the center
                of the staff.
            stem_direction: An optional stem direction override. If omitted, the
                direction is automatically calculated to point away from
                the furthest-out notehead.
            beam_break_depth: Break depth used if in a :obj:`.BeamGroup`.
            beam_hook_dir: Beamlet hook direction used in a ``BeamGroup``.
            table: The set of noteheads to use according to ``duration``.
        """
        StaffObject.__init__(self, staff)
        PositionedObject.__init__(self, Point(pos_x, ZERO), staff)
        self.duration = duration
        self._noteheads = []
        self._accidentals = []
        self._ledgers = []
        self._dots = []
        self._notes = [] if notes is None else notes
        self._stem = None
        self._flag = None
        self._rest_y = rest_y
        self._rest = None
        self._stem_direction_override = stem_direction
        self._beam_break_depth = beam_break_depth
        self._beam_hook_dir = beam_hook_dir
        self._table = table
        self._rebuild()

    @property
    def notes(self) -> List[Union[PitchDef, PitchAndGlyph]]:
        return self._notes

    @notes.setter
    def notes(self, value: Optional[List[Union[PitchDef, PitchAndGlyph]]]):
        self._notes = [] if value is None else value
        self._rebuild()

    @property
    def noteheads(self) -> List[Notehead]:
        """The noteheads contained in this Chordrest."""
        return self._noteheads

    @property
    def rest_y(self) -> Optional[Unit]:
        """The vertical position used by generated rests.

        Defaults to the staff center.
        """
        return self._rest_y

    @rest_y.setter
    def rest_y(self, value: Optional[Unit]):
        self._rest_y = value
        self._rebuild()

    @property
    def rest(self) -> Optional[Rest]:
        """A rest glyph, if no noteheads exist."""
        return self._rest

    @property
    def accidentals(self) -> List[Accidental]:
        """The accidentals contained in this Chordrest."""
        return self._accidentals

    @property
    def ledgers(self) -> List[LedgerLine]:
        """The ledger lines contained in this Chordrest.

        An empty list means none are needed.
        """
        return self._ledgers

    @property
    def dots(self) -> List[RhythmDot]:
        return self._dots

    @property
    def stem(self) -> Optional[Stem]:
        """The stem for the Chordrest."""
        return self._stem

    @property
    def flag(self) -> Optional[Flag]:
        """The flag attached to the stem."""
        return self._flag

    @property
    def beam_break_depth(self) -> Optional[int]:
        """Break depth used if in a :obj:`.BeamGroup`.

        If this Chordrest is within a beam group, this triggers a beam subdivision break
        at this point. The value indicates the number of beams to which the subdivision
        breaks. For example, in run of 16th notes a ``beam_break_depth`` of ``1`` would
        indicate a subdivision break to 1 beam at this point.
        """
        return self._beam_break_depth

    @property
    def beam_hook_dir(self) -> Optional[DirectionX]:
        """Beamlet hook direction used in a :obj:`.BeamGroup`.

        If this Chordrest is within a beam group and this position is one requiring a
        beamlet hook whose direction is ambiguous, this controls that direction.
        """
        return self._beam_hook_dir

    @property
    def table(self) -> NoteheadTable:
        return self._table

    @table.setter
    def table(self, table: NoteheadTable):
        self._table = table
        self._rebuild()

    @property
    def duration(self) -> Duration:
        """The written length of this event.

        This affects many components of the chordrest.
        """
        return self._duration

    @duration.setter
    def duration(self, value: DurationDef):
        rebuild_needed = hasattr(self, "_duration")
        value = Duration.from_def(value)
        if value.display is None:
            raise ValueError(f"{value} cannot be represented as a single note")
        self._duration = value
        if rebuild_needed:
            self._rebuild()

    @cached_property
    def ledger_line_positions(self) -> List[Unit]:
        """A set of staff positions of needed ledger lines.

        Positions are in centered staff positions.

        An empty list means no ledger lines are needed.
        """
        highest = self.highest_notehead.y
        lowest = self.lowest_notehead.y
        # This could be optimized by only doing both ledger lookups if
        # ``highest`` and ``lowest`` are on opposite sides of the staff,
        # in which case the check-then-append step can be done
        # unconditionally.
        ledgers = self.staff.ledgers_needed_for_y(lowest)
        for ledger_pos in self.staff.ledgers_needed_for_y(highest):
            if ledger_pos not in ledgers:
                ledgers.append(ledger_pos)
        return ledgers

    @cached_property
    def rhythm_dot_positions(self) -> List[Point]:
        """The positions of all rhythm dots needed."""
        start_padding = self.staff.unit(0.25)
        if self.rest:
            dot_start_x = self.rest.x + self.rest.bounding_rect.width + start_padding
            dottables = {self.rest}
        else:
            dot_start_x = (
                self.leftmost_notehead.x
                + self.notehead_column_bounding_rect.width
                + start_padding
            )
            dottables = self.noteheads
        result = []
        for i in range(self.duration.display.dot_count):
            for dottable in dottables:
                if dottable.y.display_value % 1 == 0:
                    # Dottable is on a line, add dot offset to space below
                    y_offset = self.staff.unit(-0.5)
                else:
                    y_offset = self.staff.unit(0)
                result.append(
                    Point(
                        dot_start_x + (self.staff.unit(0.5) * i), dottable.y + y_offset
                    )
                )
        return result

    @cached_property
    def furthest_notehead(self) -> Optional[Notehead]:
        """The notehead furthest from the staff center"""
        return max(
            self.noteheads,
            key=lambda n: abs(n.y - self.staff.center_y),
            default=None,
        )

    @cached_property
    def highest_notehead(self) -> Optional[Notehead]:
        """The highest notehead in the chord."""
        return min(self.noteheads, key=lambda n: n.y, default=None)

    @cached_property
    def lowest_notehead(self) -> Optional[Notehead]:
        """The lowest notehead in the chord."""
        return max(self.noteheads, key=lambda n: n.y, default=None)

    @cached_property
    def leftmost_notehead(self) -> Optional[Notehead]:
        """The notehead furthest to the left in the chord"""
        return min(self.noteheads, key=lambda n: n.x, default=None)

    @cached_property
    def rightmost_notehead(self) -> Optional[Notehead]:
        """The notehead furthest to the right in the chord"""
        return max(self.noteheads, key=lambda n: n.x, default=None)

    @cached_property
    def extra_attachment_point(self) -> Point:
        """A point where common attachments like ornaments could go.

        For chords, this is a point centered above or below the outermost notehead
        opposite of the stem direction.

        For rests, this is a point centered above the rest.

        The returned point is relative to the Chordrest.
        """
        if self.rest:
            bounding_rect = self.rest.bounding_rect
            x = self.rest.x + bounding_rect.x + (bounding_rect.width / 2)
            y = self.rest.y + bounding_rect.y - self.staff.unit(1)
            return Point(x, y)
        if self.stem_direction == DirectionY.UP:
            notehead = self.lowest_notehead
            bounding_rect = notehead.bounding_rect
            y = notehead.y + bounding_rect.y + bounding_rect.height + self.staff.unit(1)
        else:
            notehead = self.highest_notehead
            bounding_rect = notehead.bounding_rect
            y = notehead.y + bounding_rect.y - self.staff.unit(1)
        x = notehead.x + bounding_rect.x + (bounding_rect.width / 2)
        return Point(x, y)

    @cached_property
    def tremolo_attachment_point(self) -> Point:
        """A convenient reasonable point for tremolos to be placed.

        The returned point is relative to the chordrest.

        The precise position returned is not currently guaranteed, as there are known
        shortcomings that still need to be addressed, particularly with short stems and
        stems with flags attached.

        For rests, this simply returns ``ORIGIN``.
        """
        if self.rest:
            # Since there's no use-case we know of for rest tremolos, there's no
            # reasonable implementation of this, so just return 0, 0.
            return ORIGIN
        x = ZERO
        noteheads_rect = self.notehead_column_bounding_rect
        if not self.duration.display.requires_stem:
            x = noteheads_rect.x + (noteheads_rect.width / 2)
        if self.stem_direction == DirectionY.DOWN:
            y = noteheads_rect.y + noteheads_rect.height + self.staff.unit(1.5)
        else:
            y = noteheads_rect.y - self.staff.unit(1)
        return Point(x, y)

    @cached_property
    def notehead_column_bounding_rect(self) -> Rect:
        """The bounding rect of the notehead column after layout."""
        if self.rest:
            return Rect(ZERO, ZERO, ZERO, ZERO)
        left_x = Unit(float("inf"))
        top_y = Unit(float("inf"))
        right_x = Unit(float("-inf"))
        bottom_y = Unit(float("-inf"))
        for n in self.noteheads:
            n_rect = n.bounding_rect.offset(n.pos)
            if left_x > n_rect.x:
                left_x = n_rect.x
            if top_y > n_rect.y:
                top_y = n_rect.y
            n_rect_right_x = n_rect.x + n_rect.width
            if right_x < n_rect_right_x:
                right_x = n_rect_right_x
            n_rect_bottom_y = n_rect.y + n_rect.height
            if bottom_y < n_rect_bottom_y:
                bottom_y = n_rect_bottom_y
        return Rect(left_x, top_y, right_x - left_x, bottom_y - top_y)

    @cached_property
    def noteheads_outside_staff(self) -> List[Notehead]:
        """All noteheads which are above or below the staff"""
        return [
            note
            for note in self.noteheads
            # Since ``note.parent == self`` and ``self.y == 0``
            if not self.staff.y_inside_staff(note.y)
        ]

    @cached_property
    def leftmost_notehead_outside_staff(self) -> Optional[Notehead]:
        """The notehead furthest to the left outside the staff"""
        return min(self.noteheads_outside_staff, key=lambda n: n.x, default=None)

    @cached_property
    def rightmost_notehead_outside_staff(self) -> Optional[Notehead]:
        """The notehead furthest to the right outside the staff"""
        return max(self.noteheads_outside_staff, key=lambda n: n.x, default=None)

    @cached_property
    def notehead_column_outside_staff_width(self) -> Unit:
        """The total width of any noteheads outside the staff"""
        left_bounding_note = self.leftmost_notehead_outside_staff
        if not left_bounding_note:
            return ZERO
        else:
            extent = max((n.x + n.visual_width for n in self.noteheads_outside_staff))
            return extent - left_bounding_note.x

    # Can't use cached_property here because a setter is needed. Could get around this
    # by storing this computation in an attr like self._stem_direction though.
    @property
    def stem_direction(self) -> DirectionY:
        """The direction of the stem

        Takes the notehead furthest from the center of the staff, and returns the
        opposite direction.

        If the furthest notehead is in the center of the staff, the direction defaults
        to ``DirectionY.DOWN``, unless the staff has only one line, in which case it
        defaults to ``DirectionY.UP`` as a convenience for percussion staves.

        This automatically calculated property may be overridden using its setter. To
        revert to the automatically calculated value set this property to ``None``.

        If there are no noteheads (meaning this Chordrest is a rest), this arbitrarily
        returns ``DirectionY.UP``.

        """
        if self._stem_direction_override:
            return self._stem_direction_override
        furthest = self.furthest_notehead
        if furthest is None:
            return DirectionY.UP
        if furthest.y < self.staff.center_y:
            return DirectionY.DOWN
        elif furthest.y == self.staff.center_y:
            if self.staff.line_count == 1:
                return DirectionY.UP
            else:
                return DirectionY.DOWN
        else:
            return DirectionY.UP

    @stem_direction.setter
    def stem_direction(self, value: Optional[DirectionY]):
        self._stem_direction_override = value
        self._rebuild()

    @cached_property
    def stem_height(self) -> Unit:
        """The height of the stem"""
        flag_offset = self.staff.unit(Flag.vertical_offset_needed(self.duration))
        min_abs_height = self.staff.unit(2.5) + flag_offset
        fitted_abs_height = (
            abs(self.lowest_notehead.y - self.highest_notehead.y)
            + self.staff.unit(2.5)
            + flag_offset
        )
        abs_height = max(min_abs_height, fitted_abs_height)
        return abs_height

    def _clear(self):
        for notehead in self.noteheads:
            notehead.remove()
        self._noteheads = []
        for accidental in self.accidentals:
            accidental.remove()
        self._accidentals = []
        for ledger in self.ledgers:
            ledger.remove()
        self._ledgers = []
        for dot in self.dots:
            dot.remove()
        self._dots = []
        if self.stem:
            self.stem.remove()
        self._stem = None
        if self.flag:
            self.flag.remove()
        self._flag = None
        if self.rest:
            self.rest.remove()
        self._rest = None
        for prop in Chordrest._CACHED_PROPS:
            self.__dict__.pop(prop, None)

    def _rebuild(self):
        """Generate or regenerate all child objects"""
        if self.noteheads or self.rest:
            # Clear existing glyphs
            self._clear()
        if self.notes:
            for note in self.notes:
                if isinstance(note, tuple) and len(note) == 2:
                    pitch = note[0]
                    glyph_override = note[1]
                else:
                    pitch = note
                    glyph_override = None
                self._noteheads.append(
                    Notehead(
                        ZERO,
                        self,
                        pitch,
                        self.duration,
                        table=self.table,
                        glyph_override=glyph_override,
                    )
                )
            self._rest = None
            self._create_stem()
            if self.stem:
                self._position_noteheads_around_stem()
            self._create_accidentals()
            self._position_accidentals_horizontally()
            self._create_ledgers()
            self._create_flag()
        else:
            rest_y = self.rest_y or self.staff.center_y
            self._rest = Rest(Point(self.staff.unit(0), rest_y), self, self.duration)
        # Both rests and chords needs dots
        self._create_dots()

    def _create_ledgers(self):
        """Create all required ledger lines and store them in ``self.ledgers``

        This should be called after _position_noteheads_horizontally()
        as it relies on the position of noteheads in the chord to
        calculate the position and length of the ledger lines
        """
        pos_x = self.leftmost_notehead.x
        length = self.notehead_column_outside_staff_width
        for staff_pos in self.ledger_line_positions:
            self.ledgers.append(LedgerLine(Point(pos_x, staff_pos), self, length))

    def _create_accidentals(self):
        for notehead in self.noteheads:
            if notehead.pitch.accidental is None:
                continue
            accidental = Accidental(
                # X value is a placeholder to be resolved
                # in _position_accidentals_horizontally
                (ZERO, notehead.y),
                self,
                notehead.pitch.accidental,
            )
            self.accidentals.append(accidental)

    def _create_dots(self):
        """Create all the RhythmDots needed by this Chordrest."""
        for dot_pos in self.rhythm_dot_positions:
            self.dots.append(RhythmDot(dot_pos, self))

    def _create_stem(self):
        """If needed, create a Stem and store it in ``self.stem``."""
        if not self.duration.display.requires_stem:
            return
        if self.stem_direction == DirectionY.UP:
            attached_notehead = self.lowest_notehead
            anchor_key = "stemUpSE"
        else:
            attached_notehead = self.highest_notehead
            anchor_key = "stemDownNW"
        resolved_anchor_y = ZERO
        # Special case guard needed for invisible noteheads without music chars
        if attached_notehead.text:
            anchors = attached_notehead.music_chars[0].glyph_info.anchors
            if anchors:
                resolved_anchor = anchors.get(anchor_key)
                if resolved_anchor:
                    resolved_anchor_y = resolved_anchor.y
        self._stem = Stem(
            Point(ZERO, attached_notehead.y + resolved_anchor_y),
            self,
            self.stem_direction,
            self.stem_height,
        )

    def _create_flag(self):
        """Create a Flag attached to ``self.stem`` and store it in ``self.flag``"""
        if self.duration.display.flag_count:
            self._flag = Flag(
                (self.stem.pen.thickness / -2, ZERO),
                self.stem.end_point,
                self.duration,
                self.stem.direction,
            )

    def _position_noteheads_around_stem(self):
        """Reposition noteheads so that they are laid out correctly around the stem.

        This should only be run if a stem exists.
        """
        # Find the preferred side of the stem for noteheads,
        default_side = (
            DirectionX.LEFT
            if self.stem_direction == DirectionY.UP
            else DirectionX.RIGHT
        )
        # Start last staff pos at sentinel infinity position.
        # Rather than working with staff positions, we can work with
        # ``Notehead.y`` values directly because we know they all share
        # ``self`` as a parent.
        prev_y = Unit(float("inf"))
        # Start prev_side at wrong side so first note goes on the default side
        prev_side = default_side.flip()
        for note in sorted(
            self.noteheads,
            key=lambda n: n.y,
            reverse=(self.stem_direction == DirectionY.UP),
        ):
            if abs(prev_y - note.y) < self.staff.unit(1):
                # This note collides with previous, switch sides
                prev_side = prev_side.flip()
            else:
                prev_side = default_side
            note.x = self._resolve_notehead_x_pos(note, prev_side)
            prev_y = note.y

    def _resolve_notehead_x_pos(
        self, notehead: Notehead, stem_side: DirectionX
    ) -> Unit:
        if not notehead.text:
            return ZERO
        anchors = notehead.music_chars[0].glyph_info.anchors
        if not anchors:
            if stem_side == DirectionX.LEFT:
                return -notehead.visual_width
            return ZERO
        stem_offset = self.stem.pen.thickness / 2
        if stem_side == DirectionX.LEFT:
            anchor_key = "stemUpSE"
            stem_offset *= -1
        else:
            anchor_key = "stemDownNW"
        return -(anchors[anchor_key].x + stem_offset)

    def _position_accidentals_horizontally(self):
        """Reposition accidentals so that they are laid out correctly

        This implementation is very basic and fails in some fairly common use-cases. A
        proper solution would likely involve totally redoing this. See
        https://github.com/DigiScore/neoscore/issues/32
        """
        # Rather than working with staff positions, we can work with
        # ``Accidental.y`` values directly because we know they all share
        # ``self`` as a parent.
        padding = self.staff.music_font.engraving_defaults["staffLineThickness"]
        notehead_col_edge_x = self.leftmost_notehead.x
        prev_side = None
        prev_acc = None
        prev_rect = None
        for acc in sorted(self.accidentals, key=lambda a: a.y):
            acc_rect = acc.bounding_rect
            shift_for_collision = False

            if (
                prev_acc
                and prev_side == DirectionX.RIGHT
                and (prev_acc.y + prev_rect.y + prev_rect.height > acc.y + acc_rect.y)
            ):
                # This accidental collides with previous, switch sides
                shift_for_collision = True
                prev_side = DirectionX.LEFT
            else:
                prev_side = DirectionX.RIGHT
            if shift_for_collision:
                acc.x = prev_acc.x - acc.bounding_rect.width
            else:
                acc.x = notehead_col_edge_x - acc.bounding_rect.width - padding
            prev_acc = acc
            prev_rect = acc_rect
