import { isPlainObject, type JsonObject, type JsonValue } from "../contracts/json";
import { getDirectSpriteRows, getRawImageFrames, getSpriteRows, normalizeRowsToSize } from "../graphics/rowEncoding";
import { normalizeStringList } from "../shared/normalization";

const IMAGES_ROOT = "images";
const SPRITES_ROOT = "sprites";
const TILES_ROOT = "tiles";

function createRandomIdFragment(): string {
  try {
    const bytes = new Uint8Array(5);
    crypto.getRandomValues(bytes);
    return Array.from(bytes).map((value) => value.toString(16).padStart(2, "0")).join("");
  } catch {
    return Math.random().toString(16).slice(2, 12);
  }
}

function normalizeImageRoles(value: JsonValue | undefined): string[] {
  if (!Array.isArray(value)) {
    return [];
  }
  return Array.from(new Set(
    value
      .map((entry) => String(entry || "").trim().toLowerCase())
      .filter((entry) => entry === "tile" || entry === "sprite"),
  ));
}

function normalizeSpriteLikeRecord(record: JsonObject, defaultIdPrefix: string): JsonObject {
  const nextRecord: JsonObject = { ...record };
  const id = String(nextRecord.id ?? "").trim();
  nextRecord.id = id || `${defaultIdPrefix}_${createRandomIdFragment()}`;

  if (typeof nextRecord.name !== "string") {
    nextRecord.name = "";
  }

  const width = Number(nextRecord.width);
  const height = Number(nextRecord.height);
  const pixelScale = Number(nextRecord.pixelScale);
  const opacity = Number(nextRecord.opacity);
  nextRecord.width = Number.isFinite(width) && width > 0 ? Math.floor(width) : 1;
  nextRecord.height = Number.isFinite(height) && height > 0 ? Math.floor(height) : 1;
  nextRecord.pixelScale = Number.isFinite(pixelScale) && pixelScale > 0 ? Math.floor(pixelScale) : 1;
  nextRecord.opacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1;

  delete nextRecord.palette;

  nextRecord.rows = normalizeRowsToSize(
    getDirectSpriteRows(nextRecord),
    Number(nextRecord.width) || 1,
    Number(nextRecord.height) || 1,
  ) as unknown as JsonValue;

  return nextRecord;
}

function normalizeImageFrameRecord(
  frame: JsonObject,
  fallbackRecord: JsonObject,
  index: number,
): JsonObject {
  const nextFrameId = String(frame.id || "").trim() || `frame_${index}`;
  const normalizedFrame = normalizeSpriteLikeRecord({
    ...frame,
    id: nextFrameId,
    width: Number(frame.width) || Number(fallbackRecord.width) || 1,
    height: Number(frame.height) || Number(fallbackRecord.height) || 1,
    pixelScale: Number(frame.pixelScale) || Number(fallbackRecord.pixelScale) || 1,
    opacity: Number(frame.opacity ?? fallbackRecord.opacity ?? 1),
    rows: Array.isArray(frame.rows) ? frame.rows : getDirectSpriteRows(fallbackRecord),
  }, "frame");
  return {
    ...normalizedFrame,
    id: nextFrameId,
    enabled: frame.enabled !== false,
    index: Number.isFinite(Number(frame.index)) ? Math.max(0, Math.floor(Number(frame.index))) : index,
  };
}

export function normalizeSpritePayloadForSave(payload: JsonValue): JsonValue {
  if (!isPlainObject(payload)) {
    return payload;
  }
  const records = payload[SPRITES_ROOT];
  if (!Array.isArray(records)) {
    return payload;
  }
  return {
    ...payload,
    [SPRITES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeSpriteLikeRecord(entry, "sprite") : entry)),
  };
}

export function normalizeTileRecordForSave(record: JsonObject): JsonObject {
  const nextRecord = normalizeSpriteLikeRecord(record, "tile");
  const symbol = String(nextRecord.symbol ?? "").trim().charAt(0);
  nextRecord.symbol = symbol || String(nextRecord.id || "T").charAt(0) || "T";
  if (typeof nextRecord.description !== "string") {
    nextRecord.description = "";
  }
  return nextRecord;
}

export function normalizeTilesPayloadForSave(payload: JsonValue): JsonValue {
  if (!isPlainObject(payload)) {
    return payload;
  }
  const records = payload[TILES_ROOT];
  if (!Array.isArray(records)) {
    return payload;
  }
  return {
    ...payload,
    [TILES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeTileRecordForSave(entry) : entry)),
  };
}

export function normalizeImagePlayback(value: JsonValue | undefined): "normal" | "rewind" | "stop" {
  const normalized = String(value || "").trim().toLowerCase();
  if (normalized === "rewind" || normalized === "stop") {
    return normalized;
  }
  return "normal";
}

export function normalizeImageRecordForSave(record: JsonObject): JsonObject {
  const nextRecord = normalizeSpriteLikeRecord(record, "image");
  const roles = normalizeImageRoles(nextRecord.roles);
  const inputFrames = getRawImageFrames(record);
  const explicitRows = getDirectSpriteRows(record);
  nextRecord.description = typeof nextRecord.description === "string" ? nextRecord.description : "";
  nextRecord.tags = normalizeStringList(nextRecord.tags);
  nextRecord.roles = roles as unknown as JsonValue;
  let normalizedFrames = inputFrames.map((entry, index) => normalizeImageFrameRecord(entry, nextRecord, index));
  if (normalizedFrames.length <= 0) {
    normalizedFrames = [normalizeImageFrameRecord({
      id: "frame_0",
      rows: explicitRows.length > 0 ? explicitRows : getDirectSpriteRows(nextRecord),
    }, nextRecord, 0)];
  }
  const requestedDefaultFrameId = String(record.defaultFrame || nextRecord.defaultFrame || "").trim();
  const resolvedDefaultFrameId = String(
    normalizedFrames.find((entry) => String(entry.id || "").trim() === requestedDefaultFrameId)?.id
      || normalizedFrames[0]?.id
      || "frame_0",
  ).trim() || "frame_0";
  if (explicitRows.length > 0) {
    normalizedFrames = normalizedFrames.map((entry) => (
      String(entry.id || "").trim() !== resolvedDefaultFrameId
        ? entry
        : normalizeImageFrameRecord({
          ...entry,
          id: resolvedDefaultFrameId,
          rows: explicitRows,
          width: Number(nextRecord.width) || 1,
          height: Number(nextRecord.height) || 1,
          pixelScale: Number(nextRecord.pixelScale) || 1,
          opacity: Number(nextRecord.opacity ?? 1),
        }, nextRecord, normalizedFrames.findIndex((candidate) => String(candidate.id || "").trim() === resolvedDefaultFrameId))
    ));
  }
  nextRecord.defaultFrame = resolvedDefaultFrameId;
  nextRecord.speed = Number.isFinite(Number(record.speed)) && Number(record.speed) >= 0 ? Number(record.speed) : 0;
  nextRecord.playback = normalizeImagePlayback(record.playback);
  nextRecord.frames = normalizedFrames as unknown as JsonValue;
  nextRecord.tileSymbol = roles.includes("tile")
    ? (String(nextRecord.tileSymbol ?? nextRecord.symbol ?? "").trim().charAt(0) || String(nextRecord.id || "T").charAt(0) || "T")
    : "";
  delete nextRecord.rows;
  delete nextRecord.symbol;
  delete nextRecord.graphicRole;
  return nextRecord;
}

export function normalizeImagesPayloadForSave(payload: JsonValue): JsonValue {
  if (!isPlainObject(payload)) {
    return payload;
  }
  const records = payload[IMAGES_ROOT];
  if (!Array.isArray(records)) {
    return payload;
  }
  return {
    ...payload,
    [IMAGES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeImageRecordForSave(entry) : entry)),
  };
}

export function buildTilesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
  const normalizedPayload = normalizeImagesPayloadForSave(payload);
  const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
    ? normalizedPayload.images
    : [];
  return {
    schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
    tiles: records
      .filter((entry): entry is JsonObject => isPlainObject(entry))
      .filter((entry) => normalizeImageRoles(entry.roles).includes("tile"))
      .map((entry) => normalizeTileRecordForSave({
        id: String(entry.id || "").trim(),
        symbol: String(entry.tileSymbol || entry.symbol || "").trim().charAt(0),
        name: String(entry.name || "").trim(),
        description: String(entry.description || "").trim(),
        width: Number(entry.width) || 16,
        height: Number(entry.height) || 16,
        pixelScale: Number(entry.pixelScale) || 1,
        opacity: Number(entry.opacity ?? 1),
        rows: getSpriteRows(entry),
        tags: normalizeStringList(entry.tags),
      })),
  };
}

export function buildSpritesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
  const normalizedPayload = normalizeImagesPayloadForSave(payload);
  const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
    ? normalizedPayload.images
    : [];
  return {
    schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
    sprites: records
      .filter((entry): entry is JsonObject => isPlainObject(entry))
      .filter((entry) => {
        const roles = normalizeImageRoles(entry.roles);
        return roles.includes("sprite") || roles.length === 0;
      })
      .map((entry) => {
        const roles = normalizeImageRoles(entry.roles);
        return normalizeSpriteLikeRecord({
          id: String(entry.id || "").trim(),
          name: String(entry.name || "").trim(),
          description: String(entry.description || "").trim(),
          width: Number(entry.width) || 16,
          height: Number(entry.height) || 16,
          pixelScale: Number(entry.pixelScale) || 1,
          opacity: Number(entry.opacity ?? 1),
          rows: getSpriteRows(entry),
          tags: normalizeStringList(entry.tags),
          graphicRole: roles.includes("sprite") ? "sprite" : "other",
        }, "sprite");
      }),
  };
}

export function mergeImagesPayloadWithTilesPayload(imagesPayload: JsonValue, tilesPayload: JsonValue): JsonObject {
  const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
  const normalizedTilesPayload = normalizeTilesPayloadForSave(tilesPayload);
  const nextImagesById = new Map<string, JsonObject>();
  const nextOrder: string[] = [];
  const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
    ? normalizedImagesPayload.images
    : [];
  existingImages.forEach((entry) => {
    if (!isPlainObject(entry)) {
      return;
    }
    const normalizedEntry = normalizeImageRecordForSave(entry);
    const id = String(normalizedEntry.id || "").trim();
    if (!id) {
      return;
    }
    nextImagesById.set(id, normalizedEntry);
    nextOrder.push(id);
  });
  const incomingTiles = isPlainObject(normalizedTilesPayload) && Array.isArray(normalizedTilesPayload.tiles)
    ? normalizedTilesPayload.tiles
    : [];
  const seenTileIds = new Set<string>();
  incomingTiles.forEach((entry) => {
    if (!isPlainObject(entry)) {
      return;
    }
    const normalizedTile = normalizeTileRecordForSave(entry);
    const id = String(normalizedTile.id || "").trim();
    if (!id) {
      return;
    }
    seenTileIds.add(id);
    const existing = nextImagesById.get(id);
    const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
    const nextImage = normalizeImageRecordForSave({
      ...(existing || {}),
      id,
      name: String(normalizedTile.name || existing?.name || "").trim(),
      description: String(normalizedTile.description || existing?.description || "").trim(),
      width: Number(normalizedTile.width) || Number(existing?.width) || 16,
      height: Number(normalizedTile.height) || Number(existing?.height) || 16,
      pixelScale: Number(normalizedTile.pixelScale) || Number(existing?.pixelScale) || 1,
      opacity: Number(normalizedTile.opacity ?? existing?.opacity ?? 1),
      rows: getSpriteRows(normalizedTile),
      tags: normalizeStringList(normalizedTile.tags ?? existing?.tags),
      roles: Array.from(new Set([...existingRoles, "tile"])),
      tileSymbol: String(normalizedTile.symbol || existing?.tileSymbol || "").trim().charAt(0),
    });
    if (!nextImagesById.has(id)) {
      nextOrder.push(id);
    }
    nextImagesById.set(id, nextImage);
  });
  Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
    const roles = normalizeImageRoles(entry.roles);
    if (!roles.includes("tile") || seenTileIds.has(id)) {
      return;
    }
    const nextRoles = roles.filter((role) => role !== "tile");
    if (nextRoles.length === 0) {
      nextImagesById.delete(id);
      return;
    }
    nextImagesById.set(id, normalizeImageRecordForSave({
      ...entry,
      roles: nextRoles,
      tileSymbol: "",
    }));
  });
  return {
    schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
    images: nextOrder
      .map((id) => nextImagesById.get(id))
      .filter((entry): entry is JsonObject => isPlainObject(entry)),
  };
}

export function mergeImagesPayloadWithSpritesPayload(imagesPayload: JsonValue, spritesPayload: JsonValue): JsonObject {
  const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
  const normalizedSpritesPayload = normalizeSpritePayloadForSave(spritesPayload);
  const nextImagesById = new Map<string, JsonObject>();
  const nextOrder: string[] = [];
  const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
    ? normalizedImagesPayload.images
    : [];
  existingImages.forEach((entry) => {
    if (!isPlainObject(entry)) {
      return;
    }
    const normalizedEntry = normalizeImageRecordForSave(entry);
    const id = String(normalizedEntry.id || "").trim();
    if (!id) {
      return;
    }
    nextImagesById.set(id, normalizedEntry);
    nextOrder.push(id);
  });
  const incomingSprites = isPlainObject(normalizedSpritesPayload) && Array.isArray(normalizedSpritesPayload.sprites)
    ? normalizedSpritesPayload.sprites
    : [];
  const seenSpriteIds = new Set<string>();
  incomingSprites.forEach((entry) => {
    if (!isPlainObject(entry)) {
      return;
    }
    const normalizedSprite = normalizeSpriteLikeRecord(entry, "sprite");
    const id = String(normalizedSprite.id || "").trim();
    if (!id) {
      return;
    }
    seenSpriteIds.add(id);
    const existing = nextImagesById.get(id);
    const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
    const wantsSpriteRole = String(normalizedSprite.graphicRole || "sprite").trim().toLowerCase() !== "other";
    const nextRoles = wantsSpriteRole
      ? Array.from(new Set([...existingRoles, "sprite"]))
      : existingRoles.filter((role) => role !== "sprite");
    const nextImage = normalizeImageRecordForSave({
      ...(existing || {}),
      id,
      name: String(normalizedSprite.name || existing?.name || "").trim(),
      description: String(normalizedSprite.description || existing?.description || "").trim(),
      width: Number(normalizedSprite.width) || Number(existing?.width) || 16,
      height: Number(normalizedSprite.height) || Number(existing?.height) || 16,
      pixelScale: Number(normalizedSprite.pixelScale) || Number(existing?.pixelScale) || 1,
      opacity: Number(normalizedSprite.opacity ?? existing?.opacity ?? 1),
      rows: getSpriteRows(normalizedSprite),
      tags: normalizeStringList(normalizedSprite.tags ?? existing?.tags),
      roles: nextRoles,
      tileSymbol: String(existing?.tileSymbol || "").trim().charAt(0),
    });
    if (!nextImagesById.has(id)) {
      nextOrder.push(id);
    }
    if (nextRoles.length === 0 && !normalizeImageRoles(existing?.roles).includes("tile")) {
      nextImagesById.delete(id);
      return;
    }
    nextImagesById.set(id, nextImage);
  });
  Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
    if (seenSpriteIds.has(id)) {
      return;
    }
    const roles = normalizeImageRoles(entry.roles);
    if (!roles.includes("sprite")) {
      return;
    }
    const nextRoles = roles.filter((role) => role !== "sprite");
    if (nextRoles.length === 0) {
      nextImagesById.delete(id);
      return;
    }
    nextImagesById.set(id, normalizeImageRecordForSave({
      ...entry,
      roles: nextRoles,
    }));
  });
  return {
    schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
    images: nextOrder
      .map((id) => nextImagesById.get(id))
      .filter((entry): entry is JsonObject => isPlainObject(entry)),
  };
}
