// Developer-only script: rewrite compact DOM dumps into step-to-step DIFFs.
// NOT for production automation; intended to help understand UI transitions between numbered steps.
//
// Usage:
//
// 1. Generate compact DOM exports from HTML dumps (full DOM per step) using:
//      npx ts-node scripts/dom-export/exportHtmlDumps.ts
//
// 2. Then run THIS script to REPLACE those per-step JSON exports with DIFF objects:
//      npx ts-node scripts/dom-export/buildDomDiffs.ts html-dumps-optimized/iracing-hosted-sessions
//
// After step 2, each *.json in the target directory no longer contains the full DOM,
// but only the diff relative to its predecessor.

const fs = require("fs/promises");
const path = require("path");

type DomElement = {
  el: string;
  x?: string;
  t?: string;
  l?: string;
  p?: string;
  n?: string;
  i?: string;
  d?: string;
  r?: string;
  h?: string;
  [key: string]: unknown;
};

type StepFile = {
  stepId: string;
  num: number;
  suffix: string;
  fileName: string;
  fullPath: string;
};

type MatchedPair = {
  before: DomElement;
  after: DomElement;
};

type DiffObject = {
  from: string | null;
  to: string;
  added: DomElement[];
  removed: DomElement[];
  modified: { before: DomElement; after: DomElement }[];
};

type IdentityProp = "i" | "t" | "l" | "p" | "n";
const identityProps: IdentityProp[] = ["i", "t", "l", "p", "n"];

function parseStepId(fileName: string): StepFile | null {
  const base = fileName.replace(/\.json$/i, "");
  const m = /^(\d+)([a-z]?)/i.exec(base);
  if (!m) return null;
  const num = parseInt(m[1], 10);
  const suffix = (m[2] || "").toLowerCase();
  return {
    stepId: m[1] + suffix,
    num,
    suffix,
    fileName,
    fullPath: "",
  };
}

function sortSteps(steps: StepFile[]): StepFile[] {
  return [...steps].sort((a, b) => {
    if (a.num !== b.num) return a.num - b.num;
    const sa = a.suffix || "";
    const sb = b.suffix || "";
    if (sa === sb) return 0;
    if (!sa) return -1;
    if (!sb) return 1;
    return sa.localeCompare(sb);
  });
}

function getPropValue(e: DomElement, prop: IdentityProp | "r"): string | null {
  const raw = e[prop];
  if (typeof raw !== "string") return null;
  const value = raw.trim();
  return value.length ? value : null;
}

function getIdentityKey(e: DomElement): string | null {
  for (const prop of identityProps) {
    const val = getPropValue(e, prop);
    if (val) return `${prop}:${val}`;
  }
  return null;
}

// Remove repeated entries that have the same identity + role.
function dedupeElements(elems: DomElement[]): DomElement[] {
  const seen = new Set<string>();
  const result: DomElement[] = [];
  for (const elem of elems) {
    const key = getIdentityKey(elem);
    if (!key) {
      result.push(elem);
      continue;
    }
    const role = getPropValue(elem, "r") || "";
    const composite = `${key}|role=${role}`;
    if (seen.has(composite)) continue;
    seen.add(composite);
    result.push(elem);
  }
  return result;
}

function isSameElement(a: DomElement, b: DomElement): boolean {
  const fields: (keyof DomElement)[] = [
    "el",
    "t",
    "l",
    "p",
    "n",
    "i",
    "d",
    "r",
  ];
  for (const f of fields) {
    const av = (a[f] as string | undefined) ?? null;
    const bv = (b[f] as string | undefined) ?? null;
    if (av !== bv) return false;
  }
  return true;
}

function computePairs(
  before: DomElement[],
  after: DomElement[]
): {
  matches: MatchedPair[];
  remainingBefore: DomElement[];
  remainingAfter: DomElement[];
} {
  const beforeIdx = new Set(before.map((_, i) => i));
  const afterIdx = new Set(after.map((_, i) => i));
  const matches: MatchedPair[] = [];

  const matchUsingProp = (prop: IdentityProp) => {
    const afterMap = new Map<string, number[]>();
    for (const ai of afterIdx) {
      const val = getPropValue(after[ai], prop);
      if (!val) continue;
      const arr = afterMap.get(val) || [];
      arr.push(ai);
      afterMap.set(val, arr);
    }

    for (const bi of [...beforeIdx]) {
      const val = getPropValue(before[bi], prop);
      if (!val) continue;
      const candidates = afterMap.get(val);
      if (!candidates || candidates.length !== 1) continue;
      const ai = candidates[0];
      if (!afterIdx.has(ai)) continue;

      matches.push({ before: before[bi], after: after[ai] });
      beforeIdx.delete(bi);
      afterIdx.delete(ai);
    }
  };

  for (const prop of identityProps) {
    matchUsingProp(prop);
  }

  const remainingBefore = [...beforeIdx].map((i) => before[i]);
  const remainingAfter = [...afterIdx].map((i) => after[i]);

  return { matches, remainingBefore, remainingAfter };
}

function reconcileDiff(diff: DiffObject): DiffObject {
  const modified = [...diff.modified];
  const remainingAdded: DomElement[] = [];
  const remainingRemoved: DomElement[] = [];

  const addedMap = new Map<string, DomElement[]>();
  const removedMap = new Map<string, DomElement[]>();

  for (const elem of diff.added) {
    const key = getIdentityKey(elem);
    if (!key) {
      remainingAdded.push(elem);
      continue;
    }
    const arr = addedMap.get(key) || [];
    arr.push(elem);
    addedMap.set(key, arr);
  }

  for (const elem of diff.removed) {
    const key = getIdentityKey(elem);
    if (!key) {
      remainingRemoved.push(elem);
      continue;
    }
    const arr = removedMap.get(key) || [];
    arr.push(elem);
    removedMap.set(key, arr);
  }

  for (const [key, addList] of addedMap.entries()) {
    const remList = removedMap.get(key);
    if (!remList) {
      remainingAdded.push(...addList);
      continue;
    }

    const pairCount = Math.min(addList.length, remList.length);
    for (let i = 0; i < pairCount; i++) {
      const addedElem = addList[i];
      const removedElem = remList[i];
      if (isSameElement(removedElem, addedElem)) {
        continue;
      }
      modified.push({ before: removedElem, after: addedElem });
    }

    if (addList.length > pairCount) {
      remainingAdded.push(...addList.slice(pairCount));
    }
    if (remList.length > pairCount) {
      remainingRemoved.push(...remList.slice(pairCount));
    }
    removedMap.delete(key);
  }

  for (const leftovers of removedMap.values()) {
    remainingRemoved.push(...leftovers);
  }

  return {
    from: diff.from,
    to: diff.to,
    added: remainingAdded,
    removed: remainingRemoved,
    modified,
  };
}

function computeDiffObject(
  fromId: string | null,
  toId: string,
  before: DomElement[] | null,
  after: DomElement[]
): DiffObject {
  const normalizedAfter = dedupeElements(after);

  if (!before) {
    return reconcileDiff({
      from: null,
      to: toId,
      added: normalizedAfter,
      removed: [],
      modified: [],
    });
  }

  const normalizedBefore = dedupeElements(before);
  const { matches, remainingBefore, remainingAfter } = computePairs(
    normalizedBefore,
    normalizedAfter
  );

  const modified: { before: DomElement; after: DomElement }[] = [];
  for (const m of matches) {
    if (!isSameElement(m.before, m.after)) {
      modified.push(m);
    }
  }

  return reconcileDiff({
    from: fromId,
    to: toId,
    added: remainingAfter,
    removed: remainingBefore,
    modified,
  });
}

async function loadDomArray(filePath: string): Promise<DomElement[]> {
  const raw = await fs.readFile(filePath, "utf8");
  const parsed = JSON.parse(raw);

  if (Array.isArray(parsed)) {
    return parsed as DomElement[];
  }

  if (
    parsed &&
    typeof parsed === "object" &&
    Array.isArray((parsed as { added?: unknown }).added) &&
    Array.isArray((parsed as { removed?: unknown }).removed) &&
    Array.isArray((parsed as { modified?: unknown }).modified)
  ) {
    throw new Error(
      `File already looks like a diff, not a raw DOM array: ${filePath}`
    );
  }

  throw new Error(
    `Unexpected JSON structure in ${filePath}; expected an array of DOM elements.`
  );
}

async function rewriteDomExportsAsDiffs(dir: string): Promise<void> {
  const entries = await fs.readdir(dir);
  const stepFiles: StepFile[] = [];

  for (const name of entries) {
    if (!name.endsWith(".json")) continue;
    const parsed = parseStepId(name);
    if (!parsed) continue;
    parsed.fullPath = path.join(dir, name);
    stepFiles.push(parsed);
  }

  if (stepFiles.length === 0) {
    console.log("No JSON files with step-like names found in", dir);
    return;
  }

  const ordered = sortSteps(stepFiles);

  const domByStep = new Map<string, DomElement[]>();
  for (const step of ordered) {
    const arr = await loadDomArray(step.fullPath);
    domByStep.set(step.stepId, arr);
  }

  const first = ordered[0];
  const firstDom = domByStep.get(first.stepId)!;
  const firstDiff = computeDiffObject(null, first.stepId, null, firstDom);
  await fs.writeFile(
    first.fullPath,
    JSON.stringify(firstDiff, null, 2),
    "utf8"
  );

  for (let i = 0; i < ordered.length - 1; i++) {
    const from = ordered[i];
    const to = ordered[i + 1];
    const before = domByStep.get(from.stepId)!;
    const after = domByStep.get(to.stepId)!;

    const diff = computeDiffObject(from.stepId, to.stepId, before, after);
    await fs.writeFile(to.fullPath, JSON.stringify(diff, null, 2), "utf8");
  }

  console.log(
    `Rewrote ${ordered.length} JSON exports in ${dir} as step-to-step diffs.`
  );
}

async function main() {
  const argDir = process.argv[2];
  const dir = argDir
    ? path.resolve(process.cwd(), argDir)
    : path.join(
        process.cwd(),
        "html-dumps-optimized",
        "iracing-hosted-sessions"
      );

  await rewriteDomExportsAsDiffs(dir);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
