/* SPDX-FileCopyrightText: 2024 Google LLC */
/* SPDX-License-Identifier: Apache-2.0 */

#include "ecompass.h"

#include "util/trig.h"
#include "kernel/pbl_malloc.h"
#include "system/logging.h"
#include "util/math.h"

#include <stdint.h>
#include <stdio.h>

#define N_SAMPS    4 // four points define a unique sphere
#define N_AXIS     3
static int16_t s_samples[N_SAMPS][N_AXIS];

//
// Two basic equations of a sphere:
//  a) (x - x0)^2 + (y - y0)^2 + (z - z0)^2 = r0^2
//     (x^2 - 2*x*x0 + x0^2) + (y^2 - 2*y*x0 + y0^2) + (z^2 - 2*z*z0 + z0^2) = r^2
//     -2*x*x0 - 2*y*y0 - 2*z*z0 + (x^2 + y^2 + z^2) + r0^2 = 0
//  b) A*(x^2 + y^2 + x^2) + B*x + C*y + D*z + E = 0
//
// Ideally, we would be able to build a large cloud of points and apply a least
// squares or ellipsoid fit to that dataset. However, this quickly becomes
// expensive (from a code space perspective). Therefore, we focus on collecting
// four 'good' data points and fitting those point to a sphere
//
// The sphere fit entails solving a linear system of the form Ax = B
//
// |  (x^2 + y^2 + z^2)      x     y     z     1 |   | A |    | 0 |
// | (x0^2 + y0^2 + z0^2)    x0    y0    z0    1 |   | B |    | 0 |
// | (x1^2 + y1^2 + z1^2)    x1    y1    z1    1 | * | C | =  | 0 |
// | (x2^2 + y2^2 + z2^2)    x2    y2    z2    1 |   | D |    | 0 |
// | (x3^2 + y3^2 + z3^2)    x3    y3    z3    1 |   | E |    | 0 |
//
// to solve we want to find where det(A) = 0
//
// Using Laplace's formula for determinant expansion we can break this into a
// system of 4x4 determinants. Expanding along row 0 and using Cij to represent
// the cofactor which removes row i and column j:
//
// (x^2 + y^2 + z^2) * C_0,0 + x * C_0,1 + y * C_0,2 + z * C_0,3 + C_0,4 = 0
//
// This solution can be re-written in a form similar to eq a) above as
//
// x * (C_0,1 / C_0,0) + y * (C_0,2 / C_0,0) + z * (C_0,3 / C_0,0) +
//     (C_0,4 / C_0,0) + (x^2 + y^2 + z^2) = 0
//
// This gives us our hard iron correction estimates (i.e location of the sphere
// origin, (xo, yo, zo)) as:
//
//  (C_0,1 / C_0,0) = -2*x0 ==> x0 = (C_0,1 / C_0,0) / -2
//  (C_0,2 / C_0,0) = -2*y0 ==> y0 = (C_0,2 / C_0,0) / -2
//  (C_0,3 / C_0,0) = -2*z0 ==> z0 = (C_0,3 / C_0,0) / -2
//

static int32_t sphere_determinant4x4(int32_t **m, int32_t down_samp) {
  // assume x, y, z value fits within 14 bits & m[x][3] == 1
  // when m[x][0] == x^2 + y^2 + z^2, this val < 30 bits
  // so m[x][0] * temp is capped at 44bits leaving plenty of space
  // within 64 bits

  int64_t res = 0;
  // computes the determinant by decomposing the computation into 4 3x3 minor
  // determinants formed by removal of row 'skip_row' & the third column
  for (int skip_row = 0; skip_row < 4; skip_row++) {
    int r0 = (skip_row != 0) ? 0 : 1;
    int r1 = (skip_row < 2) ? 2 : 1;
    int r2 = (skip_row < 3) ? 3 : 2;

    int64_t temp = (m[r1][1] * m[r2][2]) - (m[r1][2] * m[r2][1]);
    int64_t det = temp * (int64_t)m[r0][0];

    temp = (m[r1][0] * m[r2][2]) - (m[r1][2] * m[r2][0]);
    det -= temp * (int64_t)m[r0][1];

    temp = (m[r1][0] * m[r2][1]) - (m[r1][1] * m[r2][0]);
    det += temp * (int64_t)m[r0][2];

    if ((skip_row % 2) == 0) {
      res += det;
    } else {
      res -=det;
    }
  }

  return (res / down_samp);
}

static bool sphere_fit(int16_t *solution) {
  bool rv = false;

  int32_t **matrix = kernel_malloc(sizeof(int32_t *) * N_SAMPS);
  for (int i = 0; i < N_SAMPS; i++) {
    matrix[i] = kernel_malloc(sizeof(int32_t) * N_AXIS);
  }

  // determine average value of x, y, & z coordinates
  // and shift by this factor to prevent overflow.
  int32_t shift_factor[N_AXIS] = { 0 };
  for (int j = 0; j < N_AXIS; j++) {
    for (int i = 0; i < N_SAMPS; i++) {
      shift_factor[j] += s_samples[i][j];
    }
    shift_factor[j] /= N_SAMPS;
  }

  int16_t raw_data[N_SAMPS][N_AXIS];
  for (int i = 0; i < N_SAMPS; i++) {
    for (int j = 0; j < 3; j++) {
      raw_data[i][j] = (s_samples[i][j] - shift_factor[j]);
      // TODO: stop here if we have a chance of overflowing
    }
  }

  int32_t r[N_SAMPS];
  for (int i = 0; i < N_SAMPS; i++) {
    r[i] = raw_data[i][0] * raw_data[i][0] +
        raw_data[i][1] * raw_data[i][1] +
        raw_data[i][2] * raw_data[i][2];
  }

  // We now find the origin by solving the linear equation discussed above

  for (int i = 0; i < N_SAMPS; i++) {
    matrix[i][0] = raw_data[i][0];
    matrix[i][1] = raw_data[i][1];
    matrix[i][2] = raw_data[i][2];
  }
  int32_t c00 = sphere_determinant4x4(matrix, 1);
  if (c00 == 0) {
    goto cleanup;
  }

  // compute cofactor01 to solve for x0
  for (int i = 0; i < N_SAMPS; i++) {
    matrix[i][0] = r[i]; // m[i][1] = y, m[i][2] = z
  }
  solution[0] = shift_factor[0] + sphere_determinant4x4(matrix, c00 * 2);

  // compute cofactor02 to solve for y0
  for (int i = 0; i < N_SAMPS; i++) {
    matrix[i][1] = raw_data[i][0]; // m[i][0] = r^2, m[i][2] = z
  }
  solution[1] = shift_factor[1] - sphere_determinant4x4(matrix, c00 * 2);

  // compute cofactor03 to solve for z0
  for (int i = 0; i < N_SAMPS; i++) {
    matrix[i][2] = raw_data[i][1]; // m[i][0] = r^2, m[i][1] = x
  }
  solution[2] = shift_factor[2] + sphere_determinant4x4(matrix, c00 * 2);

  rv = true;
cleanup:
  for (int i = 0; i < N_SAMPS; i++) {
    kernel_free(matrix[i]);
  }
  kernel_free(matrix);
  return (rv);
}

// Earths magnetic field intensity ranges from 25uT (near the equator) to 65uT
// (near the earths poles). A majority of Europe, North America, & Asia ranges
// between 35-50uT.
//
// Assuming magnetometer readings are predominantly influenced by hard iron
// distortions, we seek to find four points and fit them to a sphere in
// order to determine the offset we need to shift the raw data by to correct
// for hard iron distortions.
//
// For points A, B, C, D and a distance threshold, t, we select four points by
// satisfying the following:
//   distance_ptA_to_ptB > t
//   distance_lineAB_to_ptC > t
//   distance_planeABC_to_ptD > t.
//
// Conceptually, it makes sense that the farther points are from one another (>
// t), the less that errors due to noise, fixed point mathematics, & motion
// render bad solutions. (empirically, this seems to be the behavior as
// well). However, the greater the the threshold, the more orientations one
// must put their watch through in order to get solution sets
//
// For now, select a distance metric that should work out of the box for a
// majority of the middle of the world. However, if no solution sets are found
// after 45s, fall back to a less aggressive threshold that will work
// anywhere in the world.

#define THRESH_MAX 370 /* 37 uT */
#define THRESH_MIN 220 /* 22 uT */

// Note: All of the following helper routines operate on the s_samples array
// defined above and populated by add_raw_mag_sample

static bool pt_to_pt_dist_under_thresh(int idx_a, int idx_b, int32_t thresh) {
  int32_t v_ab[3] = {
    s_samples[idx_b][0] - s_samples[idx_a][0],
    s_samples[idx_b][1] - s_samples[idx_a][1],
    s_samples[idx_b][2] - s_samples[idx_a][2]
  };

  int32_t dist_sq = v_ab[0] * v_ab[0] + v_ab[1] * v_ab[1] +
      v_ab[2] * v_ab[2];

  return (dist_sq > (thresh * thresh));
}

static bool pt_to_line_dist_under_thresh(int idx_line_a, int idx_line_b,
    int idx_pt, int32_t thresh) {

  int32_t s[3] = {
    s_samples[idx_line_b][0] - s_samples[idx_line_a][0],
    s_samples[idx_line_b][1] - s_samples[idx_line_a][1],
    s_samples[idx_line_b][2] - s_samples[idx_line_a][2]
  };

  int32_t m1[3] = {
    s_samples[idx_line_a][0] - s_samples[idx_pt][0],
    s_samples[idx_line_a][1] - s_samples[idx_pt][1],
    s_samples[idx_line_a][2] - s_samples[idx_pt][2]
  };

  int32_t m1xs[3] = {
    m1[1] * s[2] - m1[2] *s[1],
    -(m1[0] * s[2] - m1[2] * s[0]),
    m1[0]*s[1] - m1[1] * s[0]
  };

  int64_t dist_sq = ((int64_t)m1xs[0]*m1xs[0] + (int64_t)m1xs[1]*m1xs[1] +
      (int64_t)m1xs[2]*m1xs[2]) / (s[0]*s[0] + s[1]*s[1] + s[2]*s[2]);

  return (dist_sq > (thresh * thresh));
}

static bool pt_to_plane_dist_under_thresh(int idx_pln_a, int idx_pln_b,
    int idx_pln_c, int idx_pt, int32_t thresh) {

  int32_t v_ab[3];
  int32_t v_ac[3];
  for (int i = 0; i < 3; i++) {
      v_ab[i] = s_samples[idx_pln_b][i] - s_samples[idx_pln_a][i];
      v_ac[i] = s_samples[idx_pln_c][i] - s_samples[idx_pln_a][i];
  }

  int64_t plane_eq[4];
  plane_eq[0] = v_ab[1] * v_ac[2] - v_ab[2] * v_ac[1];
  plane_eq[1] = -v_ab[0] * v_ac[2] + v_ab[2] * v_ac[0];
  plane_eq[2] = v_ab[0] * v_ac[1] - v_ab[1] * v_ac[0];

  // solve for d
  plane_eq[3] = -(plane_eq[0] * s_samples[0][0] +
      plane_eq[1] * s_samples[0][1] + plane_eq[2] * s_samples[0][2]);

  // Distance^2 = (a * xo + b * yo + c * zo + d) / (a^2 + b^2 + c^2)
  int64_t distance = ABS((plane_eq[0] * s_samples[idx_pt][0] +
      plane_eq[1] * s_samples[idx_pt][1] + plane_eq[2] * s_samples[idx_pt][2] +
      plane_eq[3]));
  int32_t r = integer_sqrt(plane_eq[0] * plane_eq[0] + plane_eq[1] * plane_eq[1]
      + plane_eq[2] * plane_eq[2]);


  return ((distance / r) > thresh);
}

static int min_max_diff(int16_t *vals, int n_vals) {
  int min = vals[0];
  int max = vals[0];

  for (int i = 0; i < n_vals; i++) {
    if (vals[i] > max) {
      max = vals[i];
    } else if (vals[i] < min) {
      min = vals[i];
    }
  }
  return (max - min);
}

#define N_COMP_SAMPS 3
static MagCalStatus check_correction_value(int16_t *solution,
    int16_t *saved_solution) {
  const int max_delta_thresh = 50;

  // stash several of the most recent calibration results. The idea here is if
  // we get multiple readings in a row close to one another than we have locked
  // onto a good set of solutions
  static int16_t calib_idx = 0;
  static int16_t calib_val[N_AXIS][N_COMP_SAMPS];
  calib_val[0][calib_idx % 3] = solution[0];
  calib_val[1][calib_idx % 3] = solution[1];
  calib_val[2][calib_idx % 3] = solution[2];
  calib_idx++;

  static int saved_sample_match = 0;
  int x_delta, y_delta, z_delta;

  // is the new solution close to what we already have saved?
  if (saved_solution != NULL) {
    x_delta = ABS(saved_solution[0] - solution[0]);
    y_delta = ABS(saved_solution[1] - solution[1]);
    z_delta = ABS(saved_solution[2] - solution[2]);
    if ((x_delta < max_delta_thresh) && (y_delta < max_delta_thresh) &&
        (z_delta < max_delta_thresh)) {
      saved_sample_match++;
      if (saved_sample_match == 3) {
        saved_sample_match = 0;
        calib_idx = 0;
        PBL_LOG(LOG_LEVEL_INFO, "Persisting previous values!");
        return (MagCalStatusSavedSampleMatch); // locked
      }
    }
  }

  // do we have several solutions in a row that are close to one another
  if (calib_idx >= N_COMP_SAMPS) {
    x_delta = min_max_diff(calib_val[0], 3);
    y_delta = min_max_diff(calib_val[1], 3);
    z_delta = min_max_diff(calib_val[2], 3);
    if ((x_delta < max_delta_thresh) && (y_delta < max_delta_thresh) &&
        (z_delta < max_delta_thresh)) {
      int corrs[N_AXIS] = { 0 };
      for (int i = 0; i < N_AXIS; i++) {
        for (int j = 0; j < N_COMP_SAMPS; j++) {
          corrs[i] += calib_val[i][j];
        }
        solution[i] = corrs[i] / N_COMP_SAMPS;
      }
      calib_idx = 0;
      return (MagCalStatusNewLockedSolutionAvail);
    }
  }

  return (MagCalStatusNewSolutionAvail);
}

static int s_sample_idx = 0;
static int s_no_fit_strikes = 0;
static int s_samples_collected_for_fit = 0;

void ecomp_corr_reset(void) {
    s_no_fit_strikes = 0;
    s_samples_collected_for_fit = 0;
    s_sample_idx = 0;
}

MagCalStatus ecomp_corr_add_raw_mag_sample(int16_t *sample,
    int16_t *saved_corr, int16_t *solution) {
  static int thresh = THRESH_MAX;
  s_samples_collected_for_fit++;

  // if we haven't gotten good samples points in 15s @ 20Hz (60s @ 5Hz)
  if (s_samples_collected_for_fit > 300) {
    if (s_sample_idx >= 2) { // there was some kind of motion
      s_no_fit_strikes++;
    }
    if (s_no_fit_strikes == 2) {
      PBL_LOG(LOG_LEVEL_INFO, "Lowering magnetometer distance threshold");
      thresh = THRESH_MIN;
    }

    s_samples_collected_for_fit = 0;
    s_sample_idx = 0;
  }

  s_samples[s_sample_idx][0] = sample[0];
  s_samples[s_sample_idx][1] = sample[1];
  s_samples[s_sample_idx][2] = sample[2];

  if (s_sample_idx == 1) {
    if (!pt_to_pt_dist_under_thresh(0, 1, thresh)) {
      return (MagCalStatusNoSolution);
    }
  } else if (s_sample_idx == 2) {
    if (!pt_to_line_dist_under_thresh(0, 1, 2, thresh)) {
      return (MagCalStatusNoSolution);
    }
  } else if (s_sample_idx == 3) {
    if (!pt_to_plane_dist_under_thresh(0, 1, 2, 3, thresh)) {
      return (MagCalStatusNoSolution);
    }
  }

  // the sample has passed its distance threshold check so add it
  PBL_LOG(LOG_LEVEL_DEBUG, "---> [%d] Adding %d %d %d \n",
      s_sample_idx, (int)sample[0], (int)sample[1], (int)sample[2]);
  s_sample_idx++;

  if (s_sample_idx != 4) {
    return (MagCalStatusNoSolution); // we need 4 pts before we can do a fit
  }

  // reset vars for next potential sphere fit
  s_samples_collected_for_fit = 0;
  s_sample_idx = 0;

  if (!sphere_fit(&solution[0])) {
    return (MagCalStatusNoSolution);
  }

  return (check_correction_value(solution, saved_corr));
}
