// control coords are not relative to start or end
import { assertExists } from './assert.js';
import { CURVETIME_EPSILON, isZero } from './math.js';
import { Bound, type IVec, PointLocation, Vec } from './model/index.js';

export type BezierCurveParameters = [
  start: IVec,
  control1: IVec,
  control2: IVec,
  end: IVec,
];

function evaluate(
  v: BezierCurveParameters,
  t: number,
  type: number,
  normalized: boolean
): IVec | null {
  if (t == null || t < 0 || t > 1) return null;
  const x0 = v[0][0],
    y0 = v[0][1],
    x3 = v[3][0],
    y3 = v[3][1];
  let x1 = v[1][0],
    y1 = v[1][1],
    x2 = v[2][0],
    y2 = v[2][1];

  if (isZero(x1 - x0) && isZero(y1 - y0)) {
    x1 = x0;
    y1 = y0;
  }
  if (isZero(x2 - x3) && isZero(y2 - y3)) {
    x2 = x3;
    y2 = y3;
  }
  // Calculate the polynomial coefficients.
  const cx = 3 * (x1 - x0),
    bx = 3 * (x2 - x1) - cx,
    ax = x3 - x0 - cx - bx,
    cy = 3 * (y1 - y0),
    by = 3 * (y2 - y1) - cy,
    ay = y3 - y0 - cy - by;
  let x, y;
  if (type === 0) {
    // type === 0: getPoint()
    x = t === 0 ? x0 : t === 1 ? x3 : ((ax * t + bx) * t + cx) * t + x0;
    y = t === 0 ? y0 : t === 1 ? y3 : ((ay * t + by) * t + cy) * t + y0;
  } else {
    // type === 1: getTangent()
    // type === 2: getNormal()
    // type === 3: getCurvature()
    const tMin = CURVETIME_EPSILON,
      tMax = 1 - tMin;
    if (t < tMin) {
      x = cx;
      y = cy;
    } else if (t > tMax) {
      x = 3 * (x3 - x2);
      y = 3 * (y3 - y2);
    } else {
      x = (3 * ax * t + 2 * bx) * t + cx;
      y = (3 * ay * t + 2 * by) * t + cy;
    }
    if (normalized) {
      if (x === 0 && y === 0 && (t < tMin || t > tMax)) {
        x = x2 - x1;
        y = y2 - y1;
      }
      const len = Math.sqrt(x * x + y * y);
      if (len) {
        x /= len;
        y /= len;
      }
    }
    if (type === 3) {
      const x2 = 6 * ax * t + 2 * bx,
        y2 = 6 * ay * t + 2 * by,
        d = Math.pow(x * x + y * y, 3 / 2);
      x = d !== 0 ? (x * y2 - y * x2) / d : 0;
      y = 0;
    }
  }
  return type === 2 ? [y, -x] : [x, y];
}

export function getBezierPoint(values: BezierCurveParameters, t: number) {
  return evaluate(values, t, 0, false);
}

export function getBezierTangent(values: BezierCurveParameters, t: number) {
  return evaluate(values, t, 1, true);
}

export function getBezierNormal(values: BezierCurveParameters, t: number) {
  return evaluate(values, t, 2, true);
}

export function getBezierCurvature(values: BezierCurveParameters, t: number) {
  return evaluate(values, t, 3, false)?.[0];
}

export function getBezierNearestTime(
  values: BezierCurveParameters,
  point: IVec
) {
  const count = 100;
  let minDist = Infinity,
    minT = 0;

  function refine(t: number) {
    if (t >= 0 && t <= 1) {
      const tmpPoint = getBezierPoint(values, t);
      assertExists(tmpPoint);
      const dist = Vec.dist2(point, tmpPoint);
      if (dist < minDist) {
        minDist = dist;
        minT = t;
        return true;
      }
    }
    return false;
  }

  for (let i = 0; i <= count; i++) refine(i / count);

  let step = 1 / (count * 2);
  while (step > CURVETIME_EPSILON) {
    if (!refine(minT - step) && !refine(minT + step)) step /= 2;
  }
  return minT;
}

export function getBezierNearestPoint(
  values: BezierCurveParameters,
  point: IVec
) {
  const t = getBezierNearestTime(values, point);
  const pointOnCurve = getBezierPoint(values, t);
  assertExists(pointOnCurve);
  return pointOnCurve;
}

export function getBezierParameters(
  points: PointLocation[]
): BezierCurveParameters {
  // Fallback for degenerate Bezier curve (all points are at the same position)
  if (points.length === 1) {
    const point = points[0];
    return [point, point, point, point];
  }

  return [points[0], points[0].absOut, points[1].absIn, points[1]];
}

// https://stackoverflow.com/questions/2587751/an-algorithm-to-find-bounding-box-of-closed-bezier-curves
export function getBezierCurveBoundingBox(values: BezierCurveParameters) {
  const [start, controlPoint1, controlPoint2, end] = values;

  const [x0, y0] = start;
  const [x1, y1] = controlPoint1;
  const [x2, y2] = controlPoint2;
  const [x3, y3] = end;

  const points = []; // local extremes
  const tvalues = []; // t values of local extremes
  const bounds: [number[], number[]] = [[], []];

  let a;
  let b;
  let c;
  let t;
  let t1;
  let t2;
  let b2ac;
  let sqrtb2ac;

  for (let i = 0; i < 2; i += 1) {
    if (i === 0) {
      b = 6 * x0 - 12 * x1 + 6 * x2;
      a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3;
      c = 3 * x1 - 3 * x0;
    } else {
      b = 6 * y0 - 12 * y1 + 6 * y2;
      a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3;
      c = 3 * y1 - 3 * y0;
    }

    if (Math.abs(a) < 1e-12) {
      if (Math.abs(b) < 1e-12) {
        continue;
      }

      t = -c / b;
      if (t > 0 && t < 1) tvalues.push(t);

      continue;
    }

    b2ac = b * b - 4 * c * a;
    sqrtb2ac = Math.sqrt(b2ac);

    if (b2ac < 0) continue;

    t1 = (-b + sqrtb2ac) / (2 * a);
    if (t1 > 0 && t1 < 1) tvalues.push(t1);

    t2 = (-b - sqrtb2ac) / (2 * a);
    if (t2 > 0 && t2 < 1) tvalues.push(t2);
  }

  let x;
  let y;
  let mt;
  let j = tvalues.length;
  const jlen = j;

  while (j) {
    j -= 1;
    t = tvalues[j];
    mt = 1 - t;

    x =
      mt * mt * mt * x0 +
      3 * mt * mt * t * x1 +
      3 * mt * t * t * x2 +
      t * t * t * x3;
    bounds[0][j] = x;

    y =
      mt * mt * mt * y0 +
      3 * mt * mt * t * y1 +
      3 * mt * t * t * y2 +
      t * t * t * y3;

    bounds[1][j] = y;
    points[j] = { X: x, Y: y };
  }

  tvalues[jlen] = 0;
  tvalues[jlen + 1] = 1;

  points[jlen] = { X: x0, Y: y0 };
  points[jlen + 1] = { X: x3, Y: y3 };

  bounds[0][jlen] = x0;
  bounds[1][jlen] = y0;

  bounds[0][jlen + 1] = x3;
  bounds[1][jlen + 1] = y3;

  tvalues.length = jlen + 2;
  bounds[0].length = jlen + 2;
  bounds[1].length = jlen + 2;
  points.length = jlen + 2;

  const left = Math.min.apply(null, bounds[0]);
  const top = Math.min.apply(null, bounds[1]);
  const right = Math.max.apply(null, bounds[0]);
  const bottom = Math.max.apply(null, bounds[1]);

  return new Bound(left, top, right - left, bottom - top);
}

// https://pomax.github.io/bezierjs/#intersect-line
// MIT Licence

// cube root function yielding real roots
function crt(v: number) {
  return v < 0 ? -Math.pow(-v, 1 / 3) : Math.pow(v, 1 / 3);
}

function align(points: BezierCurveParameters, [start, end]: IVec[]) {
  const tx = start[0],
    ty = start[1],
    a = -Math.atan2(end[1] - ty, end[0] - tx),
    d = function ([x, y]: IVec) {
      return [
        (x - tx) * Math.cos(a) - (y - ty) * Math.sin(a),
        (x - tx) * Math.sin(a) + (y - ty) * Math.cos(a),
      ];
    };
  return points.map(d);
}

function between(v: number, min: number, max: number) {
  return (
    (min <= v && v <= max) || approximately(v, min) || approximately(v, max)
  );
}

function approximately(
  a: number,
  b: number,
  precision?: number,
  epsilon = 0.000001
) {
  return Math.abs(a - b) <= (precision || epsilon);
}

function roots(points: BezierCurveParameters, line: IVec[]) {
  const order = points.length - 1;
  const aligned = align(points, line);
  const reduce = function (t: number) {
    return 0 <= t && t <= 1;
  };

  if (order === 2) {
    const a = aligned[0][1],
      b = aligned[1][1],
      c = aligned[2][1],
      d = a - 2 * b + c;
    if (d !== 0) {
      const m1 = -Math.sqrt(b * b - a * c),
        m2 = -a + b,
        v1 = -(m1 + m2) / d,
        v2 = -(-m1 + m2) / d;
      return [v1, v2].filter(reduce);
    } else if (b !== c && d === 0) {
      return [(2 * b - c) / (2 * b - 2 * c)].filter(reduce);
    }
    return [];
  }

  // see http://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm
  const pa = aligned[0][1],
    pb = aligned[1][1],
    pc = aligned[2][1],
    pd = aligned[3][1];

  const d = -pa + 3 * pb - 3 * pc + pd;
  let a = 3 * pa - 6 * pb + 3 * pc,
    b = -3 * pa + 3 * pb,
    c = pa;

  if (approximately(d, 0)) {
    // this is not a cubic curve.
    if (approximately(a, 0)) {
      // in fact, this is not a quadratic curve either.
      if (approximately(b, 0)) {
        // in fact in fact, there are no solutions.
        return [];
      }
      // linear solution:
      return [-c / b].filter(reduce);
    }
    // quadratic solution:
    const q = Math.sqrt(b * b - 4 * a * c),
      a2 = 2 * a;
    return [(q - b) / a2, (-b - q) / a2].filter(reduce);
  }

  // at this point, we know we need a cubic solution:

  a /= d;
  b /= d;
  c /= d;

  const p = (3 * b - a * a) / 3,
    p3 = p / 3,
    q = (2 * a * a * a - 9 * a * b + 27 * c) / 27,
    q2 = q / 2,
    discriminant = q2 * q2 + p3 * p3 * p3;

  let u1, v1, x1, x2, x3;
  if (discriminant < 0) {
    const mp3 = -p / 3,
      mp33 = mp3 * mp3 * mp3,
      r = Math.sqrt(mp33),
      t = -q / (2 * r),
      cosphi = t < -1 ? -1 : t > 1 ? 1 : t,
      phi = Math.acos(cosphi),
      crtr = crt(r),
      t1 = 2 * crtr;
    x1 = t1 * Math.cos(phi / 3) - a / 3;
    x2 = t1 * Math.cos((phi + Math.PI * 2) / 3) - a / 3;
    x3 = t1 * Math.cos((phi + 2 * Math.PI * 2) / 3) - a / 3;
    return [x1, x2, x3].filter(reduce);
  } else if (discriminant === 0) {
    u1 = q2 < 0 ? crt(-q2) : -crt(q2);
    x1 = 2 * u1 - a / 3;
    x2 = -u1 - a / 3;
    return [x1, x2].filter(reduce);
  } else {
    const sd = Math.sqrt(discriminant);
    u1 = crt(-q2 + sd);
    v1 = crt(q2 + sd);
    return [u1 - v1 - a / 3].filter(reduce);
  }
}

export function curveIntersects(path: PointLocation[], line: [IVec, IVec]) {
  const { minX, maxX, minY, maxY } = Bound.fromPoints(line);
  const points = getBezierParameters(path);
  const intersectedPoints = roots(points, line)
    .map(t => getBezierPoint(points, t))
    .filter(point =>
      point
        ? between(point[0], minX, maxX) && between(point[1], minY, maxY)
        : false
    )
    .map(point => new PointLocation(point!));
  return intersectedPoints.length > 0 ? intersectedPoints : null;
}
