/**

* @typedef {number[]} Point
*/

/**

* @typedef {object} Solution
* @property {Point[]} positions list of points
*/

class MuzzlePainter extends headbreaker.painters.Konva {

_newLine(options) {

  const line = super._newLine(options);
  line.strokeScaleEnabled(false);
  return line;
}

}

/**

* Facade for referencing and creating a global puzzle canvas,
* handling solutions persistence and submitting them
*/

class MuzzleCanvas {

// =============
// Global canvas
// =============

constructor(id = 'muzzle-canvas') {

  /**
   * @private
   * @type {Canvas}
   **/
  this._canvas = null;

  /**
   * The id of the HTML element that will contain the canvas
   * Override it you are going to place in a non-standard way
   *
   * @type {string}
   */
  this.canvasId = id;

  /**
   * An optional list of refs that, if set, will be used to validate
   * this puzzle both on client and server side
   *
   * @private
   * @type {Point[]}
   * */
  this._expectedRefs = null;

  /**
   * Wether expected refs shall be ignored by Muzzle.
   *
   * They will still be evaluated server-side.
   *
   * @type {boolean}
   */
  this.expectedRefsAreOnlyDescriptive = false;

  /**
   * Width of canvas
   *
   * @type {number}
   */
  this.canvasWidth = 600;

  /**
   * Height of canvas
   *
   * @type {number}
   */
  this.canvasHeight = 600;

  /**
   * Wether canvas shoud **not** be resized.
   * Default is `false`
   *
   * @type {boolean}
   */
  this.fixedDimensions = false;

  /**
   * Size of fill. Set null for perfect-match
   *
   * @type {number}
   */
  this.borderFill = null;

  /**
   * Canvas line width
   *
   * @type {number}
   */
  this.strokeWidth = 3;

  /**
   * Piece size
   *
   * @type {number}
   */
  this.pieceSize = 100;

  /**
   * The `x:y` aspect ratio of the piece. Set null for automatic
   * aspectRatio
   *
   * @type {number}
   */
  this.aspectRatio = null;

  /**
   * If the images should be adjusted vertically instead of horizontally
   * to puzzle dimensions.
   *
   * Set null for automatic fit.
   *
   * @type {boolean}
   */
  this.fitImagesVertically = null;

  /**
   * Wether the scaling should ignore the scaler
   * rise events
   */
  this.manualScale = false;

  /**
   * The canvas shuffler.
   *
   * Set it null to automatic shuffling algorithm selection.
   */
  this.shuffler = null;

  /**
   * Callback that will be executed
   * when muzzle has fully loaded and rendered its first
   * canvas.
   *
   * It does nothing by default but you can override this
   * property with any code you need the be called here
   */
  this.onReady = () => {};

  /**
   * The previous solution to the current puzzle in a past session,
   * if any
   *
   * @type {string}
   */
  this.previousSolutionContent = null;

  /**
   * Whether the current puzzle can be solved in very few tries.
   *
   * Set null for automatic configuration of this property. Basic puzzles will be considered
   * basic and match puzzles will be considered non-simple.
   *
   * @type {boolean}
   */
  this.simple = null;

  this.spiky = false;

  /**
   * The reference insert axis, used at rounded outline to compute insert internal and external diameters
   *
   * Set null for default computation of axis - no axis reference for basic boards
   * and vertical axis for match
   *
   * @type {Axis}
   * */
  this.referenceInsertAxis = null;

  /**
   * Callback to be executed when submitting puzzle.
   *
   * Does nothing by default but you can
   * override it to perform additional actions
   *
   * @param {{solution: {content: string}, client_result: {status: "passed" | "failed"}}} submission
   */
  this.onSubmit = (submission) => {};

   /**
   * Callback that will be executed
   * when muzzle's puzzle becomes valid
   *
   * It does nothing by default but you can override this
   * property with any code you need the be called here
   */
  this.onValid = () => {};

  /**
   * @private
   */
  this._ready = false;
}

get painter() {
  return new MuzzlePainter();
}

/**
 */
get baseConfig() {
  return Object.assign({
    preventOffstageDrag: true,
    width: this.canvasWidth,
    height: this.canvasHeight,
    pieceSize: this.adjustedPieceSize,
    proximity: Math.min(this.adjustedPieceSize.x, this.adjustedPieceSize.y) / 5,
    strokeWidth: this.strokeWidth,
    lineSoftness: 0.18,
    painter: this.painter
  }, this.outlineConfig);
}

/**
 */
get outlineConfig() {
  if (this.spiky) {
    return {
      borderFill: this.borderFill === null ? headbreaker.Vector.divide(this.adjustedPieceSize, 10) : this.borderFill,
    }
  } else {
    return {
      borderFill: 0,
      outline: new headbreaker.outline.Rounded({
        bezelize: true,
        insertDepth: 3/5,
        bezelDepth: 9/10,
        referenceInsertAxis: this.referenceInsertAxis
      }),
    }
  }
}

/**
 * The piece size, adjusted to the aspect ratio
 *
 * @returns {Vector}
 */
get adjustedPieceSize() {
  if (!this._adjustedPieceSize) {
    const aspectRatio = this.effectiveAspectRatio;
    this._adjustedPieceSize = headbreaker.vector(this.pieceSize * aspectRatio, this.pieceSize);
  }
  return this._adjustedPieceSize;
}

/**
 * @type {Axis}
 */
get imageAdjustmentAxis() {
  return this.fitImagesVertically ? headbreaker.Vertical : headbreaker.Horizontal;
}

/**
 * The configured aspect ratio, or 1
 *
 * @type {number}
 */
get effectiveAspectRatio() {
  return this.aspectRatio || 1;
}

/**
 * The currently active canvas, or null if
 * it has not yet initialized
 *
 * @returns {Canvas}
 */
get canvas() {
  return this._canvas;
}

/**
 * Draws the - previusly built - current canvas.
 *
 * Prefer `this.currentCanvas.redraw()` when performing
 * small updates to the pieces.
 */
draw() {
  this.canvas.draw();
}

// ========
// Building
// ========

/**
 * @param {Point[]} refs
 */
expect(refs) {
  this._expectedRefs = refs;
}

/**
 * Creates a basic puzzle canvas with a rectangular shape
 * and a background image, that is automatically
 * submitted when solved
 *
 * @param {number} x the number of horizontal pieces
 * @param {number} y the number of vertical pieces
 * @param {string} imagePath
 * @returns {Promise<Canvas>} the promise of the built canvas
 */
async basic(x, y, imagePath) {
  this._config('aspectRatio', y / x);
  this._config('simple', true);
  this._config('shuffler', Muzzle.Shuffler.grid);

  /**
   * @todo take all container size
   **/
  const image = await this._loadImage(imagePath);
  /** @type {Canvas} */
  // @ts-ignore
  const canvas = this._createCanvas({ image: image });
  canvas.adjustImagesToPuzzle(this.imageAdjustmentAxis);
  canvas.autogenerate({ horizontalPiecesCount: x, verticalPiecesCount: y });
  this._attachBasicValidator(canvas);
  this._configCanvas(canvas);
  canvas.onValid(() => {
    setTimeout(() => {
      if (canvas.valid) {
        this.submit();
      }
    }, 1500);
  });
  return canvas;
}

/**
 * Creates a choose puzzle, where a single right piece must match the single left piece,
 * choosing the latter from a bunch of other left odd pieces. By default, `Muzzle.Shuffler.line` shuffling is used.
 *
 * This is a particular case of a match puzzle with line
 *
 * @param {string} leftUrl the url of the left piece
 * @param {string} rightUrl the url of the right piece
 * @param {string[]} leftOddUrls the urls of the off left urls
 * @param {number} [rightAspectRatio] the `x:y` ratio of the right pieces, that override the general `aspectRatio` of the puzzle.
 *                                    Use null to have the same aspect ratio as left pieces
 *
 * @returns {Promise<Canvas>} the promise of the built canvas
 */
async choose(leftUrl, rightUrl, leftOddUrls, rightAspectRatio = null) {
  this._config('shuffler', Muzzle.Shuffler.line);
  return this.match([leftUrl], [rightUrl], {leftOddUrls, rightAspectRatio});
}

/**
 * Creates a match puzzle, where left pieces are matched against right pieces,
 * with optional odd left and right pieces that don't match. By default, `Muzzle.Shuffler.columns`
 * shuffling is used.
 *
 * @param {string[]} leftUrls
 * @param {string[]} rightUrls must be of the same size of lefts
 * @param {object} [options]
 * @param {string[]} [options.leftOddUrls]
 * @param {string[]} [options.rightOddUrls]
 * @param {number?} [options.rightAspectRatio] the aspect ratio of the right pieces. Use null to have the same aspect ratio as left pieces
 * @returns {Promise<Canvas>} the promise of the built canvas
 */
async match(leftUrls, rightUrls, {leftOddUrls = [], rightOddUrls = [], rightAspectRatio = this.effectiveAspectRatio} = {}) {
  const rightWidthRatio = rightAspectRatio / this.effectiveAspectRatio;

  this._config('simple', false);
  this._config('shuffler', Muzzle.Shuffler.columns);
  this._config('fitImagesVertically', rightWidthRatio > 1);
  this._config('referenceInsertAxis', headbreaker.Vertical);

  /** @private @type {(Promise<Template>)[]} */
  const templatePromises = [];

  const rightSize = headbreaker.diameter(
    headbreaker.Vector.multiply(this.adjustedPieceSize, headbreaker.vector(rightWidthRatio, 1)));

  const pushTemplate = (path, options) =>
    templatePromises.push(this._createMatchTemplate(path, options));

  const pushLeftTemplate = (index, path, options) =>
    pushTemplate(path, {
      left: true,
      targetPosition: headbreaker.Vector.multiply(this.pieceSize, headbreaker.vector(1, index)),
      ...options
    });

  const pushRightTemplate = (index, path, options) =>
    pushTemplate(path, {
      size: rightSize,
      targetPosition: headbreaker.Vector.multiply(this.pieceSize, headbreaker.vector(2, index)),
      ...options
    });

  const last = leftUrls.length - 1;
  for (let i = 0; i <= last; i++) {
    const leftId = `l${i}`;
    const rightId = `r${i}`;

    pushLeftTemplate(i + 1, leftUrls[i], {
      id: leftId,
      rightTargetId: rightId
    });
    pushRightTemplate(i + 1, rightUrls[i], {
      id: rightId
    });
  }

  leftOddUrls.forEach((it, i) =>
    pushLeftTemplate(i + leftUrls.length, it, {
      id: `lo${i}`,
      odd: true
    })
  );
  rightOddUrls.forEach((it, i) =>
    pushRightTemplate(i + rightUrls.length, it, {
      id: `ro${i}`,
      odd: true
    })
  );

  //  + Math.max(leftOddUrls.length, rightOddUrls.length)
  const templates = await Promise.all(templatePromises);
  /** @type {Canvas} */
  const canvas = this._createCanvas({ maxPiecesCount: {x: 2, y: leftUrls.length} });
  canvas.adjustImagesToPiece(this.imageAdjustmentAxis);
  templates.forEach(it => canvas.sketchPiece(it));
  this._attachMatchValidator(canvas);
  this._configCanvas(canvas);
  return canvas;
}

/**
 * @param {Canvas} canvas
 * @returns {Promise<Canvas>} the promise of the built canvas
 */
custom(canvas) {
  this._configCanvas(canvas);
  return Promise.resolve(canvas);
}

/**
 * @private
 * @param {any} config
 * @return {Canvas}
 */
_createCanvas(config = {}) {
  return new headbreaker.Canvas(this.canvasId, Object.assign(config, this.baseConfig));
}

/**
 * @private
 * @param {Canvas} canvas
 */
_attachBasicValidator(canvas) {
  if (!this.expectedRefsAreOnlyDescriptive && this._expectedRefs) {
    canvas.attachRelativeRefsValidator(this._expectedRefs);
  } else {
    canvas.attachSolvedValidator();
  }
}

/**
 * @private
 * @param {Canvas} canvas
 */
_attachMatchValidator(canvas) {
  canvas.attachValidator(new headbreaker.PuzzleValidator(
    puzzle => puzzle.pieces
                .filter(it => !it.metadata.odd && it.metadata.left)
                .every(it => it.rightConnection && it.rightConnection.id === it.metadata.rightTargetId)
  ));
}

/**
 * @private
 * @param {string} path
 * @returns {Promise<HTMLImageElement>}
 */
_loadImage(path) {
  const image = new Image();
  image.src = path;
  return new Promise((resolve, reject) => image.onload = () => resolve(image));
}

/**
 * @private
 * @param {string} imagePath
 * @param {object} options
 * @returns {Promise<object>}
 */
_createMatchTemplate(imagePath, {id, left = false, targetPosition = null, rightTargetId = null, odd = false, size = null}) {
  const structure = left ? 'T-N-' : `N-S-`;
  return this._loadImage(imagePath).then((image) => {
    return {
      ...(size ? {size} : {}),
      structure,
      metadata: { id, left, odd, rightTargetId, image, targetPosition }
    }
  });
}

/**
 * @private
 * @param {Canvas} canvas
 */
_configCanvas(canvas) {
  this._canvas = canvas;
  this._canvas.shuffleWith(0.8, this.shuffler);
  this._canvas.onValid(() => {
    setTimeout(() => this.onValid(), 0);
  });
  this._setUpScaler();
  this.ready();
}

_setUpScaler() {
  if (this.manualScale) return;

  ['resize', 'load'].forEach((event) => {
    window.addEventListener(event, () => {
      console.debug("Scaler event fired:", event);
      var container = document.getElementById(this.canvasId);
      this.scale(container.offsetWidth, container.scrollHeight);
    });
  });
}

/**
 * Scales the canvas to the given width and height
 *
 * @param {number} width
 * @param {number} height
 */
scale(width, height) {
  if (this.fixedDimensions || !this.canvas) return;

  console.debug("Scaling:", {width, height})
  const factor = this.optimalScaleFactor(width, height);
  this.canvas.resize(width, height);
  this.canvas.scale(factor);
  this.canvas.redraw();
  this.focus();
}

/**
 * Focuses the stage around the canvas center
 */
focus() {
  const stage = this.canvas['__konvaLayer__'].getStage();

  const area = headbreaker.Vector.divide(headbreaker.vector(stage.width(), stage.height()), stage.scaleX());
  const realDiameter = (() => {
    const [xs, ys] = this.coordinates;

    const minX = Math.min(...xs);
    const minY = Math.min(...ys);

    const maxX = Math.max(...xs);
    const maxY = Math.max(...ys);

    return headbreaker.vector(maxX - minX, maxY - minY);
  })();
  const diff = headbreaker.Vector.minus(area, realDiameter);
  const semi = headbreaker.Vector.divide(diff, -2);

  stage.setOffset(semi);
  stage.draw();
}

/**
 * @private
 */
get coordinates() {
  const points = this.canvas.puzzle.points;
  return [points.map(([x, _y]) => x), points.map(([_x, y]) => y)];
}

/**
 * @private
 * @param {number} width
 * @param {number} height
 */
optimalScaleFactor(width, height) {
  const factors = headbreaker.Vector.divide(headbreaker.vector(width, height), this.canvas.puzzleDiameter);
  return Math.min(factors.x, factors.y) / 1.75;
}

/**
 * Mark Muzzle as ready, loading previous solution
 * and drawing the canvas
 */
ready() {
  this.loadPreviousSolution();
  this.resetCoordinates();
  this.draw();
  this._ready = true;
  this.onReady();
}

isReady() {
  return this._ready;
}

// ===========
// Persistence
// ===========

/**
 * The state of the current puzzle
 * expressed as a Solution object
 *
 * @returns {Solution}
 */
get solution() {
  return { positions: this.canvas.puzzle.points }
}

/**
 * Loads - but does not draw - a solution into the canvas.
 *
 * @param {Solution} solution
 */
loadSolution(solution) {
  this.canvas.puzzle.relocateTo(solution.positions);
  this.canvas.puzzle.autoconnect();
}

/**
 * Loads - but does not draw - the current canvas with the previous solution, if available.
 *
 */
loadPreviousSolution() {
  if (this.previousSolutionContent) {
    try {
      this.loadSolution(JSON.parse(this.previousSolutionContent));
    } catch (e) {
      console.warn("Ignoring unparseabe editor value");
    }
  }
}

/**
 * Translates the pieces so that
 * they start at canvas' coordinates origin
 */
resetCoordinates() {
  const [xs, ys] = this.coordinates;
  const minX = Math.min(...xs);
  const minY = Math.min(...ys);
  this.canvas.puzzle.translate(-minX, -minY);
}

// ==========
// Submitting
// ==========

/**
 * Submits the puzzle to the bridge,
 * validating it if necessary
 */
submit() {
  this.onSubmit(this._prepareSubmission());
}

/**
 * The current solution, expressed as a JSON string
 */
get solutionContent() {
  return JSON.stringify(this.solution);
}

/**
 * The solution validation status
 *
 * @returns {"passed" | "failed"}
 */
get clientResultStatus() {
  return this.canvas.valid ? 'passed' : 'failed';
}

_prepareSubmission() {
  return {
    solution: {
      content: this.solutionContent
    },
    client_result: {
      status: this.clientResultStatus
    }
  };
}

/**
 * @param {string} key
 * @param {any} value
 */
_config(key, value) {
  const current = this[key];
  console.debug("Setting config: ", [key, value])

  if (current === null) {
    this[key] = value;
  }
}

// ==============
// Event handling
// ==============

/**
 * Registers an event handler
 *
 * @param {string} event
 * @param {(...args: any) => void} callback
 */
register(event, callback) {
  const _event = this[event];
  this[event] = (...args) => {
    callback(...args);
    _event(...args);
  }
}

/**
 * Runs the given action if muzzle is ready,
 * queueing it otherwise
 * @param {() => void} callback
 */
run(callback) {
  if (this.isReady()) {
    callback();
  } else {
    this.register('onReady', callback);
  }
}

}

const Muzzle = new class extends MuzzleCanvas {

constructor() {
  super();
  this.aux = {};

  this.Shuffler = headbreaker.Shuffler;
}

/**
 * Creates a suplementary canvas at the element
 * of the given id
 *
 * @param {string} id
 * @returns {MuzzleCanvas}
 */
another(id) {
  const muzzle = new MuzzleCanvas(id);
  Muzzle.aux[id] = muzzle
  return muzzle;
}

}

window = Muzzle;