import { linreg, mix } from "@iosevka/util";
import * as SpiroJs from "spiro";

import { Vec2 } from "./point.mjs";
import { MonoKnot } from "./spiro-to-outline.mjs";

///////////////////////////////////////////////////////////////////////////////////////////////////

export class BiKnotCollector {
	constructor(contrast) {
		this.contrast = contrast; // stroke contrast
		this.defaultD1 = 0; // default LHS
		this.defaultD2 = 0; // default RHS sw
		this.lastKnot = null; // last knot in the processed items

		this.knots = []; // all the control items
		this.closed = false; // whether the shape is closed

		this.m_finished = false;
	}

	get controls() {
		throw new Error("Not implemented");
	}

	finish() {
		this.m_finished = true;
	}
	pushKnot(c) {
		if (this.m_finished) throw new Error("Cannot push knot after finish");

		let k;
		if (this.lastKnot) {
			k = new BiKnot(c.type, c.x, c.y, this.lastKnot.d1, this.lastKnot.d2);
		} else {
			k = new BiKnot(c.type, c.x, c.y, this.defaultD1, this.defaultD2);
		}

		this.knots.push(k);
		this.lastKnot = k;

		c.applyTo(this);
	}
	setWidth(l, r) {
		if (this.lastKnot) {
			this.lastKnot.d1 = l;
			this.lastKnot.d2 = r;
		} else {
			this.defaultD1 = l;
			this.defaultD2 = r;
		}
	}
	headsTo(direction) {
		if (this.lastKnot) {
			this.lastKnot.proposedNormal = direction;
		}
	}
	setImportant() {
		if (this.lastKnot) {
			this.lastKnot.unimportant = 0;
		}
	}
	setUnimportant() {
		if (this.lastKnot) {
			this.lastKnot.unimportant = 1;
		}
	}
	setContrast(c) {
		this.contrast = c;
	}

	getMonoKnots() {
		const a = [];
		for (const c of this.knots) {
			a.push(c.toMono());
		}
		return a;
	}
}

export class BiKnot {
	constructor(type, x, y, d1, d2) {
		this.type = type;
		this.x = x;
		this.y = y;
		this.d1 = d1;
		this.d2 = d2;
		this.proposedNormal = null;
		this.unimportant = 0;

		// Derived properties
		this.origTangent = null;
	}
	clone() {
		const k1 = new BiKnot(this.type, this.x, this.y, this.d1, this.d2);
		k1.origTangent = this.origTangent;
		k1.proposedNormal = this.proposedNormal;
		k1.unimportant = this.unimportant;
		return k1;
	}
	withGizmo(gizmo) {
		const tfZ = gizmo.applyXY(this.x, this.y);
		const k1 = new BiKnot(this.type, tfZ.x, tfZ.y, this.d1, this.d2);
		k1.origTangent = this.origTangent ? gizmo.applyOffset(this.origTangent) : null;
		k1.proposedNormal = this.proposedNormal ? gizmo.applyOffset(this.proposedNormal) : null;
		k1.unimportant = this.unimportant;
		return k1;
	}
	hash(h) {
		h.beginStruct("BiKnot");
		h.str(this.type);
		h.bool(this.unimportant);
		h.f64(this.x);
		h.f64(this.y);

		h.bool(this.d1 != null);
		if (this.d1 != null) h.f64(this.d1);
		h.bool(this.d2 != null);
		if (this.d2 != null) h.f64(this.d2);

		h.bool(this.proposedNormal != null);
		if (this.proposedNormal) {
			h.f64(this.proposedNormal.x);
			h.f64(this.proposedNormal.y);
		}
		h.endStruct();
	}

	toMono() {
		return new MonoKnot(this.type, this.unimportant, this.x, this.y);
	}
}

///////////////////////////////////////////////////////////////////////////////////////////////////

export class SpiroExpander {
	constructor(gizmo, contrast, closed, biKnots) {
		this.m_gizmo = gizmo;
		this.m_contrast = contrast;
		this.m_closed = closed;

		this.m_biKnotsU = Array.from(biKnots);
		this.m_biKnotsT = biKnots.map(k => k.withGizmo(gizmo));
	}
	initializeNormals() {
		const normalRectifier = new NormalRectifier(this.m_biKnotsT, this.m_gizmo);
		SpiroJs.spiroToArcsOnContext(this.m_biKnotsT, this.m_closed, normalRectifier);
	}
	iterateNormals() {
		const centerBone = this.getPass2Knots();
		const normalRectifier = new NormalRectifier(this.m_biKnotsT, this.m_gizmo);
		SpiroJs.spiroToArcsOnContext(centerBone, this.m_closed, normalRectifier);
		return normalRectifier.totalDelta / normalRectifier.nKnotsProcessed;
	}
	getPass2Knots() {
		const expanded = this.expand(this.m_contrast);
		const middles = [];
		for (let j = 0; j < this.m_biKnotsT.length; j++) {
			const lhs = expanded.lhs[j];
			const rhs = expanded.rhs[j];
			middles[j] = new MonoKnot(
				this.m_biKnotsT[j].type,
				this.m_biKnotsT[j].unimportant,
				mix(lhs.x, rhs.x, 0.5),
				mix(lhs.y, rhs.y, 0.5),
			);
		}
		return middles;
	}
	expand() {
		const lhsT = [], // transformed LHS
			rhsT = [], // transformed RHS
			lhsU = [], // untransformed LHS
			rhsU = []; // untransformed RHS

		for (let j = 0; j < this.m_biKnotsT.length; j++) {
			const bk = this.m_biKnotsT[j];
			lhsT[j] = new MonoKnot(bk.type, bk.unimportant, 0, 0);
			rhsT[j] = new MonoKnot(bk.type, bk.unimportant, 0, 0);
			lhsU[j] = new MonoKnot(bk.type, bk.unimportant, 0, 0);
			rhsU[j] = new MonoKnot(bk.type, bk.unimportant, 0, 0);
		}

		for (let j = 0; j < this.m_biKnotsT.length; j++) {
			const knotT = this.m_biKnotsT[j];
			if (knotT.unimportant) continue;
			let dx, dy;
			if (knotT.proposedNormal) {
				dx = knotT.proposedNormal.x;
				dy = knotT.proposedNormal.y;
			} else {
				dx = normalX(knotT.origTangent, this.m_contrast);
				dy = normalY(knotT.origTangent, this.m_contrast);
			}
			lhsT[j].x = knotT.x + knotT.d1 * dx;
			lhsT[j].y = knotT.y + knotT.d1 * dy;
			rhsT[j].x = knotT.x - knotT.d2 * dx;
			rhsT[j].y = knotT.y - knotT.d2 * dy;

			this.m_gizmo.unapplyToSink(lhsT[j], lhsU[j]);
			this.m_gizmo.unapplyToSink(rhsT[j], rhsU[j]);
		}

		this.interpolateUnimportantKnots(lhsT, rhsT, lhsU, rhsU);
		return { lhs: lhsT, rhs: rhsT, lhsUntransformed: lhsU, rhsUntransformed: rhsU };
	}
	interpolateUnimportantKnots(lhsT, rhsT, lhsU, rhsU) {
		let firstImportantIdx = -1;
		let lastImportantIdx = -1;

		for (let j = 0; j < this.m_biKnotsU.length; j++) {
			// If the current knot is unimportant, skip it
			if (this.m_biKnotsU[j].unimportant) continue;

			// If we've scanned an important knot before, interpolate the unimportant knots between
			if (lastImportantIdx !== -1) {
				this.interpolateUnimportantKnotsRg(lhsT, rhsT, lhsU, rhsU, lastImportantIdx, j);
			}

			if (firstImportantIdx === -1) firstImportantIdx = j;
			lastImportantIdx = j;
		}

		// Handle the last important ... first important wraparound
		if (firstImportantIdx !== -1 && lastImportantIdx !== -1) {
			this.interpolateUnimportantKnotsRg(
				lhsT,
				rhsT,
				lhsU,
				rhsU,
				lastImportantIdx,
				firstImportantIdx,
			);
		}
	}

	interpolateUnimportantKnotsRg(lhsT, rhsT, lhsU, rhsU, jBefore, jAfter) {
		const count = jAfter > jBefore ? jAfter - jBefore : lhsT.length - jBefore + jAfter;
		for (let offset = 1; offset < count; offset++) {
			const j = (jBefore + offset) % lhsT.length;

			const knotUBefore = this.m_biKnotsU[jBefore],
				knotU = this.m_biKnotsU[j],
				knotUAfter = this.m_biKnotsU[jAfter],
				lhsUBefore = lhsU[jBefore],
				lhsUAfter = lhsU[jAfter],
				rhsUBefore = rhsU[jBefore],
				rhsUAfter = rhsU[jAfter];

			lhsU[j].x = linreg(knotUBefore.x, lhsUBefore.x, knotUAfter.x, lhsUAfter.x, knotU.x);
			lhsU[j].y = linreg(knotUBefore.y, lhsUBefore.y, knotUAfter.y, lhsUAfter.y, knotU.y);
			rhsU[j].x = linreg(knotUBefore.x, rhsUBefore.x, knotUAfter.x, rhsUAfter.x, knotU.x);
			rhsU[j].y = linreg(knotUBefore.y, rhsUBefore.y, knotUAfter.y, rhsUAfter.y, knotU.y);

			this.m_gizmo.applyToSink(lhsU[j], lhsT[j]);
			this.m_gizmo.applyToSink(rhsU[j], rhsT[j]);
		}
	}
}
class NormalRectifier {
	constructor(stage1ControlKnots, gizmo) {
		this.m_gizmo = gizmo;
		this.m_biKnots = stage1ControlKnots;

		this.nKnotsProcessed = 0;
		this.totalDelta = 0;
	}
	beginShape() {}
	endShape() {}
	moveTo(_x, _y) {
		this.nKnotsProcessed += 1;
	}
	arcTo(arc, _x, _y) {
		if (this.nKnotsProcessed === 1) {
			const d = new Vec2(arc.deriveX0, arc.deriveY0);
			if (isTangentValid(d)) {
				this.updateKnotTangent(this.m_biKnots[0], d);
			} else {
				throw new Error("NaN angle detected.");
			}
		}
		if (this.m_biKnots[this.nKnotsProcessed]) {
			const d = new Vec2(arc.deriveX1, arc.deriveY1);
			if (isTangentValid(d)) {
				this.updateKnotTangent(this.m_biKnots[this.nKnotsProcessed], d);
			} else {
				throw new Error("NaN angle detected.");
			}
		}
		this.nKnotsProcessed += 1;
	}

	updateKnotTangent(knot, d) {
		if (isTangentValid(knot.origTangent)) {
			this.totalDelta +=
				(d.x - knot.origTangent.x) * (d.x - knot.origTangent.x) +
				(d.y - knot.origTangent.y) * (d.y - knot.origTangent.y);
		} else {
			this.totalDelta += 4;
		}
		knot.origTangent = d;
	}
}

function isTangentValid(d) {
	return d && Number.isFinite(d.x) && Number.isFinite(d.y);
}

function normalX(tangent, contrast) {
	return contrast * (-tangent.y / Math.hypot(tangent.x, tangent.y));
}
function normalY(tangent) {
	return tangent.x / Math.hypot(tangent.x, tangent.y);
}

function _cyNth(a, j) {
	return a[j % a.length];
}
