import { createHash } from "node:crypto";

import { isBoundedGeoJsonGeometry } from "./geometry.mjs";

export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2";
export const ZONE_SOURCE_ADAPTERS = Object.freeze({
  "depttrans-api-snapshot-v1": Object.freeze({
    authorityId: "moscow-department-of-transport",
    datasetId: "pmd-slow-zones",
    sourceIdPrefix: "depttrans-pmd-slow-zone-",
    sourceKind: "versioned_snapshot",
    sourceRevision: "depttrans-api-snapshot-v1",
  }),
  "depttrans-mmap-snapshot-v1": Object.freeze({
    authorityId: "moscow-department-of-transport",
    datasetId: "pmd-slow-zones",
    sourceIdPrefix: "depttrans-pmd-slow-zone-",
    sourceKind: "versioned_snapshot",
    sourceRevision: "depttrans-mmap-snapshot-v1",
  }),
  "gelios-rest-v1": Object.freeze({
    authorityId: "gelios-account",
    datasetId: "gelios-geofences",
    sourceIdPrefix: "gelios-zone-",
    sourceKind: "live_api",
    sourceRevision: "gelios-rest-v1",
  }),
});

/**
 * Convert one already-complete source generation into map.zone facts. Fetching,
 * credentials and page iteration stay in Engine; this function is the common
 * adapter boundary used by both live REST and immutable snapshot sources.
 */
export function normalizeZoneSourceGeneration(value, {
  identityCrosswalk = {},
  maxZones = 5000,
  maxBytes = 16 * 1024 * 1024,
  circleSegments = 64,
} = {}) {
  if (!isPlainObject(value) || value.schemaVersion !== ZONE_SOURCE_GENERATION_SCHEMA_VERSION) {
    throw zoneSourceError("zone_source_generation_schema_invalid");
  }
  const adapter = ZONE_SOURCE_ADAPTERS[value.adapterId];
  if (!adapter) throw zoneSourceError("zone_source_adapter_unsupported");
  if (value.authorityId !== adapter.authorityId) throw zoneSourceError("zone_source_authority_mismatch");
  if (value.datasetId !== adapter.datasetId) throw zoneSourceError("zone_source_dataset_mismatch");
  if (!isPlainObject(identityCrosswalk)) throw zoneSourceError("zone_source_identity_crosswalk_invalid");
  if (value.complete !== true) throw zoneSourceError("zone_source_generation_incomplete");
  const generatedAt = iso(value.generatedAt, "zone_source_generated_at_invalid");
  if (!Array.isArray(value.zones) || value.zones.length > maxZones) {
    throw zoneSourceError("zone_source_zone_limit_exceeded");
  }
  if (byteLength(value) > maxBytes) throw zoneSourceError("zone_source_generation_bytes_exceeded");
  if (!Number.isInteger(circleSegments) || circleSegments < 16 || circleSegments > 256) {
    throw zoneSourceError("zone_source_circle_segments_invalid");
  }

  const facts = [];
  const sourceIds = new Set();
  for (const [index, rawZone] of value.zones.entries()) {
    const zone = normalizeRawZone(rawZone, index, identityCrosswalk, circleSegments, value.adapterId, adapter);
    if (sourceIds.has(zone.sourceId)) throw zoneSourceError("zone_source_identity_duplicate");
    sourceIds.add(zone.sourceId);
    facts.push({
      sourceId: zone.sourceId,
      semanticType: "map.zone",
      observedAt: generatedAt,
      geometry: zone.geometry,
      attributes: compact({
        area_square_meters: finiteNonNegative(first(rawZone.areaSquareMeters, rawZone.surfaceArea, rawZone.surface_area, rawZone.area)),
        description: optionalString(first(rawZone.description, rawZone.descr)),
        display_name: requiredString(first(rawZone.displayName, rawZone.name, rawZone.display_name), "zone_source_name_required"),
        applies_to_couriers: optionalBoolean(first(rawZone.appliesToCouriers, rawZone.courier)),
        applies_to_kicksharing: optionalBoolean(first(rawZone.appliesToKicksharing, rawZone.kick_sharing)),
        dataset_authority: adapter.authorityId,
        dataset_id: adapter.datasetId,
        geometry_kind: zone.geometryKind,
        max_speed_kph: finiteNonNegative(first(
          rawZone.maxSpeedKph,
          rawZone.max_speed_kph,
          rawZone.maxPermissibleSpeed,
          rawZone.max_permissible_speed,
        )),
        perimeter_meters: finiteNonNegative(rawZone.perimeter),
        schedule_timezone: optionalString(first(rawZone.scheduleTimezone, value.timezone)),
        source_adapter: value.adapterId,
        source_created_at: optionalString(first(rawZone.sourceCreatedAt, rawZone.create_dttm)),
        source_kind: adapter.sourceKind,
        source_revision: revision(value, adapter),
        source_updated_at: optionalString(first(rawZone.sourceUpdatedAt, rawZone.update_dttm)),
        style_color: optionalString(first(rawZone.color, rawZone.style_color)),
        weekly_speed_schedule: normalizeWeeklySchedule(rawZone.weeklySpeedSchedule),
      }),
    });
  }
  facts.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
  const digest = createHash("sha256").update(stableJson({
    adapterId: value.adapterId,
    generatedAt,
    revision: revision(value, adapter),
    facts,
  })).digest("hex");
  return Object.freeze({
    schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
    adapterId: value.adapterId,
    sourceKind: adapter.sourceKind,
    sourceRevision: revision(value, adapter),
    generatedAt,
    complete: true,
    digest,
    facts: Object.freeze(facts.map(deepFreeze)),
  });
}

function normalizeRawZone(value, index, identityCrosswalk, circleSegments, adapterId, adapter) {
  if (!isPlainObject(value)) throw zoneSourceError(`zone_source_zone_${index}_invalid`);
  const nativeId = resolveNativeId(value, identityCrosswalk, adapterId);
  const sourceId = nativeId.startsWith(adapter.sourceIdPrefix) ? nativeId : `${adapter.sourceIdPrefix}${nativeId}`;
  if (!/^[a-z][a-z0-9._:-]{2,127}$/.test(sourceId)) throw zoneSourceError("zone_source_identity_invalid");
  const rawType = String(first(value.type, value.geometry?.type, value.geometry_kind, "polygon")).trim().toLowerCase();
  const geometryKind = rawType === "circle" ? "circle" : rawType === "line" || rawType === "corridor" ? "corridor" : "polygon";
  const geometry = normalizeGeometry(value, geometryKind, circleSegments);
  if (!isBoundedGeoJsonGeometry(geometry, new Set(["Polygon", "MultiPolygon"]))) {
    throw zoneSourceError(`zone_source_zone_${index}_geometry_invalid`);
  }
  return { sourceId, geometryKind, geometry };
}

function resolveNativeId(zone, identityCrosswalk, adapterId) {
  const direct = first(
    zone.nativeId,
    zone.pmd_slow_zone_id,
    zone.id,
    zone.geozoneId,
    zone.zone_id,
    zone.sourceId,
  );
  if (direct !== undefined && direct !== null && String(direct).trim()) return String(direct).trim();
  if (adapterId === "depttrans-mmap-snapshot-v1" || adapterId === "depttrans-api-snapshot-v1") {
    const snapshotKey = optionalString(first(zone.snapshotKey, zone.snapshot_key));
    if (snapshotKey && Object.hasOwn(identityCrosswalk, snapshotKey)) {
      const mapped = identityCrosswalk[snapshotKey];
      if (mapped !== undefined && mapped !== null && String(mapped).trim()) return String(mapped).trim();
    }
    throw zoneSourceError("zone_source_identity_crosswalk_required");
  }
  throw zoneSourceError("zone_source_native_identity_required");
}

function normalizeGeometry(zone, geometryKind, circleSegments) {
  if (isBoundedGeoJsonGeometry(zone.geometry, new Set(["Polygon", "MultiPolygon"]))) {
    return structuredClone(zone.geometry);
  }
  const points = parseCoordinates(first(zone.points, zone.coords, zone.coordinates));
  if (geometryKind === "circle") {
    if (!points.length) throw zoneSourceError("zone_source_circle_center_required");
    const radius = finiteNonNegative(zone.radius);
    if (!radius || radius <= 0) throw zoneSourceError("zone_source_circle_radius_required");
    return circlePolygon(points[0], radius, circleSegments);
  }
  if (geometryKind === "corridor") {
    const linePoints = parseCoordinates(first(zone.line, zone.points, zone.coords, zone.coordinates));
    const width = finiteNonNegative(first(zone.lineWidthMeters, zone.line_width_meters, zone.width, zone.radius));
    if (linePoints.length < 2 || !width || width <= 0) {
      throw zoneSourceError("zone_source_corridor_path_and_width_required");
    }
    return corridorPolygon(linePoints, width);
  }
  if (points.length < 3) throw zoneSourceError("zone_source_polygon_points_required");
  const ring = [...points];
  if (!samePosition(ring[0], ring.at(-1))) ring.push([...ring[0]]);
  return { type: "Polygon", coordinates: [ring] };
}

function parseCoordinates(value) {
  if (typeof value === "string") {
    return value.split(";").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
      const [latitude, longitude] = entry.split(",").map(Number);
      return position(longitude, latitude);
    });
  }
  if (!Array.isArray(value)) return [];
  return value.map((entry) => {
    if (Array.isArray(entry)) {
      const [latitude, longitude] = entry.map(Number);
      return position(longitude, latitude);
    }
    if (!isPlainObject(entry)) throw zoneSourceError("zone_source_coordinate_invalid");
    return position(
      firstNumber(entry.longitude, entry.lon, entry.lng, entry.x),
      firstNumber(entry.latitude, entry.lat, entry.y),
    );
  });
}

function circlePolygon([longitude, latitude], radiusMeters, segments) {
  const earthRadius = 6_371_008.8;
  const angularDistance = radiusMeters / earthRadius;
  const latitudeRad = radians(latitude);
  const longitudeRad = radians(longitude);
  const ring = [];
  for (let index = 0; index <= segments; index += 1) {
    const bearing = 2 * Math.PI * index / segments;
    const targetLatitude = Math.asin(
      Math.sin(latitudeRad) * Math.cos(angularDistance)
      + Math.cos(latitudeRad) * Math.sin(angularDistance) * Math.cos(bearing),
    );
    const targetLongitude = longitudeRad + Math.atan2(
      Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitudeRad),
      Math.cos(angularDistance) - Math.sin(latitudeRad) * Math.sin(targetLatitude),
    );
    ring.push([degrees(targetLongitude), degrees(targetLatitude)]);
  }
  ring[ring.length - 1] = [...ring[0]];
  return { type: "Polygon", coordinates: [ring] };
}

function corridorPolygon(points, widthMeters) {
  const halfWidth = widthMeters / 2;
  const left = [];
  const right = [];
  for (let index = 0; index < points.length; index += 1) {
    const previous = points[Math.max(0, index - 1)];
    const next = points[Math.min(points.length - 1, index + 1)];
    const meanLatitude = radians((previous[1] + next[1]) / 2);
    const dx = (next[0] - previous[0]) * Math.cos(meanLatitude);
    const dy = next[1] - previous[1];
    const length = Math.hypot(dx, dy);
    if (!length) throw zoneSourceError("zone_source_corridor_zero_length_segment");
    const metersPerDegreeLatitude = 111_320;
    const longitudeOffset = (-dy / length) * halfWidth / (metersPerDegreeLatitude * Math.max(0.01, Math.cos(radians(points[index][1]))));
    const latitudeOffset = (dx / length) * halfWidth / metersPerDegreeLatitude;
    left.push([points[index][0] + longitudeOffset, points[index][1] + latitudeOffset]);
    right.push([points[index][0] - longitudeOffset, points[index][1] - latitudeOffset]);
  }
  const ring = [...left, ...right.reverse(), [...left[0]]];
  return { type: "Polygon", coordinates: [ring] };
}

function revision(value, adapter) {
  const explicit = optionalString(value.sourceRevision);
  if (adapter.sourceKind === "versioned_snapshot") {
    const digest = optionalString(first(value.contentDigest, value.snapshotDigest));
    if (!digest || !/^[a-f0-9]{64}$/.test(digest)) throw zoneSourceError("zone_source_snapshot_digest_required");
    return `${explicit || adapter.sourceRevision}@sha256:${digest}`;
  }
  return explicit || adapter.sourceRevision;
}

function position(longitude, latitude) {
  if (![longitude, latitude].every(Number.isFinite)
    || longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
    throw zoneSourceError("zone_source_coordinate_invalid");
  }
  return [longitude, latitude];
}

function first(...values) {
  return values.find((value) => value !== undefined && value !== null && value !== "");
}

function firstNumber(...values) {
  const value = first(...values);
  const number = typeof value === "number" ? value : Number(String(value ?? "").trim());
  return Number.isFinite(number) ? number : Number.NaN;
}

function finiteNonNegative(value) {
  const number = firstNumber(value);
  return Number.isFinite(number) && number >= 0 ? number : undefined;
}

function optionalBoolean(value) {
  return typeof value === "boolean" ? value : undefined;
}

function normalizeWeeklySchedule(value) {
  if (value === undefined || value === null) return undefined;
  if (!Array.isArray(value)) throw zoneSourceError("zone_source_weekly_schedule_invalid");
  const entries = value.map((entry) => {
    if (!isPlainObject(entry)) throw zoneSourceError("zone_source_weekly_schedule_invalid");
    const day = Number(entry.dayOfWeek);
    const speed = Number(entry.speedLimitKph);
    const start = optionalString(entry.startTime);
    const end = optionalString(entry.endTime);
    if (!Number.isInteger(day) || day < 1 || day > 7
      || !Number.isFinite(speed) || speed < 0 || speed > 200
      || !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(start || "")
      || !/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(end || "")) {
      throw zoneSourceError("zone_source_weekly_schedule_invalid");
    }
    return `${day}@${start}-${end}=${speed}`;
  });
  return [...new Set(entries)].sort();
}

function requiredString(value, code) {
  const result = optionalString(value);
  if (!result) throw zoneSourceError(code);
  return result;
}

function optionalString(value) {
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function iso(value, code) {
  const date = new Date(String(value ?? ""));
  if (Number.isNaN(date.getTime())) throw zoneSourceError(code);
  return date.toISOString();
}

function compact(value) {
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
}

function samePosition(left, right) {
  return Array.isArray(left) && Array.isArray(right) && left[0] === right[0] && left[1] === right[1];
}

function stableJson(value) {
  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
  if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
  return JSON.stringify(value);
}

function byteLength(value) {
  try { return Buffer.byteLength(JSON.stringify(value)); } catch { return Number.POSITIVE_INFINITY; }
}

function deepFreeze(value) {
  if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
  Object.freeze(value);
  Object.values(value).forEach(deepFreeze);
  return value;
}

function isPlainObject(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function radians(value) { return value * Math.PI / 180; }
function degrees(value) { return value * 180 / Math.PI; }

function zoneSourceError(code) {
  return Object.assign(new Error(code), { code });
}
