import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join, resolve, sep } from "node:path";

export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2";
export const ZONE_SOURCE_MANIFEST_SCHEMA_VERSION = "nodedc.zone-source-manifest/v1";

const PROFILE_ID = /^[a-z][a-z0-9._-]{2,95}$/;
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/;
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
const MAX_ZONES = 5000;
const MAX_SCHEDULE_ROWS = 50_000;
const MAX_POSITIONS = 250_000;
const PROFILE_POLICIES = Object.freeze({
  "moscow-pmd-slow-zones": Object.freeze({
    authorityId: "moscow-department-of-transport",
    datasetId: "pmd-slow-zones",
    adapterIds: Object.freeze(["depttrans-mmap-snapshot-v1", "depttrans-api-snapshot-v1"]),
    timezone: "Europe/Moscow",
  }),
});

export async function loadZoneSourceProfile(root, profileId) {
  if (!PROFILE_ID.test(String(profileId || ""))) throw snapshotError("zone_source_profile_id_invalid");
  const profileRoot = withinRoot(root, profileId);
  const manifest = parseJson(
    await readBounded(withinRoot(profileRoot, "manifest.json"), 256 * 1024),
    "zone_source_manifest_json_invalid",
  );
  validateManifest(manifest, profileId);

  const geometryPath = withinRoot(profileRoot, manifest.geometry.file);
  const schedulePath = withinRoot(profileRoot, manifest.schedule.file);
  const [geometryBytes, scheduleBytes] = await Promise.all([
    readBounded(geometryPath, MAX_SOURCE_BYTES),
    readBounded(schedulePath, MAX_SOURCE_BYTES),
  ]);
  if (geometryBytes.byteLength + scheduleBytes.byteLength > MAX_SOURCE_BYTES) {
    throw snapshotError("zone_source_snapshot_bytes_exceeded");
  }

  const geometryDigest = sha256(geometryBytes);
  const scheduleDigest = sha256(scheduleBytes);
  if (geometryDigest !== manifest.geometry.sha256) throw snapshotError("zone_source_geometry_digest_mismatch");
  if (scheduleDigest !== manifest.schedule.sha256) throw snapshotError("zone_source_schedule_digest_mismatch");

  const geojson = parseJson(geometryBytes, "zone_source_geojson_invalid");
  const rawSchedule = parseJson(scheduleBytes, "zone_source_schedule_json_invalid");
  const generation = normalizeGeneration(manifest, geojson, rawSchedule, {
    geometryDigest,
    scheduleDigest,
    sourceBytes: geometryBytes.byteLength + scheduleBytes.byteLength,
  });
  const body = Buffer.from(`${JSON.stringify(generation)}\n`);
  if (body.byteLength > MAX_SOURCE_BYTES) throw snapshotError("zone_source_generation_bytes_exceeded");
  return Object.freeze({ generation: deepFreeze(generation), body, etag: `"sha256:${generation.contentDigest}"` });
}

function normalizeGeneration(manifest, geojson, rawSchedule, digests) {
  if (!geojson || geojson.type !== "FeatureCollection" || !Array.isArray(geojson.features)) {
    throw snapshotError("zone_source_feature_collection_required");
  }
  if (!geojson.features.length || geojson.features.length > MAX_ZONES) {
    throw snapshotError("zone_source_zone_limit_exceeded");
  }
  if (!Array.isArray(rawSchedule) || !rawSchedule.length || rawSchedule.length > MAX_SCHEDULE_ROWS) {
    throw snapshotError("zone_source_schedule_limit_exceeded");
  }

  const features = new Map();
  let positionCount = 0;
  for (const [index, feature] of geojson.features.entries()) {
    if (!feature || feature.type !== "Feature" || !isPlainObject(feature.properties)) {
      throw snapshotError(`zone_source_feature_${index}_invalid`);
    }
    const nativeId = nativeZoneId(feature.properties.pmd_slow_zone_id);
    if (features.has(nativeId)) throw snapshotError("zone_source_identity_duplicate");
    positionCount += validateGeometry(feature.geometry, index);
    if (positionCount > MAX_POSITIONS) throw snapshotError("zone_source_position_limit_exceeded");
    features.set(nativeId, feature);
  }

  const scheduleByZone = new Map();
  const scheduleIds = new Set();
  const scheduleKeys = new Set();
  for (const [index, row] of rawSchedule.entries()) {
    if (!isPlainObject(row)) throw snapshotError(`zone_source_schedule_${index}_invalid`);
    const rowId = nativeScheduleId(row.id);
    if (scheduleIds.has(rowId)) throw snapshotError("zone_source_schedule_identity_duplicate");
    scheduleIds.add(rowId);
    const zoneId = nativeZoneId(row.pmd_slow_zone_id);
    if (!features.has(zoneId)) throw snapshotError("zone_source_schedule_zone_missing");
    const dayOfWeek = integer(row.day_of_week_num, 1, 7, "zone_source_schedule_day_invalid");
    const speedLimitKph = finite(row.speed_limit, 0, 200, "zone_source_schedule_speed_invalid");
    const startTime = clock(row.start_time, "zone_source_schedule_start_invalid");
    const endTime = clock(row.end_time, "zone_source_schedule_end_invalid");
    const key = `${zoneId}\u0000${dayOfWeek}\u0000${startTime}\u0000${endTime}`;
    if (scheduleKeys.has(key)) throw snapshotError("zone_source_schedule_interval_duplicate");
    scheduleKeys.add(key);
    const normalized = Object.freeze({ dayOfWeek, startTime, endTime, speedLimitKph });
    const entries = scheduleByZone.get(zoneId) || [];
    entries.push(normalized);
    scheduleByZone.set(zoneId, entries);
  }

  const zones = [];
  let excludedZoneCount = 0;
  for (const [nativeId, feature] of features) {
    const properties = feature.properties;
    if (properties.archive_flg === true || properties.delete_flg === true) {
      excludedZoneCount += 1;
      continue;
    }
    const schedule = [...(scheduleByZone.get(nativeId) || [])].sort(compareSchedule);
    if (!schedule.length || new Set(schedule.map((entry) => entry.dayOfWeek)).size !== 7) {
      throw snapshotError("zone_source_schedule_week_incomplete");
    }
    const speeds = [...new Set(schedule.map((entry) => entry.speedLimitKph))];
    zones.push(compact({
      nativeId,
      displayName: requiredString(properties.pmd_slow_zone_nm, "zone_source_name_required"),
      geometry: structuredClone(feature.geometry),
      areaSquareMeters: optionalFinite(properties.area, 0, Number.MAX_SAFE_INTEGER),
      description: optionalString(properties.comment),
      appliesToKicksharing: boolean(properties.kick_sharing, "zone_source_kicksharing_flag_invalid"),
      appliesToCouriers: boolean(properties.courier, "zone_source_courier_flag_invalid"),
      sourceCreatedAt: optionalString(properties.create_dttm),
      sourceUpdatedAt: optionalString(properties.update_dttm),
      maxSpeedKph: speeds.length === 1 ? speeds[0] : undefined,
      weeklySpeedSchedule: schedule,
    }));
  }
  zones.sort((left, right) => compareNativeIds(left.nativeId, right.nativeId));

  const contentDigest = sha256(Buffer.from(`${digests.geometryDigest}\n${digests.scheduleDigest}\n`));
  return {
    schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION,
    profileId: manifest.profileId,
    authorityId: manifest.authorityId,
    datasetId: manifest.datasetId,
    adapterId: manifest.adapterId,
    sourceRevision: manifest.sourceRevision,
    generatedAt: iso(manifest.generatedAt, "zone_source_generated_at_invalid"),
    timezone: manifest.timezone,
    complete: true,
    contentDigest,
    zones,
    metadata: {
      sourceZoneCount: features.size,
      publishedZoneCount: zones.length,
      excludedZoneCount,
      scheduleRowCount: rawSchedule.length,
      positionCount,
      sourceBytes: digests.sourceBytes,
      geometrySha256: digests.geometryDigest,
      scheduleSha256: digests.scheduleDigest,
    },
  };
}

function validateManifest(value, requestedProfileId) {
  if (!isPlainObject(value) || value.schemaVersion !== ZONE_SOURCE_MANIFEST_SCHEMA_VERSION) {
    throw snapshotError("zone_source_manifest_schema_invalid");
  }
  if (value.profileId !== requestedProfileId || !PROFILE_ID.test(value.profileId)) {
    throw snapshotError("zone_source_manifest_profile_mismatch");
  }
  const policy = PROFILE_POLICIES[requestedProfileId];
  if (!policy) throw snapshotError("zone_source_profile_unsupported");
  for (const key of ["authorityId", "datasetId", "adapterId"]) {
    if (!IDENTIFIER.test(String(value[key] || ""))) throw snapshotError(`zone_source_manifest_${key}_invalid`);
  }
  if (value.authorityId !== policy.authorityId) throw snapshotError("zone_source_manifest_authority_mismatch");
  if (value.datasetId !== policy.datasetId) throw snapshotError("zone_source_manifest_dataset_mismatch");
  if (!policy.adapterIds.includes(value.adapterId)) throw snapshotError("zone_source_manifest_adapter_unsupported");
  requiredString(value.sourceRevision, "zone_source_manifest_revision_invalid");
  iso(value.generatedAt, "zone_source_manifest_generated_at_invalid");
  if (value.timezone !== policy.timezone) throw snapshotError("zone_source_manifest_timezone_invalid");
  for (const [name, artifact] of [["geometry", value.geometry], ["schedule", value.schedule]]) {
    if (!isPlainObject(artifact) || !safeFileName(artifact.file) || !SHA256.test(String(artifact.sha256 || ""))) {
      throw snapshotError(`zone_source_manifest_${name}_invalid`);
    }
  }
}

function validateGeometry(value, index) {
  if (!isPlainObject(value) || !new Set(["Polygon", "MultiPolygon"]).has(value.type)) {
    throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
  }
  const polygons = value.type === "Polygon" ? [value.coordinates] : value.coordinates;
  if (!Array.isArray(polygons) || !polygons.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
  let positions = 0;
  for (const polygon of polygons) {
    if (!Array.isArray(polygon) || !polygon.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`);
    for (const ring of polygon) {
      if (!Array.isArray(ring) || ring.length < 4) throw snapshotError(`zone_source_feature_${index}_ring_invalid`);
      for (const point of ring) {
        if (!Array.isArray(point) || point.length < 2) throw snapshotError(`zone_source_feature_${index}_position_invalid`);
        const longitude = Number(point[0]);
        const latitude = Number(point[1]);
        if (!Number.isFinite(longitude) || !Number.isFinite(latitude)
          || longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
          throw snapshotError(`zone_source_feature_${index}_position_invalid`);
        }
        positions += 1;
      }
      const first = ring[0];
      const last = ring.at(-1);
      if (first[0] !== last[0] || first[1] !== last[1]) throw snapshotError(`zone_source_feature_${index}_ring_unclosed`);
    }
  }
  return positions;
}

function compareSchedule(left, right) {
  return left.dayOfWeek - right.dayOfWeek
    || left.startTime.localeCompare(right.startTime)
    || left.endTime.localeCompare(right.endTime)
    || left.speedLimitKph - right.speedLimitKph;
}

function compareNativeIds(left, right) {
  const a = Number(left);
  const b = Number(right);
  return Number.isSafeInteger(a) && Number.isSafeInteger(b) ? a - b : left.localeCompare(right);
}

function nativeZoneId(value) {
  const normalized = String(value ?? "").trim();
  if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_native_identity_invalid");
  return normalized;
}

function nativeScheduleId(value) {
  const normalized = String(value ?? "").trim();
  if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_schedule_identity_invalid");
  return normalized;
}

async function readBounded(path, maxBytes) {
  const value = await readFile(path);
  if (value.byteLength > maxBytes) throw snapshotError("zone_source_file_bytes_exceeded");
  return value;
}

function withinRoot(root, child) {
  const base = resolve(root);
  const target = resolve(base, child);
  if (target !== base && !target.startsWith(`${base}${sep}`)) throw snapshotError("zone_source_path_escape_rejected");
  return target;
}

function safeFileName(value) {
  return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
}

function parseJson(value, code) {
  try { return JSON.parse(value.toString("utf8")); } catch { throw snapshotError(code); }
}

function sha256(value) {
  return createHash("sha256").update(value).digest("hex");
}

function integer(value, min, max, code) {
  const number = Number(value);
  if (!Number.isInteger(number) || number < min || number > max) throw snapshotError(code);
  return number;
}

function finite(value, min, max, code) {
  const number = Number(value);
  if (!Number.isFinite(number) || number < min || number > max) throw snapshotError(code);
  return number;
}

function optionalFinite(value, min, max) {
  if (value === undefined || value === null || value === "") return undefined;
  const number = Number(value);
  return Number.isFinite(number) && number >= min && number <= max ? number : undefined;
}

function boolean(value, code) {
  if (typeof value !== "boolean") throw snapshotError(code);
  return value;
}

function clock(value, code) {
  const normalized = String(value ?? "").trim();
  if (!TIME.test(normalized)) throw snapshotError(code);
  return normalized;
}

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

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

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

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

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

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

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