import {
  Map as MapLibreMap,
  NavigationControl,
  Popup,
  addProtocol,
} from "../vendor/maplibre/maplibre-gl.mjs";
import { yardGeoJSON } from "./data/yard.js";
import { CheckboxTree, loadManifest } from "../vendor/checkbox-tree/checkbox-tree.js";
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";

const MAP_MAX_ZOOM = 24;
const FEET_PER_METER = 3.280839895;
const MANDAN_BOUNDS = [
  [-101.05, 46.74],
  [-100.75, 46.93],
];
const TIMELINE_PRESETS = [
  { id: "purchase", label: "Axvigs purchase house", date: "2021-02-01" },
  { id: "trees-2021", label: "2021 trees planted", date: "2021-09-22" },
  { id: "today", label: "Today", date: getTodayDateString() },
];

const mapElement = document.getElementById("map");
const statusElement = document.getElementById("map-status");
const protocol = new globalThis.pmtiles.Protocol();
addProtocol("pmtiles", protocol.tile);
const basemapLayers = globalThis.basemaps
  .layers("protomaps", globalThis.basemaps.namedFlavor("light"), { lang: "en" })
  .filter((layer) => layer.type !== "symbol");

const map = new MapLibreMap({
  container: mapElement,
  center: [-100.89875, 46.82682],
  zoom: 20,
  maxZoom: MAP_MAX_ZOOM,
  maxBounds: MANDAN_BOUNDS,
  style: {
    version: 8,
    sources: {
      protomaps: {
        type: "vector",
        url: `pmtiles://${new URL("./tiles/mandan.pmtiles", document.baseURI).href}`,
        attribution:
          '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
      },
    },
    layers: basemapLayers,
  },
});

map.addControl(new NavigationControl(), "bottom-right");

let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date;
let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => index));
let renderedLayerIds = [];
let renderedSourceIds = [];
let renderedLayerHandlers = [];
const visibilityReady = initializeVisibilityTree();

map.on("load", async () => {
  await visibilityReady;
  renderYardForDate(selectedTimelineDate);
  addTimelineControl();
  map.fitBounds(getGeoJSONBounds(yardGeoJSON), {
    padding: 70,
    maxZoom: MAP_MAX_ZOOM,
    duration: 0,
  });
});

map.on("sourcedata", (event) => {
  if (event.sourceId === "protomaps" && event.isSourceLoaded) {
    statusElement.hidden = true;
  }
});

map.on("error", (event) => {
  const message = event.error?.message || "The vector tile archive could not be loaded.";
  statusElement.textContent = `Map unavailable: ${message}`;
  statusElement.hidden = false;
});

function renderYardForDate(date) {
  removeRenderedYard();
  const features = yardGeoJSON.features.filter(
    (feature, index) =>
      selectedFeatureIndexes.has(index) && featureExistsOnDate(feature, date),
  );

  features.forEach((feature, index) => addFeatureLayer(feature, index));
  syncTimelineButtons();
}

function removeRenderedYard() {
  for (const { layerId, event, handler } of renderedLayerHandlers) {
    map.off(event, layerId, handler);
  }
  for (const layerId of renderedLayerIds.reverse()) {
    if (map.getLayer(layerId)) {
      map.removeLayer(layerId);
    }
  }
  for (const sourceId of renderedSourceIds) {
    if (map.getSource(sourceId)) {
      map.removeSource(sourceId);
    }
  }
  renderedLayerIds = [];
  renderedSourceIds = [];
  renderedLayerHandlers = [];
}

function addFeatureLayer(feature, index) {
  const sourceId = `yard-source-${index}`;
  const layerId = `yard-layer-${index}`;
  const style = resolveFeatureStyle(feature.properties.kind, feature.properties.details);
  const layer = buildMapLibreLayer(layerId, sourceId, feature, style);

  map.addSource(sourceId, { type: "geojson", data: feature });
  map.addLayer(layer);
  renderedSourceIds.push(sourceId);
  renderedLayerIds.push(layerId);

  const handleMouseEnter = () => {
    map.getCanvas().style.cursor = "pointer";
  };
  const handleMouseLeave = () => {
    map.getCanvas().style.cursor = "";
  };
  const handleClick = (event) => {
    const coordinates =
      feature.geometry.type === "Point"
        ? feature.geometry.coordinates
        : [event.lngLat.lng, event.lngLat.lat];
    new Popup({ offset: 10 })
      .setLngLat(coordinates)
      .setHTML(buildPopupContent(feature.properties))
      .addTo(map);
  };

  for (const [event, handler] of [
    ["mouseenter", handleMouseEnter],
    ["mouseleave", handleMouseLeave],
    ["click", handleClick],
  ]) {
    map.on(event, layerId, handler);
    renderedLayerHandlers.push({ layerId, event, handler });
  }
}

function buildMapLibreLayer(id, source, feature, style) {
  if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") {
    return {
      id,
      source,
      type: "fill",
      paint: {
        "fill-color": style.fillColor ?? style.color ?? "#333333",
        "fill-opacity": style.fillOpacity ?? 0.25,
        "fill-outline-color": style.color ?? "#333333",
      },
    };
  }

  if (feature.geometry.type === "LineString" || feature.geometry.type === "MultiLineString") {
    const layer = {
      id,
      source,
      type: "line",
      paint: {
        "line-color": style.color ?? "#333333",
        "line-opacity": style.opacity ?? 1,
        "line-width": style.weight ?? 2,
      },
    };
    if (style.dashArray) {
      layer.paint["line-dasharray"] = style.dashArray.split(/\s+/).map(Number);
    }
    return layer;
  }

  return {
    id,
    source,
    type: "circle",
    paint: {
      "circle-color": style.fillColor ?? style.color ?? "#333333",
      "circle-opacity": style.fillOpacity ?? 0.85,
      "circle-stroke-color": style.color ?? "#333333",
      "circle-stroke-width": style.weight ?? 2,
      "circle-radius": getCircleRadius(style, feature.geometry.coordinates[1]),
    },
  };
}

function getCircleRadius(style, latitude) {
  const radiusMeters =
    style.radiusMeters ??
    (typeof style.iconDiameterMeters === "number" ? style.iconDiameterMeters / 2 : null);
  if (radiusMeters === null) {
    return style.radius ?? 5;
  }

  const metersPerPixelAtZoomZero = 156543.03392 * Math.cos((latitude * Math.PI) / 180);
  return [
    "interpolate",
    ["exponential", 2],
    ["zoom"],
    15,
    (radiusMeters * 2 ** 15) / metersPerPixelAtZoomZero,
    MAP_MAX_ZOOM,
    (radiusMeters * 2 ** MAP_MAX_ZOOM) / metersPerPixelAtZoomZero,
  ];
}

async function initializeVisibilityTree() {
  const container = document.getElementById("visibility-tree");
  const manifest = loadManifest(
    await fetch("./src/data/visibility-tree-manifest.json").then((response) => {
      if (!response.ok) {
        throw new Error(`Visibility manifest returned HTTP ${response.status}`);
      }
      return response.json();
    }),
  );
  const tree = new CheckboxTree(container, {
    initiallyCollapsed: true,
    initiallySelected: true,
    storageKey: "a-plot-of-plants:visibility:v1",
    manifest,
    stateEncoding: "tiered",
    onSelectionChange: ({ selectedNodes }) => {
      selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes);
      if (map.loaded()) {
        renderYardForDate(selectedTimelineDate);
      }
      pushTreeState(tree);
    },
  });

  tree.setData(buildVisibilityNodes(yardGeoJSON));
  selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());

  container.addEventListener("click", (event) => {
    if (event.target.closest(".checkbox-tree__toggle")) {
      pushTreeState(tree);
    }
  });
  window.addEventListener("popstate", () => {
    tree.restoreStateFromLocation();
    selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes());
    if (map.loaded()) {
      renderYardForDate(selectedTimelineDate);
    }
  });
}

function pushTreeState(tree) {
  const url = new URL(window.location.href);
  url.hash = tree.serializeStateToFragment();
  if (url.href !== window.location.href) {
    history.pushState(null, "", url);
  }
}

function getSelectedFeatureIndexes(nodes) {
  return new Set(
    nodes
      .map((node) => node.metadata?.featureIndex)
      .filter((index) => Number.isInteger(index)),
  );
}

function buildVisibilityNodes(collection) {
  const typeSchemasByKind = new globalThis.Map(
    collection.featureTypes.map((type) => [type.kind, type]),
  );
  const categoriesById = new globalThis.Map(
    collection.featureCategories.map((category) => [
      category.id,
      {
        id: `visibility:category:${category.id}`,
        label: category.label,
        order: category.order,
        children: [],
      },
    ]),
  );
  const featureEntries = collection.features.map((feature, featureIndex) => ({
    feature,
    featureIndex,
  }));
  const groupsById = new globalThis.Map(
    collection.groups.map((group) => [
      group.id,
      {
        id: `visibility:${group.id}`,
        label: group.label,
        children: [],
        parentGroupId: group.parentGroupId,
        parentKind: group.parentKind,
        categoryId: group.categoryId,
      },
    ]),
  );
  const groupedFeatureIndexes = new Set();

  for (const entry of featureEntries) {
    const group = entry.feature.properties.groupIds.map((id) => groupsById.get(id)).find(Boolean);
    if (!group) continue;
    const categoryId = typeSchemasByKind.get(entry.feature.properties.kind)?.categoryId;
    if (group.categoryId && group.categoryId !== categoryId) {
      throw new Error(`Visibility group spans multiple feature categories: ${group.label}`);
    }
    group.categoryId = categoryId;
    group.children.push(toVisibilityLeaf(entry));
    groupedFeatureIndexes.add(entry.featureIndex);
  }

  for (const group of groupsById.values()) {
    const parent = groupsById.get(group.parentGroupId);
    if (parent) {
      if (parent.categoryId !== group.categoryId) {
        throw new Error(`Nested visibility groups must share a category: ${group.label}`);
      }
      parent.children.push(group);
    }
  }
  const rootGroups = [...groupsById.values()].filter(
    (group) => !groupsById.has(group.parentGroupId) && group.children.length > 0,
  );

  const featuresByKind = new globalThis.Map();
  for (const entry of featureEntries) {
    if (groupedFeatureIndexes.has(entry.featureIndex)) continue;
    const { kind } = entry.feature.properties;
    const typeSchema = typeSchemasByKind.get(kind);
    const categoryId = typeSchema?.categoryId;
    const typeNode = featuresByKind.get(kind) ?? {
      id: `visibility:type:${kind}`,
      label: typeSchema?.collectionLabel ?? entry.feature.properties.label,
      kind,
      parentKind: typeSchema?.parentKind,
      categoryId,
      children: [],
    };
    typeNode.children.push(toVisibilityLeaf(entry));
    featuresByKind.set(kind, typeNode);
  }

  for (const typeNode of featuresByKind.values()) {
    const parentTypeNode = featuresByKind.get(typeNode.parentKind);
    if (parentTypeNode) {
      parentTypeNode.children.push(typeNode);
    } else {
      categoriesById.get(typeNode.categoryId)?.children.push(typeNode);
    }
  }

  for (const group of rootGroups) {
    const parentTypeNode = featuresByKind.get(group.parentKind);
    if (parentTypeNode) {
      parentTypeNode.children.push(group);
    } else {
      categoriesById.get(group.categoryId)?.children.push(group);
    }
  }

  for (const typeNode of featuresByKind.values()) {
    delete typeNode.kind;
    delete typeNode.parentKind;
    delete typeNode.categoryId;
  }
  for (const group of groupsById.values()) {
    delete group.parentGroupId;
    delete group.parentKind;
    delete group.categoryId;
  }

  return [...categoriesById.values()]
    .filter((category) => category.children.length > 0)
    .sort((left, right) => left.order - right.order)
    .map((category) => {
      delete category.order;
      return category;
    })
    .map(sortVisibilityNode);
}

function toVisibilityLeaf({ feature, featureIndex }) {
  return {
    id: `visibility:feature:${featureIndex}`,
    label: feature.properties.name,
    metadata: { featureIndex },
  };
}

function sortVisibilityNode(node) {
  if (node.children) {
    node.children = node.children.map(sortVisibilityNode).sort(compareVisibilityNodes);
  }
  return node;
}

function compareVisibilityNodes(left, right) {
  return left.label.localeCompare(right.label, undefined, { numeric: true });
}

function featureExistsOnDate(feature, date) {
  const { plantedDate, removedDate } = feature.properties;
  return (!plantedDate || plantedDate <= date) && (!removedDate || date <= removedDate);
}

const timelineButtons = new globalThis.Map();

function addTimelineControl() {
  class TimelineControl {
    onAdd() {
      const container = document.createElement("div");
      container.className = "maplibregl-ctrl timeline-control";

      const title = document.createElement("div");
      title.className = "timeline-control-title";
      title.textContent = "Timeline";

      const buttonGroup = document.createElement("div");
      buttonGroup.className = "timeline-buttons";
      buttonGroup.setAttribute("role", "group");
      buttonGroup.setAttribute("aria-label", "Choose map date");

      for (const preset of TIMELINE_PRESETS) {
        const button = document.createElement("button");
        button.type = "button";
        button.textContent = preset.label;
        button.title = preset.date;
        button.addEventListener("click", () => {
          selectedTimelineDate = preset.date;
          renderYardForDate(selectedTimelineDate);
        });
        timelineButtons.set(preset.id, button);
        buttonGroup.append(button);
      }

      container.append(title, buttonGroup);
      return container;
    }

    onRemove() {}
  }

  map.addControl(new TimelineControl(), "top-right");
  syncTimelineButtons();
}

function syncTimelineButtons() {
  for (const preset of TIMELINE_PRESETS) {
    const button = timelineButtons.get(preset.id);
    if (!button) continue;
    const isSelected = preset.date === selectedTimelineDate;
    button.classList.toggle("is-selected", isSelected);
    button.setAttribute("aria-pressed", String(isSelected));
  }
}

function getTodayDateString(date = new Date()) {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
}

function getGeoJSONBounds(collection) {
  let west = Infinity;
  let south = Infinity;
  let east = -Infinity;
  let north = -Infinity;

  visitCoordinates(collection.features.map((feature) => feature.geometry.coordinates), ([lon, lat]) => {
    west = Math.min(west, lon);
    south = Math.min(south, lat);
    east = Math.max(east, lon);
    north = Math.max(north, lat);
  });
  return [
    [west, south],
    [east, north],
  ];
}

function visitCoordinates(coordinates, callback) {
  if (typeof coordinates[0] === "number") {
    callback(coordinates);
    return;
  }
  for (const coordinate of coordinates) {
    visitCoordinates(coordinate, callback);
  }
}

function buildPopupContent(properties) {
  const { kind, label, name, plantedDate, removedDate, details, parentId, groupIds } = properties;
  const entries = Object.entries(details ?? {}).filter(
    ([key, value]) =>
      value !== null && value !== undefined && shouldShowDetailField(key, details),
  );
  const lifecycleEntries = [];
  const relationshipEntries = [];
  const linkEntries = resolveFeatureLinks(kind, details);

  if (plantedDate) lifecycleEntries.push(["plantedDate", plantedDate]);
  if (removedDate) lifecycleEntries.push(["removedDate", removedDate]);
  if (parentId) relationshipEntries.push(["parentFeature", parentId]);
  if (groupIds?.length) relationshipEntries.push(["groups", groupIds.join(", ")]);

  const title = `<strong>${escapeHtml(name)}</strong>`;
  const subtitle = name === label ? "" : `<div>${escapeHtml(label)}</div>`;
  const allEntries = [...lifecycleEntries, ...entries, ...relationshipEntries];
  const measurementTable = buildMeasurementTable(details?.measurements);
  if (allEntries.length === 0 && linkEntries.length === 0 && !measurementTable) {
    return `${title}${subtitle}`;
  }

  const detailRows = allEntries
    .map(
      ([key, value]) =>
        `<div><span>${escapeHtml(formatDetailLabel(key))}:</span> ${escapeHtml(formatDetailValue(key, value))}</div>`,
    )
    .join("");
  const linkRows = linkEntries
    .map(
      (link) =>
        `<div><a href="${escapeHtml(link.url)}" target="_blank" rel="noreferrer">${escapeHtml(link.label)}</a></div>`,
    )
    .join("");
  return `${title}${subtitle}<div>${detailRows}${measurementTable}${linkRows}</div>`;
}

function shouldShowDetailField(key, details = {}) {
  if (key === "measurements") {
    return false;
  }
  if (
    hasDatedMeasurements(details.measurements) &&
    (key === "canopyDiameter" || key === "height")
  ) {
    return false;
  }
  return !key.endsWith("Url") && key !== "gardenOrgId";
}

function formatDetailLabel(key) {
  const labels = {
    canopyDiameter: "Canopy diameter",
    height: "Height",
    measurements: "Measurements",
    measuredDate: "Measured",
    plantedDate: "Planted",
    removedDate: "Removed",
    commonName: "Common name",
    genus: "Genus",
    cultivar: "Cultivar",
    bloomColor: "Bloom color",
    bloomSeason: "Bloom season",
    species: "Species",
    fenceType: "Fence type",
    axis: "Axis",
    offsetFeet: "Offset",
    spacingFeet: "Spacing",
    note: "Note",
    parentFeature: "Parent",
    groups: "Groups",
  };
  return labels[key] ?? key;
}

function formatDetailValue(key, value) {
  if (key === "canopyDiameter" || key === "height") return formatMetersAsFeet(value);
  if (key === "offsetFeet" || key === "spacingFeet") return `${value} ft`;
  return String(value);
}

function formatMetersAsFeet(value) {
  return `${formatNumber(value * FEET_PER_METER)} ft`;
}

function formatNumber(value) {
  return Number(value.toFixed(1)).toString();
}

function buildMeasurementTable(measurements) {
  const datedMeasurements = getDatedMeasurements(measurements);
  if (!datedMeasurements.length) {
    return "";
  }

  const rows = datedMeasurements
    .map(
      (measurement) => `
        <tr>
          <td>${escapeHtml(measurement.measuredDate)}</td>
          <td>${escapeHtml(formatOptionalMeasurement(measurement.canopyDiameter))}</td>
          <td>${escapeHtml(formatOptionalMeasurement(measurement.height))}</td>
        </tr>
      `,
    )
    .join("");

  return `
    <div class="measurement-table-block">
      <div><span>${escapeHtml(formatDetailLabel("measurements"))}:</span></div>
      <table class="measurement-table">
        <thead>
          <tr>
            <th>Date</th>
            <th>Canopy dia.</th>
            <th>Height</th>
          </tr>
        </thead>
        <tbody>${rows}</tbody>
      </table>
    </div>
  `;
}

function formatOptionalMeasurement(value) {
  return value === undefined ? "" : formatMetersAsFeet(value);
}

function hasDatedMeasurements(measurements) {
  return getDatedMeasurements(measurements).length > 0;
}

function getDatedMeasurements(measurements) {
  if (!Array.isArray(measurements)) {
    return [];
  }
  return measurements.filter((measurement) => measurement.measuredDate);
}

function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}
