// [VexFlow](http://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// Author: Larry Kuhns.
//
// ## Description
//
// This file implements articulations and accents as modifiers that can be
// attached to notes. The complete list of articulations is available in
// `tables.js` under `Vex.Flow.articulationCodes`.
//
// See `tests/articulation_tests.js` for usage examples.

import { Vex } from './vex';
import { Flow } from './tables';
import { Modifier } from './modifier';
import { Glyph } from './glyph';
import { Stem } from './stem';
import { TickContext } from './tickcontext';

// To enable logging for this class. Set `Vex.Flow.Articulation.DEBUG` to `true`.
function L(...args) { if (Articulation.DEBUG) Vex.L('Vex.Flow.Articulation', args); }

const { ABOVE, BELOW } = Modifier.Position;

const roundToNearestHalf = (mathFn, value) => mathFn(value / 0.5) * 0.5;

// This includes both staff and ledger lines
const isWithinLines = (line, position) => position === ABOVE ? line <= 5 : line >= 1;

const getRoundingFunction = (line, position) => {
  if (isWithinLines(line, position)) {
    if (position === ABOVE) {
      return Math.ceil;
    } else {
      return Math.floor;
    }
  } else {
    return Math.round;
  }
};

const snapLineToStaff = (canSitBetweenLines, line, position, offsetDirection) => {
  // Initially, snap to nearest staff line or space
  const snappedLine = roundToNearestHalf(getRoundingFunction(line, position), line);
  const canSnapToStaffSpace = canSitBetweenLines && isWithinLines(snappedLine, position);
  const onStaffLine = snappedLine % 1 === 0;

  if (canSnapToStaffSpace && onStaffLine) {
    const HALF_STAFF_SPACE = 0.5;
    return snappedLine + (HALF_STAFF_SPACE * -offsetDirection);
  } else {
    return snappedLine;
  }
};

const isStaveNote = (note) => {
  const noteCategory = note.getCategory();
  return noteCategory === 'stavenotes' || noteCategory === 'gracenotes';
};

const getTopY = (note, textLine) => {
  const stave = note.getStave();
  const stemDirection = note.getStemDirection();
  const { topY: stemTipY, baseY: stemBaseY } = note.getStemExtents();

  if (isStaveNote(note)) {
    if (note.hasStem()) {
      if (stemDirection === Stem.UP) {
        return stemTipY;
      } else {
        return stemBaseY;
      }
    } else {
      return Math.min(...note.getYs());
    }
  } else if (note.getCategory() === 'tabnotes') {
    if (note.hasStem()) {
      if (stemDirection === Stem.UP) {
        return stemTipY;
      } else {
        return stave.getYForTopText(textLine);
      }
    } else {
      return stave.getYForTopText(textLine);
    }
  } else {
    throw new Vex.RERR(
      'UnknownCategory', 'Only can get the top and bottom ys of stavenotes and tabnotes'
    );
  }
};

const getBottomY = (note, textLine) => {
  const stave = note.getStave();
  const stemDirection = note.getStemDirection();
  const { topY: stemTipY, baseY: stemBaseY } = note.getStemExtents();

  if (isStaveNote(note)) {
    if (note.hasStem()) {
      if (stemDirection === Stem.UP) {
        return stemBaseY;
      } else {
        return stemTipY;
      }
    } else {
      return Math.max(...note.getYs());
    }
  } else if (note.getCategory() === 'tabnotes') {
    if (note.hasStem()) {
      if (stemDirection === Stem.UP) {
        return stave.getYForBottomText(textLine);
      } else {
        return stemTipY;
      }
    } else {
      return stave.getYForBottomText(textLine);
    }
  } else {
    throw new Vex.RERR(
      'UnknownCategory', 'Only can get the top and bottom ys of stavenotes and tabnotes'
    );
  }
};

// Gets the initial offset of the articulation from the y value of the starting position.
// This is required because the top/bottom text positions already have spacing applied to
// provide a "visually pleasent" default position. However the y values provided from
// the stavenote's top/bottom do *not* have any pre-applied spacing. This function
// normalizes this asymmetry.
const getInitialOffset = (note, position) => {
  const isOnStemTip = (
    (position === ABOVE && note.getStemDirection() === Stem.UP) ||
    (position === BELOW && note.getStemDirection() === Stem.DOWN)
  );

  if (isStaveNote(note)) {
    if (note.hasStem() && isOnStemTip) {
      return 0.5;
    } else {
      // this amount is larger than the stem-tip offset because we start from
      // the center of the notehead
      return 1;
    }
  } else {
    if (note.hasStem() && isOnStemTip) {
      return 1;
    } else {
      return 0;
    }
  }
};

export class Articulation extends Modifier {
  static get CATEGORY() { return 'articulations'; }
  static get INITIAL_OFFSET() { return -0.5; }

  // FIXME:
  // Most of the complex formatting logic (ie: snapping to space) is
  // actually done in .render(). But that logic belongs in this method.
  //
  // Unfortunately, this isn't possible because, by this point, stem lengths
  // have not yet been finalized. Finalized stem lengths are required to determine the
  // initial position of any stem-side articulation.
  //
  // This indicates that all objects should have their stave set before being
  // formatted. It can't be an optional if you want accurate vertical positioning.
  // Consistently positioned articulations that play nice with other modifiers
  // won't be possible until we stop relying on render-time formatting.
  //
  // Ideally, when this function has completed, the vertical articulation positions
  // should be ready to render without further adjustment. But the current state
  // is far from this ideal.
  static format(articulations, state) {
    if (!articulations || articulations.length === 0) return false;

    const isAbove = artic => artic.getPosition() === ABOVE;
    const isBelow = artic => artic.getPosition() === BELOW;
    const margin = 0.5;
    const getIncrement = (articulation, line, position) =>
      roundToNearestHalf(
        getRoundingFunction(line, position),
        (articulation.glyph.getMetrics().height / 10) + margin
      );

    articulations
      .filter(isAbove)
      .forEach(articulation => {
        articulation.setTextLine(state.top_text_line);
        state.top_text_line += getIncrement(articulation, state.top_text_line, ABOVE);
      });

    articulations
      .filter(isBelow)
      .forEach(articulation => {
        articulation.setTextLine(state.text_line);
        state.text_line += getIncrement(articulation, state.text_line, BELOW);
      });

    const width = articulations
      .map(articulation => articulation.getWidth())
      .reduce((maxWidth, articWidth) => Math.max(articWidth, maxWidth));

    state.left_shift += width / 2;
    state.right_shift += width / 2;
    return true;
  }

  static easyScoreHook({ articulations }, note, builder) {
    if (!articulations) return;

    const articNameToCode = {
      staccato: 'a.',
      tenuto: 'a-',
    };

    articulations
      .split(',')
      .map(articString => articString.trim().split('.'))
      .map(([name, position]) => {
        const artic = { type: articNameToCode[name] };
        if (position) artic.position = Modifier.PositionString[position];
        return builder.getFactory().Articulation(artic);
      })
      .map(artic => note.addModifier(0, artic));
  }

  // Create a new articulation of type `type`, which is an entry in
  // `Vex.Flow.articulationCodes` in `tables.js`.
  constructor(type) {
    super();
    this.setAttribute('type', 'Articulation');

    this.note = null;
    this.index = null;
    this.type = type;
    this.position = BELOW;
    this.render_options = {
      font_scale: 38,
    };

    //VexFlowPatch
    this.breathMarkDistance = 0.8; // % distance to next note or end of stave (0.8 = 80%)
    this.articulation = Flow.articulationCodes(this.type);
    if (this.type === 'abr') { // breath mark. we could put this in tables.js:articulationCodes()
      this.articulation = { code: 'v6c', between_lines: false }; // v6c: breathmarkcomma
    }
    if (!this.articulation) {
      throw new Vex.RERR('ArgumentError', `Articulation not found: ${this.type}`);
    }

    this.glyph = new Glyph(this.articulation.code, this.render_options.font_scale);

    this.setWidth(this.glyph.getMetrics().width);
  }

  getCategory() { return Articulation.CATEGORY; }

  // Render articulation in position next to note.
  draw() {
    const {
      note, index, position, glyph,
      articulation: { between_lines: canSitBetweenLines },
      text_line: textLine,
      context: ctx,
    } = this;

    this.checkContext();

    if (!note || index == null) {
      throw new Vex.RERR('NoAttachedNote', "Can't draw Articulation without a note and index.");
    }

    this.setRendered();

    const stave = note.getStave();
    const staffSpace = stave.getSpacingBetweenLines();
    const isTab = note.getCategory() === 'tabnotes';

    // Articulations are centered over/under the note head.
    let { x } = note.getModifierStartXY(position, index);
    // VexFlowPatch: breath mark support
    if (this.type === 'abr') { // breath mark
      let delayXShift = 0;
      // delay code similar to ornament.js delayed variable handling
      const noteTickContext = note.getTickContext();
      const nextContext = TickContext.getNextContext(noteTickContext);
      const noteX = note.getTickContext().getX();
      if (nextContext) {
          delayXShift = (nextContext.getX() - noteX) * this.breathMarkDistance;
      } else {
          const stave = note.getStave();
          delayXShift = (stave.getX() + stave.getWidth() - noteX) * this.breathMarkDistance;
      }
      x += delayXShift;
    }
    const x_shift = this.getXShift();
    if (x_shift) {
      x += x_shift; // VexFlowPatch: support x_shift for breath_mark
    }
    const shouldSitOutsideStaff = !canSitBetweenLines || isTab;

    const initialOffset = getInitialOffset(note, position);

    let y = {
      [ABOVE]: () => {
        glyph.setOrigin(0.5, 1);
        const y = getTopY(note, textLine) - ((textLine + initialOffset) * staffSpace);
        return shouldSitOutsideStaff
          ? Math.min(stave.getYForTopText(Articulation.INITIAL_OFFSET), y)
          : y;
      },
      [BELOW]: () => {
        glyph.setOrigin(0.5, 0);
        const y = getBottomY(note, textLine) + ((textLine + initialOffset) * staffSpace);
        return shouldSitOutsideStaff
          ? Math.max(stave.getYForBottomText(Articulation.INITIAL_OFFSET), y)
          : y;
      },
    }[position]();
    // VexFlowPatch: respect modifier.y_shift
    if (this.y_shift) {
        y += this.y_shift;
    }

    if (!isTab) {
      const offsetDirection = position === ABOVE ? -1 : +1;
      const noteLine = isTab ? note.positions[index].str : note.getKeyProps()[index].line;
      const distanceFromNote = (note.getYs()[index] - y) / staffSpace;
      const articLine = distanceFromNote + noteLine;
      const snappedLine = snapLineToStaff(canSitBetweenLines, articLine, position, offsetDirection);

      if (isWithinLines(snappedLine, position)) glyph.setOrigin(0.5, 0.5);

      y += Math.abs(snappedLine - articLine) * staffSpace * offsetDirection;
    }

    L(`Rendering articulation at (x: ${x}, y: ${y})`);

    glyph.render(ctx, x, y);
  }
}
