// ---- Math and Utilities ----
// Vector Math
const vectorDot = (u, v) => u.map((x, i) => x * v[i]).reduce((x, y) => x + y);
const unitVector = v => {
  const mag = Math.hypot(...v);
  return v.map(c => c / mag);
};

// Angle Math
const rad2deg = 180 / Math.PI;
const twoPi = 2 * Math.PI;

// Color Conversion
const hex2rgb = hex => {
  hex = hex.replace("#", "");
  const red = hex.substr(0, 2);
  const grn = hex.substr(2, 2);
  const blu = hex.substr(4, 2);
  return [red, grn, blu].map(c => parseInt(c, 16));
};

// calculated from analyze.py
RGB_TO_LMS = [
  [0.4121965, 0.53627432, 0.05143268],
  [0.2119195, 0.68071831, 0.10738379],
  [0.08834911, 0.28185414, 0.63018663],
];
LMS_TO_OKLAB = [
  [0.2104542553, 0.793617785, -0.0040720468],
  [1.9779984951, -2.428592205, 0.4505937099],
  [0.0259040371, 0.7827717662, -0.808675766],
];
const hex2oklab = hex => {
  const lrgb = hex2rgb(hex).map(c => {
    const v = c / 255;
    return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
  });
  const lms = RGB_TO_LMS.map(row => Math.cbrt(vectorDot(row, lrgb)));
  return LMS_TO_OKLAB.map(row => vectorDot(row, lms));
};

// Misc
const clamp = (min, value, max) => Math.min(Math.max(value, min), max);
const productLift =
  (...factors) =>
  (...args) =>
    factors
      .filter(fn => !!fn)
      .map(fn => fn(...args))
      .reduce((x, y) => x * y, 1);

// Pre-computation
const getColorData = hex => {
  const lab = hex2oklab(hex);
  return {
    hex,
    vector: lab,
    unit: unitVector(lab),
    chroma: Math.hypot(lab[1], lab[2]),
    hue: (Math.atan2(lab[2], lab[1]) + twoPi) % twoPi,
  };
};

const pokemonData = database.map(({ total, clusters, ...rest }) => ({
  total: {
    ...total,
    unitCentroid: unitVector(total.centroid),
    lightness: 100 * total.centroid[0],
    abar: total.centroid[1],
    bbar: total.centroid[2],
    proportion: null,
    beta: 1,
    inverseSize: 1 / total.size,
  },
  clusters: clusters.map(c => ({
    ...c,
    unitCentroid: unitVector(c.centroid),
    lightness: 100 * c.centroid[0],
    abar: c.centroid[1],
    bbar: c.centroid[2],
    proportion: ((100 * c.size) / total.size).toFixed(2),
    beta: Math.sqrt((c.chroma * c.size) / total.size),
    inverseSize: 1 / c.size,
  })),
  ...rest,
}));
const pokemonLookup = new Fuse(pokemonData, { keys: ["name"] });

const calcScores = (data, target) => {
  const { centroid, unitCentroid, tilt, variance, chroma, hue } = data;
  const delta = Math.hypot(...centroid.map((c, i) => c - target.vector[i]));
  const psi = Math.sqrt(variance + delta * delta);
  const omega = 1 - vectorDot(tilt, target.unit);

  return {
    ...data,
    hue: data.hue * rad2deg,
    delta,
    psi,
    theta: Math.acos(vectorDot(unitCentroid, target.unit)) * rad2deg,
    omega,
    alpha: 100 * Math.sqrt(psi * omega),
    deltaL: Math.abs(centroid[0] - target.vector[0]),
    deltaC: Math.abs(chroma - target.chroma),
    deltaH: (Math.abs(hue - target.hue) % Math.PI) * rad2deg,
  };
};

const sortOrders = {
  max: (a, b) => b - a,
  min: (a, b) => a - b,
};

// who needs a framework?
const makeTemplate = (id, definition = () => ({})) => {
  const content = document.getElementById(id).content;
  return (...args) => {
    const fragment = content.cloneNode(true);
    const binds = Object.fromEntries(
      Array.from(fragment.querySelectorAll("[bind-to]")).map(element => {
        const name = element.getAttribute("bind-to");
        element.removeAttribute("bind-to");
        return [name, element];
      })
    );
    Object.entries(definition(...args))
      .map(([name, settings]) => [binds[name], settings])
      .filter(([bind]) => !!bind)
      .forEach(([bind, settings]) =>
        Object.entries(settings).forEach(([setting, value]) => {
          if (setting.startsWith("@")) {
            bind.addEventListener(setting.slice(1), value);
          } else if (setting.startsWith("--")) {
            bind.style.setProperty(setting, value);
          } else if (setting === "dataset") {
            Object.entries(value).forEach(([key, data]) => (bind.dataset[key] = data));
          } else if (setting === "append") {
            if (Array.isArray(value)) {
              bind.append(...value);
            } else {
              bind.append(value);
            }
          } else {
            bind[setting] = value;
          }
        })
      );
    return [fragment, binds];
  };
};

// ---- Selectors ----

const rootStyle = document.querySelector(":root").style;
const colorSearchResultsTarget = document.getElementById("color-results");
const nameSearchResultsTarget = document.getElementById("name-results");
const prevColorsSidebar = document.getElementById("prevColors");
const clusterRankingTitle = document.getElementById("cls-title");
const clusterMetricSection = document.getElementById("cls-metric-mount");
const clusterFunctionSection = document.getElementById("cls-fn");

const colorCalculateForm = document.forms.colorCalculateForm;
const colorSortForm = document.forms.colorSortForm;
const targetColorElements = document.forms.targetColorForm.elements;
const colorDisplayElements = document.forms.colorDisplayForm.elements;
const filterElements = document.forms.filterControl.elements;
const nameSearchFormElements = document.forms.nameSearchForm.elements;

// ---- Add Metric Selects ----

const createMetricSelect = makeTemplate("metric-select-template");
const [{ firstElementChild: sortMetricForm }] = createMetricSelect();
const [{ firstElementChild: clusterMetricForm }] = createMetricSelect();

document.getElementById("sort-metric-mount").append(sortMetricForm);
sortMetricForm.elements.metricKind.value = "compare";

document.getElementById("cls-metric-mount").append(clusterMetricForm);
clusterMetricForm.elements.metricKind.value = "stat";

const updateMetricSelects = form => {
  const kind = form.elements.metricKind.value;
  form.elements.compare.disabled = kind !== "compare";
  form.elements.stat.disabled = kind !== "stat";
  form.elements.metric.value = form.elements[kind].value;
};

// bit of a hack, but lets us control this all from the template
const metricSymbols = Object.fromEntries(
  Array.from(document.querySelectorAll("option")).map(el => [
    el.value,
    el.textContent.split("(")[1].split(")")[0],
  ])
);

const updateMetricDisplays = () => {
  updateMetricSelects(sortMetricForm);
  updateMetricSelects(clusterMetricForm);

  colorCalculateForm.elements.sortMetricSymbolP.value =
    colorCalculateForm.elements.sortMetricSymbolB.value =
      metricSymbols[
        sortMetricForm.elements[sortMetricForm.elements.metricKind.value].value
      ];

  colorCalculateForm.elements.clusterMetricSymbol.value =
    metricSymbols[
      clusterMetricForm.elements[clusterMetricForm.elements.metricKind.value].value
    ];
};

// ---- Styling ----

const getColorStyles = hex => {
  const rgb = hex2rgb(hex);
  const lum = vectorDot(rgb, [0.3, 0.6, 0.1]);
  const highlight = lum >= 128 ? "var(--color-dark)" : "var(--color-light)";
  return {
    "--highlight": highlight,
    "--background": hex,
    "--shadow-component": lum >= 128 ? "0" : "255",
  };
};

const setColorStyles = (style, hex) =>
  Object.entries(getColorStyles(hex)).forEach(([prop, value]) =>
    style.setProperty(prop, value)
  );

// ---- Pokemon Display ----

// pulled out bc the render uses them
const metricScores = {};
const bestClusterIndices = {};
const objectiveValues = {};

const createPokemonTooltip = makeTemplate("pkmn-data-template", data =>
  Object.fromEntries(
    Object.entries(data).map(([metric, value]) => [
      metric,
      {
        innerText: Array.isArray(value)
          ? value.map(v => v.toFixed(2)).join(", ")
          : value?.toFixed?.(3)?.replace(".000", ""),
      },
    ])
  )
);

const createPokemonTile = makeTemplate(
  "pkmn-tile-template",
  ({ name, species, color }, enableTotalFlags, enableClusterFlags) => {
    const formattedName = name
      .split("-")
      .map(part => part.charAt(0).toUpperCase() + part.substr(1))
      .join(" ");

    let spriteName = name
      .toLowerCase()
      .replace("'", "") // farfetchd line
      .replace("-gmax", "-gigantamax")
      .replace("-alola", "-alolan")
      .replace("-galar", "-galarian")
      .replace("-hisui", "-hisuian")
      .replace("-paldea", "-paldean")
      .replace("-paldean-combat", "-paldean") // tauros
      .replace("-paldean-blaze", "-paldean-fire") // tauros
      .replace("-paldean-aqua", "-paldean-water") // tauros
      .replace("-phony", "") // sinistea and polteageist
      .replace(". ", "-") // mr mime + rime
      .replace("darmanitan-galarian", "darmanitan-galarian-standard")
      .replace("chienpao", "chien-pao")
      .replace("tinglu", "ting-lu")
      .replace("wochien", "wo-chien")
      .replace("chiyu", "chi-yu");
    if (
      [
        "flabebe",
        "floette",
        "florges",
        "vivillon",
        "basculin",
        "furfrou",
        "magearna",
        "alcremie",
      ].find(s => spriteName.includes(s))
    ) {
      spriteName = spriteName.replace(/-.*$/, "");
    }
    const imageErrorHandler2 = ({ target }) => {
      target.removeEventListener("error", imageErrorHandler2);
      target.src = `https://img.pokemondb.net/sprites/scarlet-violet/icon/${spriteName}.png`;
    };
    const imageErrorHandler1 = ({ target }) => {
      target.removeEventListener("error", imageErrorHandler1);
      target.addEventListener("error", imageErrorHandler2);
      target.src = `https://img.pokemondb.net/sprites/sword-shield/icon/${spriteName}.png`;
    };
    const image = {
      alt: formattedName,
      src: `https://img.pokemondb.net/sprites/sword-shield/normal/${spriteName}.png`,
      "@error": imageErrorHandler1,
    };
    const link = {
      href: `https://pokemondb.net/pokedex/${species.replace("'", "")}`,
    };

    const score = {
      innerText: objectiveValues[name].toFixed(2),
    };

    const { total, clusters } = metricScores[name];
    const buttonBinds = [
      [clusters[0], "cls1Btn", "cls1Data"],
      [clusters[1], "cls2Btn", "cls2Data"],
      [clusters[2], "cls3Btn", "cls3Data"],
      [clusters[3], "cls4Btn", "cls4Data"],
      [total, "totalBtn", "totalData"],
    ]
      .filter(([data]) => !!data)
      .map(([data, button, tooltip], index) => {
        return {
          [button]: {
            dataset: {
              included: enableClusterFlags && index === bestClusterIndices[name],
            },
            hidden: false,
            innerText: data.hex + (data.proportion ? ` - ${data.proportion}%` : ""),
            "@click"() {
              model.setTargetColor(data.hex);
            },
            ...getColorStyles(data.hex),
          },
          [tooltip]: {
            append: createPokemonTooltip(data)[0],
          },
        };
      })
      .reduce((a, b) => ({ ...a, ...b }), {});
    buttonBinds.totalBtn.dataset.included = enableTotalFlags;

    return {
      name: {
        innerText: formattedName,
        title: formattedName,
      },
      image,
      color: { innerText: color },
      link,
      score,
      ...buttonBinds,
    };
  }
);

const renderPokemon = (list, target) => {
  target.innerText = "";

  const {
    sortUseWholeImage,
    sortUseBestCluster,
    sortUseClusterSize,
    sortUseInvClusterSize,
    sortUseTotalSize,
    sortUseInvTotalSize,
  } = Object.fromEntries(new FormData(colorCalculateForm).entries());

  const enableTotalFlags = !!(
    sortUseWholeImage ||
    sortUseTotalSize ||
    sortUseInvTotalSize
  );

  const enableClusterFlags = !!(
    sortUseBestCluster ||
    sortUseClusterSize ||
    sortUseInvClusterSize
  );

  target.append(
    ...list.map(pkmn => createPokemonTile(pkmn, enableTotalFlags, enableClusterFlags)[0])
  );
};

// ---- Calculation Logic ----

const model = {
  setTargetColor(newColor) {
    const hex = `#${newColor?.replace("#", "")}`;
    if (hex.length !== 7) {
      return;
    }

    setColorStyles(rootStyle, hex);

    const oldColor = this.targetColor;
    this.targetColor = hex;

    targetColorElements.colorText.value = hex;
    targetColorElements.colorText.dataset.lastValid = hex;
    targetColorElements.colorPicker.value = hex;

    if (oldColor) {
      const prevButton = document.createElement("button");
      prevButton.innerText = oldColor;
      prevButton.classList = "color-select";
      setColorStyles(prevButton.style, oldColor);
      prevButton.addEventListener("click", () => this.setTargetColor(oldColor));
      prevColorsSidebar.prepend(prevButton);
    }

    const targetData = getColorData(hex);
    targetColorElements.info.value = `
      (${(targetData.vector[0] * 100).toFixed(2)}%,
      ${targetData.chroma.toFixed(4)}, 
      ${(targetData.hue * rad2deg).toFixed(1)}°)
    `;

    pokemonData.forEach(({ name, total, clusters }) => {
      metricScores[name] = {
        total: calcScores(total, targetData),
        clusters: clusters.map(c => calcScores(c, targetData)),
      };
    });

    this.calculateObjective();
  },

  calculateObjective() {
    const {
      clusterUseClusterSize,
      clusterUseInvClusterSize,
      clusterUseTotalSize,
      clusterUseInvTotalSize,
      clusterSortOrder,
      sortUseWholeImage,
      sortUseBestCluster,
      sortUseClusterSize,
      sortUseInvClusterSize,
      sortUseTotalSize,
      sortUseInvTotalSize,
    } = Object.fromEntries(new FormData(colorCalculateForm).entries());

    const clsMetric = clusterMetricForm.elements.metric.value;
    const getClusterScore = productLift(
      cluster => cluster[clsMetric],
      clusterUseClusterSize && (cluster => cluster.size),
      clusterUseInvClusterSize && (cluster => cluster.inverseSize),
      clusterUseTotalSize && ((_, total) => total.size),
      clusterUseInvTotalSize && ((_, total) => total.inverseSize)
    );

    const clsSort = sortOrders[clusterSortOrder];
    const getBestClusterIndex = ({ total, clusters }) =>
      clusters
        .map((c, i) => [getClusterScore(c, total), i])
        .reduce((a, b) => (clsSort(a[0], b[0]) > 0 ? b : a))[1];

    Object.entries(metricScores).forEach(([name, scores]) => {
      bestClusterIndices[name] = getBestClusterIndex(scores);
    });

    const metric = sortMetricForm.elements.metric.value;
    const getSortScore = productLift(
      sortUseWholeImage && (({ total }) => total[metric]),
      sortUseBestCluster && (({ clusters }, i) => clusters[i][metric]),
      sortUseClusterSize && (({ clusters }, i) => clusters[i].size),
      sortUseInvClusterSize && (({ clusters }, i) => clusters[i].inverseSize),
      sortUseTotalSize && (({ total }) => total.size),
      sortUseInvTotalSize && (({ total }) => total.inverseSize)
    );

    Object.entries(metricScores).forEach(([name, scores]) => {
      objectiveValues[name] = getSortScore(scores, bestClusterIndices[name]);
    });

    this.renderNameSearchResults();
    this.rank();
  },

  rank() {
    const { sortOrder } = Object.fromEntries(new FormData(colorSortForm).entries());
    const compare = sortOrders[sortOrder];
    const sortFn = (a, b) => compare(objectiveValues[a], objectiveValues[b]);

    this.ranked = pokemonData
      .slice(0)
      .sort((a, b) => sortFn(a.name, b.name) || a.name.localeCompare(b.name));

    this.renderColorSearchResults();
  },

  setNameSearchResults(newNameResults) {
    this.nameSearchResults = newNameResults;
    this.renderNameSearchResults();
  },

  renderNameSearchResults() {
    renderPokemon(this.nameSearchResults ?? [], nameSearchResultsTarget);
  },

  renderColorSearchResults() {
    renderPokemon(
      this.ranked
        .filter(({ traits }) => traits.every(t => !filterElements[t]?.checked))
        .slice(0, parseInt(colorDisplayElements.resultsToDisplay.value)),
      colorSearchResultsTarget
    );
  },
};

// ---- Form Controls ----

nameSearchFormElements.input.addEventListener("input", ({ target: { value } }) => {
  model.setNameSearchResults(
    pokemonLookup.search(value, { limit: 24 }).map(({ item }) => item)
  );
});

nameSearchFormElements.clear.addEventListener("click", () => {
  nameSearchFormElements.input.value = "";
  model.setNameSearchResults([]);
});

nameSearchFormElements.random.addEventListener("click", () => {
  model.setNameSearchResults(
    Array.from(
      { length: 24 },
      () => pokemonData[Math.floor(Math.random() * pokemonData.length)]
    )
  );
});

targetColorElements.colorText.addEventListener("input", ({ target }) => {
  if (target.willValidate && !target.validity.valid) {
    target.value = target.dataset.lastValid || "";
  } else {
    model.setTargetColor(target.value);
  }
});

targetColorElements.colorPicker.addEventListener("change", ({ target }) =>
  model.setTargetColor(target.value)
);

const randomizeTargetColor = () =>
  model.setTargetColor(
    [Math.random(), Math.random(), Math.random()]
      .map(component =>
        Math.floor(component * 256)
          .toString(16)
          .padStart(2, "0")
      )
      .reduce((x, y) => x + y)
  );

targetColorElements.randomColor.addEventListener("click", randomizeTargetColor);

colorDisplayElements.resultsToDisplay.addEventListener(
  "input",
  ({ target: { value } }) => {
    colorDisplayElements.output.value = value;
  }
);

colorDisplayElements.resultsToDisplay.addEventListener("change", () =>
  model.renderColorSearchResults()
);

Array.from(filterElements).forEach(el =>
  el.addEventListener("change", () => model.renderColorSearchResults())
);

Array.from(colorSortForm.elements).forEach(el =>
  el.addEventListener("change", () => model.rank())
);

Array.from(colorCalculateForm.elements).forEach(el =>
  el.addEventListener("change", () => {
    const { sortUseBestCluster, sortUseClusterSize, sortUseInvClusterSize } =
      Object.fromEntries(new FormData(colorCalculateForm).entries());
    clusterRankingTitle.dataset.faded =
      clusterMetricSection.dataset.faded =
      clusterFunctionSection.dataset.faded =
        !(sortUseBestCluster || sortUseClusterSize || sortUseInvClusterSize);
    model.calculateObjective();
  })
);

sortMetricForm.addEventListener("change", () => {
  updateMetricDisplays();
  model.calculateObjective();
});

clusterMetricForm.addEventListener("change", () => {
  updateMetricDisplays();
  model.calculateObjective();
});

// ---- Initial Setup ----

updateMetricDisplays();
randomizeTargetColor();
