import type { SceneSettings } from "../../sceneSettings";

export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 2 as const;
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
  "/api/v1/workspace-layouts/observation.spatial" as const;

export interface ObservationWindowRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export interface ObservationViewportSize {
  width: number;
  height: number;
}

export interface NormalizedObservationWindowRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export interface ObservationLayoutSnapshot {
  visibleSourceIds: readonly string[];
  activeFloatingSourceId: string | null;
  windowRects: Readonly<Record<string, NormalizedObservationWindowRect>>;
  viewportSize: ObservationViewportSize;
}

export interface ProjectedObservationLayout {
  visibleSourceIds: readonly string[];
  activeFloatingSourceId: string | null;
  windowRects: Readonly<Record<string, ObservationWindowRect>>;
}

export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnapshot {
  version: typeof OBSERVATION_WORKSPACE_LAYOUT_VERSION;
  revision: number;
  workspaceId: typeof OBSERVATION_WORKSPACE_ID;
  sceneSettings: SceneSettings;
}

export type WorkspaceLayoutFetch = (
  input: RequestInfo | URL,
  init?: RequestInit,
) => Promise<Response>;

type WireProfile = {
  version: number;
  revision: number;
  workspace_id: string;
  scene_settings: {
    projection: string;
    point_size: number;
    color_mode: string;
    palette: string;
    custom_color: string;
    accumulation_seconds: number;
    show_points: boolean;
    show_trajectory: boolean;
    show_grid: boolean;
    show_labels: boolean;
    show_camera_frustums: boolean;
  };
  visible_source_ids: string[];
  active_floating_source_id: string | null;
  window_rects: Record<string, NormalizedObservationWindowRect>;
  viewport_size: ObservationViewportSize;
};

const PROFILE_KEYS = new Set([
  "version",
  "revision",
  "workspace_id",
  "scene_settings",
  "visible_source_ids",
  "active_floating_source_id",
  "window_rects",
  "viewport_size",
]);
const SCENE_KEYS = new Set([
  "projection",
  "point_size",
  "color_mode",
  "palette",
  "custom_color",
  "accumulation_seconds",
  "show_points",
  "show_trajectory",
  "show_grid",
  "show_labels",
  "show_camera_frustums",
]);
const VIEWPORT_KEYS = new Set(["width", "height"]);
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
const COLOR_MODES = new Set<SceneSettings["colorMode"]>([
  "intensity",
  "height",
  "distance",
  "rgb",
  "class",
]);
const PALETTES = new Set<SceneSettings["palette"]>([
  "turbo",
  "viridis",
  "plasma",
  "grayscale",
  "custom",
]);
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const MAX_SOURCES = 256;
const MAX_VIEWPORT_EDGE = 100_000;
const NORMALIZED_EPSILON = 1e-9;

export class WorkspaceLayoutContractError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "WorkspaceLayoutContractError";
  }
}

export class WorkspaceLayoutApiError extends Error {
  readonly status: number;

  constructor(message: string, status = 0) {
    super(message);
    this.name = "WorkspaceLayoutApiError";
    this.status = status;
  }

  get conflict(): boolean {
    return this.status === 409 || this.status === 412;
  }
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function requireRecord(value: unknown, field: string): Record<string, unknown> {
  if (!isRecord(value)) {
    throw new WorkspaceLayoutContractError(`Поле ${field} должно быть объектом.`);
  }
  return value;
}

function assertExactKeys(
  value: Record<string, unknown>,
  expected: ReadonlySet<string>,
  field: string,
): void {
  const actual = Object.keys(value);
  const unknown = actual.filter((key) => !expected.has(key));
  const missing = [...expected].filter((key) => !(key in value));
  if (unknown.length || missing.length) {
    const details = [
      unknown.length ? `неизвестные: ${unknown.sort().join(", ")}` : "",
      missing.length ? `отсутствуют: ${missing.sort().join(", ")}` : "",
    ].filter(Boolean).join("; ");
    throw new WorkspaceLayoutContractError(`${field}: неверная схема (${details}).`);
  }
}

function requireBoolean(value: unknown, field: string): boolean {
  if (typeof value !== "boolean") {
    throw new WorkspaceLayoutContractError(`Поле ${field} должно быть boolean.`);
  }
  return value;
}

function requireFiniteInRange(
  value: unknown,
  field: string,
  minimum: number,
  maximum: number,
  { integer = false, minimumExclusive = false }: { integer?: boolean; minimumExclusive?: boolean } = {},
): number {
  const belowMinimum = minimumExclusive
    ? typeof value === "number" && value <= minimum
    : typeof value === "number" && value < minimum;
  if (
    typeof value !== "number" ||
    !Number.isFinite(value) ||
    belowMinimum ||
    value > maximum ||
    (integer && !Number.isInteger(value))
  ) {
    throw new WorkspaceLayoutContractError(
      `Поле ${field} должно быть конечным ${integer ? "целым " : ""}числом в допустимом диапазоне.`,
    );
  }
  return value;
}

function requireStableId(value: unknown, field: string): string {
  if (typeof value !== "string" || !SAFE_STABLE_ID.test(value)) {
    throw new WorkspaceLayoutContractError(
      `Поле ${field} должно содержать безопасный стабильный идентификатор источника.`,
    );
  }
  return value;
}

function decodeViewport(value: unknown, field = "viewport_size"): ObservationViewportSize {
  const record = requireRecord(value, field);
  assertExactKeys(record, VIEWPORT_KEYS, field);
  return {
    width: requireFiniteInRange(record.width, `${field}.width`, 1, MAX_VIEWPORT_EDGE),
    height: requireFiniteInRange(record.height, `${field}.height`, 1, MAX_VIEWPORT_EDGE),
  };
}

export function validateObservationViewportSize(
  value: ObservationViewportSize,
): ObservationViewportSize {
  return decodeViewport(value, "viewport");
}

function decodeNormalizedRect(
  value: unknown,
  field: string,
): NormalizedObservationWindowRect {
  const record = requireRecord(value, field);
  assertExactKeys(record, RECT_KEYS, field);
  const rect = {
    x: requireFiniteInRange(record.x, `${field}.x`, 0, 1),
    y: requireFiniteInRange(record.y, `${field}.y`, 0, 1),
    width: requireFiniteInRange(record.width, `${field}.width`, 0, 1, { minimumExclusive: true }),
    height: requireFiniteInRange(record.height, `${field}.height`, 0, 1, { minimumExclusive: true }),
  };
  if (
    rect.x + rect.width > 1 + NORMALIZED_EPSILON ||
    rect.y + rect.height > 1 + NORMALIZED_EPSILON
  ) {
    throw new WorkspaceLayoutContractError(`${field} выходит за нормализованные границы viewport.`);
  }
  return rect;
}

function decodeStableIds(value: unknown): string[] {
  if (!Array.isArray(value) || value.length > MAX_SOURCES) {
    throw new WorkspaceLayoutContractError(
      `Поле visible_source_ids должно быть массивом не более ${MAX_SOURCES} элементов.`,
    );
  }
  const result = value.map((entry, index) => requireStableId(entry, `visible_source_ids[${index}]`));
  if (new Set(result).size !== result.length) {
    throw new WorkspaceLayoutContractError("Поле visible_source_ids содержит повторяющиеся id.");
  }
  return result;
}

function decodeWindowRects(value: unknown): Record<string, NormalizedObservationWindowRect> {
  const record = requireRecord(value, "window_rects");
  if (Object.keys(record).length > MAX_SOURCES) {
    throw new WorkspaceLayoutContractError(
      `Поле window_rects содержит более ${MAX_SOURCES} элементов.`,
    );
  }
  return Object.fromEntries(Object.entries(record).map(([sourceId, rect]) => [
    requireStableId(sourceId, "window_rects key"),
    decodeNormalizedRect(rect, `window_rects.${sourceId}`),
  ]));
}

function requireEnum<T extends string>(
  value: unknown,
  allowed: ReadonlySet<T>,
  field: string,
): T {
  if (typeof value !== "string" || !allowed.has(value as T)) {
    throw new WorkspaceLayoutContractError(`Поле ${field} содержит неизвестное значение.`);
  }
  return value as T;
}

function decodeSceneSettings(value: unknown): SceneSettings {
  const record = requireRecord(value, "scene_settings");
  assertExactKeys(record, SCENE_KEYS, "scene_settings");
  if (typeof record.custom_color !== "string" || !HEX_COLOR.test(record.custom_color)) {
    throw new WorkspaceLayoutContractError("Поле scene_settings.custom_color должно быть цветом #RRGGBB.");
  }
  return {
    projection: requireEnum(record.projection, PROJECTIONS, "scene_settings.projection"),
    pointSize: requireFiniteInRange(record.point_size, "scene_settings.point_size", 0.1, 32),
    colorMode: requireEnum(record.color_mode, COLOR_MODES, "scene_settings.color_mode"),
    palette: requireEnum(record.palette, PALETTES, "scene_settings.palette"),
    customColor: record.custom_color,
    accumulationSeconds: requireFiniteInRange(
      record.accumulation_seconds,
      "scene_settings.accumulation_seconds",
      0,
      3_600,
    ),
    showPoints: requireBoolean(record.show_points, "scene_settings.show_points"),
    showTrajectory: requireBoolean(record.show_trajectory, "scene_settings.show_trajectory"),
    showGrid: requireBoolean(record.show_grid, "scene_settings.show_grid"),
    showLabels: requireBoolean(record.show_labels, "scene_settings.show_labels"),
    showCameraFrustums: requireBoolean(
      record.show_camera_frustums,
      "scene_settings.show_camera_frustums",
    ),
  };
}

export function decodeObservationWorkspaceLayoutProfile(
  value: unknown,
): ObservationWorkspaceLayoutProfile {
  const record = requireRecord(value, "workspace layout");
  assertExactKeys(record, PROFILE_KEYS, "workspace layout");
  if (record.version !== OBSERVATION_WORKSPACE_LAYOUT_VERSION) {
    throw new WorkspaceLayoutContractError(
      `Неподдерживаемая версия workspace layout: ${String(record.version)}.`,
    );
  }
  if (record.workspace_id !== OBSERVATION_WORKSPACE_ID) {
    throw new WorkspaceLayoutContractError("Профиль относится к другой рабочей поверхности.");
  }
  const visibleSourceIds = decodeStableIds(record.visible_source_ids);
  const activeFloatingSourceId = record.active_floating_source_id === null
    ? null
    : requireStableId(record.active_floating_source_id, "active_floating_source_id");
  if (activeFloatingSourceId && !visibleSourceIds.includes(activeFloatingSourceId)) {
    throw new WorkspaceLayoutContractError(
      "Активное плавающее окно должно относиться к видимому источнику.",
    );
  }
  return {
    version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
    revision: requireFiniteInRange(record.revision, "revision", 0, Number.MAX_SAFE_INTEGER, {
      integer: true,
    }),
    workspaceId: OBSERVATION_WORKSPACE_ID,
    sceneSettings: decodeSceneSettings(record.scene_settings),
    visibleSourceIds,
    activeFloatingSourceId,
    windowRects: decodeWindowRects(record.window_rects),
    viewportSize: decodeViewport(record.viewport_size),
  };
}

export function encodeObservationWorkspaceLayoutProfile(
  profile: ObservationWorkspaceLayoutProfile,
): WireProfile {
  const wire: WireProfile = {
    version: profile.version,
    revision: profile.revision,
    workspace_id: profile.workspaceId,
    scene_settings: {
      projection: profile.sceneSettings.projection,
      point_size: profile.sceneSettings.pointSize,
      color_mode: profile.sceneSettings.colorMode,
      palette: profile.sceneSettings.palette,
      custom_color: profile.sceneSettings.customColor,
      accumulation_seconds: profile.sceneSettings.accumulationSeconds,
      show_points: profile.sceneSettings.showPoints,
      show_trajectory: profile.sceneSettings.showTrajectory,
      show_grid: profile.sceneSettings.showGrid,
      show_labels: profile.sceneSettings.showLabels,
      show_camera_frustums: profile.sceneSettings.showCameraFrustums,
    },
    visible_source_ids: [...profile.visibleSourceIds],
    active_floating_source_id: profile.activeFloatingSourceId,
    window_rects: Object.fromEntries(
      Object.entries(profile.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
    ),
    viewport_size: { ...profile.viewportSize },
  };
  // The decoder is the single runtime schema authority for inbound and outbound documents.
  decodeObservationWorkspaceLayoutProfile(wire);
  return wire;
}

function clamp(value: number, minimum: number, maximum: number): number {
  return Math.min(maximum, Math.max(minimum, value));
}

export function normalizeObservationWindowRect(
  rect: ObservationWindowRect,
  viewport: ObservationViewportSize,
): NormalizedObservationWindowRect {
  const safeViewport = decodeViewport(viewport, "viewport");
  for (const [field, value] of Object.entries(rect)) {
    if (typeof value !== "number" || !Number.isFinite(value)) {
      throw new WorkspaceLayoutContractError(`Поле rect.${field} должно быть конечным числом.`);
    }
  }
  const x = clamp(rect.x, 0, Math.max(0, safeViewport.width - 1));
  const y = clamp(rect.y, 0, Math.max(0, safeViewport.height - 1));
  const width = clamp(rect.width, 1, Math.max(1, safeViewport.width - x));
  const height = clamp(rect.height, 1, Math.max(1, safeViewport.height - y));
  return {
    x: x / safeViewport.width,
    y: y / safeViewport.height,
    width: width / safeViewport.width,
    height: height / safeViewport.height,
  };
}

export function denormalizeObservationWindowRect(
  rect: NormalizedObservationWindowRect,
  viewport: ObservationViewportSize,
): ObservationWindowRect {
  const normalized = decodeNormalizedRect(rect, "rect");
  const safeViewport = decodeViewport(viewport, "viewport");
  return {
    x: normalized.x * safeViewport.width,
    y: normalized.y * safeViewport.height,
    width: normalized.width * safeViewport.width,
    height: normalized.height * safeViewport.height,
  };
}

export function validateObservationLayoutSnapshot(
  snapshot: ObservationLayoutSnapshot,
): ObservationLayoutSnapshot {
  const wire = {
    version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
    revision: 0,
    workspace_id: OBSERVATION_WORKSPACE_ID,
    scene_settings: {
      projection: "3d",
      point_size: 1,
      color_mode: "intensity",
      palette: "turbo",
      custom_color: "#ffffff",
      accumulation_seconds: 0,
      show_points: true,
      show_trajectory: true,
      show_grid: true,
      show_labels: false,
      show_camera_frustums: true,
    },
    visible_source_ids: [...snapshot.visibleSourceIds],
    active_floating_source_id: snapshot.activeFloatingSourceId,
    window_rects: snapshot.windowRects,
    viewport_size: snapshot.viewportSize,
  };
  const decoded = decodeObservationWorkspaceLayoutProfile(wire);
  return {
    visibleSourceIds: decoded.visibleSourceIds,
    activeFloatingSourceId: decoded.activeFloatingSourceId,
    windowRects: decoded.windowRects,
    viewportSize: decoded.viewportSize,
  };
}

export function projectObservationLayoutSnapshot(
  snapshot: ObservationLayoutSnapshot,
  knownSourceIds: ReadonlySet<string>,
  viewport: ObservationViewportSize,
): ProjectedObservationLayout {
  const validated = validateObservationLayoutSnapshot(snapshot);
  const targetViewport = decodeViewport(viewport, "viewport");
  const visibleSourceIds = validated.visibleSourceIds.filter((sourceId) => knownSourceIds.has(sourceId));
  const visibleSet = new Set(visibleSourceIds);
  return {
    visibleSourceIds,
    activeFloatingSourceId: validated.activeFloatingSourceId && visibleSet.has(validated.activeFloatingSourceId)
      ? validated.activeFloatingSourceId
      : null,
    windowRects: Object.fromEntries(
      Object.entries(validated.windowRects)
        .filter(([sourceId]) => knownSourceIds.has(sourceId))
        .map(([sourceId, rect]) => [
          sourceId,
          denormalizeObservationWindowRect(rect, targetViewport),
        ]),
    ),
  };
}

async function responseMessage(response: Response, fallback: string): Promise<string> {
  try {
    const body: unknown = await response.json();
    if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
      return body.detail.trim();
    }
  } catch {
    // A non-JSON error body is represented by the stable fallback.
  }
  return fallback;
}

export async function fetchObservationWorkspaceLayoutProfile({
  fetcher = fetch,
}: { fetcher?: WorkspaceLayoutFetch } = {}): Promise<ObservationWorkspaceLayoutProfile | null> {
  const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
    method: "GET",
    headers: { Accept: "application/json" },
    cache: "no-store",
  });
  if (response.status === 404) return null;
  if (!response.ok) {
    throw new WorkspaceLayoutApiError(
      await responseMessage(response, "Не удалось загрузить профиль рабочей поверхности."),
      response.status,
    );
  }
  let body: unknown;
  try {
    body = await response.json();
  } catch {
    throw new WorkspaceLayoutContractError("Сервер вернул профиль не в формате JSON.");
  }
  return decodeObservationWorkspaceLayoutProfile(body);
}

export async function saveObservationWorkspaceLayoutProfile(
  profile: ObservationWorkspaceLayoutProfile,
  { fetcher = fetch }: { fetcher?: WorkspaceLayoutFetch } = {},
): Promise<ObservationWorkspaceLayoutProfile> {
  const wire = encodeObservationWorkspaceLayoutProfile(profile);
  const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
    method: "PUT",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      "If-Match": `"${profile.revision}"`,
    },
    body: JSON.stringify(wire),
  });
  if (!response.ok) {
    throw new WorkspaceLayoutApiError(
      await responseMessage(response, response.status === 409 || response.status === 412
        ? "Профиль уже изменён в другом окне. Обновите данные и повторите сохранение."
        : "Не удалось сохранить профиль рабочей поверхности."),
      response.status,
    );
  }
  let body: unknown;
  try {
    body = await response.json();
  } catch {
    throw new WorkspaceLayoutContractError("Сервер не вернул сохранённый профиль в формате JSON.");
  }
  const saved = decodeObservationWorkspaceLayoutProfile(body);
  if (saved.revision <= profile.revision) {
    throw new WorkspaceLayoutContractError("Сервер не увеличил revision сохранённого профиля.");
  }
  return saved;
}
