import { useEffect, useMemo, useState } from "react";
import type { MapDataProductBinding } from "./MapFixturePreview.js";

export type DataProductPoint = {
  type: "Point";
  coordinates: [number, number];
};

export type DataProductPolygon = {
  type: "Polygon";
  coordinates: Array<Array<[number, number]>>;
};

export type DataProductMultiPolygon = {
  type: "MultiPolygon";
  coordinates: Array<Array<Array<[number, number]>>>;
};

export type DataProductGeometry = DataProductPoint | DataProductPolygon | DataProductMultiPolygon;

/**
 * The only entity shape the map runtime accepts from Platform. It is the
 * canonical data-product fact, not a provider payload and not a Cesium model.
 */
export type MapRuntimeFact = {
  sourceId: string;
  semanticType: string;
  observedAt: string;
  receivedAt: string;
  attributes: Record<string, unknown>;
  geometry: DataProductGeometry | null;
  presentationStatus: string;
};

export type MapRuntimeBinding = {
  bindingId: string;
  dataProductId: string;
  slotId: string;
  presentationProfileId?: string;
  facts: MapRuntimeFact[];
  cursor: string | null;
  state: "idle" | "loading" | "ready" | "reconnecting" | "error";
};

type SnapshotEnvelope = {
  schemaVersion: "nodedc.data-product.snapshot/v1";
  dataProduct: { id: string; version: string };
  generatedAt: string;
  cursor: string;
  facts: MapRuntimeFact[];
};

type PatchEnvelope = {
  schemaVersion: "nodedc.data-product.patch/v1";
  dataProduct: { id: string; version: string };
  cursor: string;
  previousCursor: string;
  emittedAt: string;
  operations: Array<
    | { op: "upsert"; fact: MapRuntimeFact }
    | { op: "remove"; sourceId: string; semanticType: string }
  >;
};

type PresentationPatchEnvelope = {
  schemaVersion: "nodedc.foundry.presentation-patch/v1";
  generatedAt: string;
  operations: Array<{ op: "upsert"; sourceId: string; semanticType: string; status: string }>;
};

type BindingState = {
  binding: MapDataProductBinding;
  cursor: string | null;
  facts: Record<string, MapRuntimeFact>;
  state: MapRuntimeBinding["state"];
};

const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const cursorPattern = /^(?:0|[1-9]\d*)$/;

function factKey(fact: MapRuntimeFact) {
  return `${fact.semanticType}\u0000${fact.sourceId}`;
}

export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
  return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
}

function isIsoTimestamp(value: unknown): value is string {
  return typeof value === "string" && !Number.isNaN(Date.parse(value));
}

function asPosition(value: unknown): [number, number] | null {
  if (!Array.isArray(value) || value.length !== 2) return null;
  const [longitude, latitude] = value;
  if (
    typeof longitude !== "number"
    || !Number.isFinite(longitude)
    || longitude < -180
    || longitude > 180
    || typeof latitude !== "number"
    || !Number.isFinite(latitude)
    || latitude < -90
    || latitude > 90
  ) return null;
  return [longitude, latitude];
}

function asLinearRing(value: unknown): Array<[number, number]> | null {
  if (!Array.isArray(value) || value.length < 4 || value.length > 10_000) return null;
  const positions = value.map(asPosition);
  if (positions.some((position) => !position)) return null;
  const normalized = positions as Array<[number, number]>;
  const first = normalized[0];
  const last = normalized.at(-1);
  if (!last || first[0] !== last[0] || first[1] !== last[1]) return null;
  return normalized;
}

function asPolygonCoordinates(value: unknown): Array<Array<[number, number]>> | null {
  if (!Array.isArray(value) || value.length < 1 || value.length > 256) return null;
  const rings = value.map(asLinearRing);
  if (rings.some((ring) => !ring)) return null;
  return rings as Array<Array<[number, number]>>;
}

function asGeometry(value: unknown): DataProductGeometry | null {
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
  const candidate = value as { type?: unknown; coordinates?: unknown };
  if (candidate.type === "Point") {
    const coordinates = asPosition(candidate.coordinates);
    return coordinates ? { type: "Point", coordinates } : null;
  }
  if (candidate.type === "Polygon") {
    const coordinates = asPolygonCoordinates(candidate.coordinates);
    return coordinates ? { type: "Polygon", coordinates } : null;
  }
  if (candidate.type === "MultiPolygon" && Array.isArray(candidate.coordinates) && candidate.coordinates.length >= 1 && candidate.coordinates.length <= 256) {
    const coordinates = candidate.coordinates.map(asPolygonCoordinates);
    if (coordinates.some((polygon) => !polygon)) return null;
    return { type: "MultiPolygon", coordinates: coordinates as Array<Array<Array<[number, number]>>> };
  }
  return null;
}

function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact | null {
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
  const candidate = value as Record<string, unknown>;
  const sourceId = typeof candidate.sourceId === "string" ? candidate.sourceId : "";
  const semanticType = typeof candidate.semanticType === "string" ? candidate.semanticType : "";
  if (!identifier.test(sourceId) || !identifier.test(semanticType) || !binding.semanticTypes.includes(semanticType)) return null;
  if (!isIsoTimestamp(candidate.observedAt) || !isIsoTimestamp(candidate.receivedAt)) return null;
  const sourceAttributes = candidate.attributes && typeof candidate.attributes === "object" && !Array.isArray(candidate.attributes)
    ? candidate.attributes as Record<string, unknown>
    : {};
  // A data-product binding is also a field-level browser projection. Treat an
  // empty projection as no optional attributes rather than as "all fields".
  const allowed = new Set(binding.fieldProjection);
  const attributes = Object.fromEntries(Object.entries(sourceAttributes).filter(([key]) => allowed.has(key)));
  return {
    sourceId,
    semanticType,
    observedAt: candidate.observedAt,
    receivedAt: candidate.receivedAt,
    attributes,
    geometry: asGeometry(candidate.geometry),
    presentationStatus: typeof candidate.presentationStatus === "string" && /^[a-z0-9_-]{1,64}$/.test(candidate.presentationStatus)
      ? candidate.presentationStatus
      : "active",
  };
}

function asSnapshot(value: unknown, binding: MapDataProductBinding): SnapshotEnvelope | null {
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
  const candidate = value as Record<string, unknown>;
  const product = candidate.dataProduct;
  if (candidate.schemaVersion !== "nodedc.data-product.snapshot/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
  if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !isIsoTimestamp(candidate.generatedAt) || !Array.isArray(candidate.facts)) return null;
  return {
    schemaVersion: "nodedc.data-product.snapshot/v1",
    dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
    generatedAt: candidate.generatedAt,
    cursor: String(candidate.cursor),
    facts: candidate.facts.map((fact) => asFact(fact, binding)).filter((fact): fact is MapRuntimeFact => Boolean(fact)),
  };
}

function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope | null {
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
  const candidate = value as Record<string, unknown>;
  const product = candidate.dataProduct;
  if (candidate.schemaVersion !== "nodedc.data-product.patch/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
  if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !cursorPattern.test(String(candidate.previousCursor || "")) || !isIsoTimestamp(candidate.emittedAt) || !Array.isArray(candidate.operations)) return null;
  const operations: PatchEnvelope["operations"] = [];
  for (const operation of candidate.operations) {
    if (!operation || typeof operation !== "object" || Array.isArray(operation)) continue;
    const value = operation as { op?: unknown; fact?: unknown; sourceId?: unknown; semanticType?: unknown };
    if (value.op === "upsert") {
      const fact = asFact(value.fact, binding);
      if (fact) operations.push({ op: "upsert", fact });
      continue;
    }
    if (
      value.op === "remove"
      && typeof value.sourceId === "string"
      && identifier.test(value.sourceId)
      && typeof value.semanticType === "string"
      && binding.semanticTypes.includes(value.semanticType)
    ) {
      operations.push({ op: "remove", sourceId: value.sourceId, semanticType: value.semanticType });
    }
  }
  return {
    schemaVersion: "nodedc.data-product.patch/v1",
    dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
    cursor: String(candidate.cursor),
    previousCursor: String(candidate.previousCursor),
    emittedAt: candidate.emittedAt,
    operations,
  };
}

function asPresentationPatch(value: unknown, binding: MapDataProductBinding): PresentationPatchEnvelope | null {
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
  const candidate = value as Record<string, unknown>;
  if (candidate.schemaVersion !== "nodedc.foundry.presentation-patch/v1" || !isIsoTimestamp(candidate.generatedAt) || !Array.isArray(candidate.operations)) return null;
  const operations = candidate.operations.flatMap((operation) => {
    if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
    const item = operation as { op?: unknown; sourceId?: unknown; semanticType?: unknown; status?: unknown };
    if (
      item.op !== "upsert"
      || typeof item.sourceId !== "string"
      || !identifier.test(item.sourceId)
      || typeof item.semanticType !== "string"
      || !binding.semanticTypes.includes(item.semanticType)
      || typeof item.status !== "string"
      || !/^[a-z0-9_-]{1,64}$/.test(item.status)
    ) return [];
    return [{ op: "upsert" as const, sourceId: item.sourceId, semanticType: item.semanticType, status: item.status }];
  });
  return { schemaVersion: "nodedc.foundry.presentation-patch/v1", generatedAt: candidate.generatedAt, operations };
}

function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): BindingState {
  return {
    ...current,
    cursor: snapshot.cursor,
    state: "ready",
    facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])),
  };
}

function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState | null {
  // A durable stream must advance from the exact snapshot/previous patch
  // cursor. Any gap is recoverable: re-fetch a current snapshot before the
  // browser applies more updates.
  if (current.cursor !== patch.previousCursor) return null;
  const facts = { ...current.facts };
  for (const operation of patch.operations) {
    if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact;
    else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`];
  }
  return { ...current, facts, cursor: patch.cursor, state: "ready" };
}

function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState {
  const facts = { ...current.facts };
  for (const operation of patch.operations) {
    const key = `${operation.semanticType}\u0000${operation.sourceId}`;
    if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status };
  }
  return { ...current, facts };
}

function stateFor(binding: MapDataProductBinding, state: MapRuntimeBinding["state"] = "idle"): BindingState {
  return { binding, cursor: null, facts: {}, state };
}

function runtimePath(applicationId: string, pageId: string, bindingId: string, resource: "snapshot" | "stream") {
  return `/api/applications/${encodeURIComponent(applicationId)}/pages/${encodeURIComponent(pageId)}/data-bindings/${encodeURIComponent(bindingId)}/${resource}`;
}

/**
 * Opens a Platform data product only while the page is mounted. The browser
 * knows the target binding but never a provider URL, tenant/connection scope,
 * or reader credential: those are resolved by Foundry's same-origin BFF.
 */
export function useMapDataProductRuntime({
  applicationId,
  pageId,
  bindings,
  enabled = true,
}: {
  applicationId?: string;
  pageId?: string;
  bindings: MapDataProductBinding[];
  enabled?: boolean;
}) {
  const [records, setRecords] = useState<Record<string, BindingState>>({});
  const bindingSignature = useMemo(
    () => JSON.stringify(bindings.map((binding) => ({
      id: binding.id,
      dataProductId: binding.dataProductId,
      slotId: binding.slotId,
      semanticTypes: binding.semanticTypes,
      fieldProjection: binding.fieldProjection,
      presentationProfileId: binding.presentationProfileId,
      dataClass: binding.dataClass ?? "operational",
    }))),
    [bindings],
  );

  useEffect(() => {
    if (!enabled || !applicationId || !pageId || bindings.length === 0) {
      setRecords({});
      return;
    }

    let disposed = false;
    const sources = new Map<string, EventSource>();
    const controllers = new Set<AbortController>();
    const retries = new Map<string, number>();
    const latest = new Map<string, BindingState>();

    const update = (binding: MapDataProductBinding, updater: (current: BindingState) => BindingState) => {
      if (disposed) return stateFor(binding);
      const next = updater(latest.get(binding.id) ?? stateFor(binding));
      latest.set(binding.id, next);
      setRecords((current) => ({ ...current, [binding.id]: next }));
      return next;
    };

    const scheduleReconnect = (binding: MapDataProductBinding) => {
      if (disposed || retries.has(binding.id)) return;
      update(binding, (current) => ({ ...current, state: "reconnecting" }));
      const timer = window.setTimeout(() => {
        retries.delete(binding.id);
        void loadSnapshotAndStream(binding);
      }, 1_500);
      retries.set(binding.id, timer);
    };

    const loadSnapshotAndStream = async (binding: MapDataProductBinding) => {
      const existing = sources.get(binding.id);
      if (existing) {
        existing.close();
        sources.delete(binding.id);
      }
      update(binding, (current) => ({ ...current, state: "loading" }));
      const controller = new AbortController();
      controllers.add(controller);
      try {
        const snapshotResponse = await fetch(runtimePath(applicationId, pageId, binding.id, "snapshot"), {
          cache: "no-store",
          credentials: "same-origin",
          signal: controller.signal,
        });
        if (!snapshotResponse.ok) throw new Error(`snapshot_${snapshotResponse.status}`);
        const snapshot = asSnapshot(await snapshotResponse.json(), binding);
        if (!snapshot) throw new Error("snapshot_contract_invalid");
        if (disposed) return;
        update(binding, (current) => replaceSnapshot(current, snapshot));

        const source = new EventSource(`${runtimePath(applicationId, pageId, binding.id, "stream")}?after=${encodeURIComponent(snapshot.cursor)}`, { withCredentials: true });
        sources.set(binding.id, source);
        source.addEventListener("nodedc.data-product.patch.v1", (event) => {
          let patch: PatchEnvelope | null = null;
          try {
            patch = asPatch(JSON.parse((event as MessageEvent<string>).data), binding);
          } catch {
            return;
          }
          if (!patch) return;
          const next = update(binding, (current) => {
            const next = applyPatch(current, patch);
            return next ?? current;
          });
          if (next.cursor !== patch.cursor) void loadSnapshotAndStream(binding);
        });
        source.addEventListener("nodedc.data-product.resync-required.v1", () => {
          void loadSnapshotAndStream(binding);
        });
        source.addEventListener("nodedc.foundry.presentation-patch.v1", (event) => {
          let patch: PresentationPatchEnvelope | null = null;
          try {
            patch = asPresentationPatch(JSON.parse((event as MessageEvent<string>).data), binding);
          } catch {
            return;
          }
          if (!patch) return;
          update(binding, (current) => applyPresentationPatch(current, patch));
        });
        source.onerror = () => {
          // EventSource will retry transient transport failures itself. A
          // permanent upstream rejection closes the stream; loading a fresh
          // snapshot also handles an expired outbox cursor deterministically.
          if (source.readyState === EventSource.CLOSED) scheduleReconnect(binding);
        };
      } catch (error) {
        if (disposed || controller.signal.aborted) return;
        update(binding, (current) => ({ ...current, state: "error" }));
        scheduleReconnect(binding);
      } finally {
        controllers.delete(controller);
      }
    };

    for (const binding of bindings) void loadSnapshotAndStream(binding);
    return () => {
      disposed = true;
      for (const source of sources.values()) source.close();
      for (const controller of controllers) controller.abort();
      for (const timer of retries.values()) window.clearTimeout(timer);
    };
  }, [applicationId, bindingSignature, bindings, enabled, pageId]);

  return useMemo<MapRuntimeBinding[]>(() => bindings.map((binding) => {
    const record = records[binding.id] ?? stateFor(binding);
    return {
      bindingId: binding.id,
      dataProductId: binding.dataProductId,
      slotId: binding.slotId,
      presentationProfileId: binding.presentationProfileId,
      facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
      cursor: record.cursor,
      state: record.state,
    };
  }), [bindings, records]);
}
