/**
 * SDK resolver-parity tripwire (telemetry-only).
 *
 * Checks whether the SDK session resolves the same element id the server
 * patch path would target, then optionally verifies value parity after an
 * in-memory dispatch. Emits `sdk_resolver_shadow` on any divergence.
 *
 * Headline signal: `element_not_found` — the resolver divergence class that
 * caused the v0.6.110 regression. The writer-parity suite (#1533) cannot see
 * this class; this tripwire exists specifically to catch it.
 *
 * Decoupled from `STUDIO_SDK_CUTOVER_ENABLED`. Gated by its own flag
 * `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (default ON during the soak — collect
 * wild telemetry; flip off / remove once resolver parity is proven).
 * Telemetry-only — never writes to disk, never affects the user-visible edit.
 */

import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
import type { PatchOperation } from "./sourcePatcher";
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
import { trackStudioEvent } from "./studioTelemetry";

// ─── Types ────────────────────────────────────────────────────────────────────

export interface SdkResolverMismatch {
  kind: "element_not_found" | "value_mismatch" | "dispatch_error" | "animation_not_found";
  hfId?: string;
  animationId?: string;
  property?: string;
  expected?: string | null;
  actual?: string | null | undefined;
  error?: string;
}

// ─── Op helpers ───────────────────────────────────────────────────────────────

// Drop studio-internal data-hf-* markers the SDK model doesn't represent.
function isShadowableOp(op: PatchOperation): boolean {
  const name =
    op.type === "attribute"
      ? op.property.startsWith("data-")
        ? op.property
        : `data-${op.property}`
      : op.type === "html-attribute"
        ? op.property
        : null;
  return name === null || !name.startsWith("data-hf-");
}

const MAPPED_OP_TYPES = new Set(["inline-style", "text-content", "attribute", "html-attribute"]);

// ─── Read-back helpers ────────────────────────────────────────────────────────

function kebabToCamel(prop: string): string {
  return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}

function normalizeText(v: string | null | undefined): string | null {
  if (v == null) return null;
  const t = v.trim();
  return t === "" ? null : t;
}

type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
type AttrMap = Record<string, string | null>;

/**
 * Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
 * (engine/model.ts resolveScoped), NOT via Composition.getElement.
 *
 * getElement is canonical-only for a bare id by design — it deliberately will
 * not resolve a bare id to a non-canonical (sub-composition) element, so that
 * removeElement(bareId) and getElement(bareId) agree on the same instance
 * (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
 * path dispatches the studio's bare data-hf-id, and dispatch resolves it via
 * resolveScoped, which locates the leaf anywhere (canonical preferred, else
 * first match). So getElement under-resolves a bare leaf that lives inside an
 * inlined sub-composition (scopedId "host/leaf") — exactly the false
 * `element_not_found` this tripwire was emitting for inlined compositions.
 *
 * Mirror resolveScoped here: exact scoped-path match, then canonical bare
 * match, then first bare match — the resolvability dispatch actually has.
 */
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
  const els = session.getElements();
  const exact = els.find((el) => el.scopedId === id);
  if (exact) return exact;
  const matches = els.filter((el) => el.id === id);
  return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
}

function checkStyleOp(
  op: PatchOperation,
  el: FlatEl,
): { expected: string | null; actual: string | null } {
  return {
    expected: op.value ?? null,
    actual: el.inlineStyles[kebabToCamel(op.property)] ?? el.inlineStyles[op.property] ?? null,
  };
}

function checkTextOp(
  op: PatchOperation,
  el: FlatEl,
): { expected: string | null; actual: string | null } {
  return { expected: normalizeText(op.value), actual: normalizeText(el.text) };
}

function checkAttrOp(
  op: PatchOperation,
  el: FlatEl,
): { property: string; expected: string | null; actual: string | null } {
  const property =
    op.type === "attribute"
      ? op.property.startsWith("data-")
        ? op.property
        : `data-${op.property}`
      : op.property;
  return {
    property,
    expected: op.value ?? null,
    actual: (el.attributes as AttrMap)[property] ?? null,
  };
}

function checkOpValue(op: PatchOperation, el: FlatEl, hfId: string): SdkResolverMismatch | null {
  let property: string;
  let expected: string | null;
  let actual: string | null;

  if (op.type === "inline-style") {
    property = op.property;
    ({ expected, actual } = checkStyleOp(op, el));
  } else if (op.type === "text-content") {
    property = "text";
    ({ expected, actual } = checkTextOp(op, el));
  } else if (op.type === "attribute" || op.type === "html-attribute") {
    ({ property, expected, actual } = checkAttrOp(op, el));
  } else {
    return null;
  }

  if (actual === expected) return null;
  return { kind: "value_mismatch", hfId, property, expected, actual };
}

// ─── Core check (pure — testable without flag) ────────────────────────────────

/**
 * Run the resolver shadow check against an already-open SDK session.
 *
 * Returns an array of mismatches (empty = parity). The value-parity check
 * dispatches the ops into the session to read the result back, then UNDOES
 * those mutations via the captured inverse patches before returning — the
 * session ends exactly as it started. This is essential: the session is shared
 * with the cutover path, and a residual shadow mutation would make the
 * subsequent sdkCutoverPersist see before === after and silently fall back to
 * the server path. Telemetry-only; the server path stays authoritative on disk.
 *
 * Exported for unit tests; call `runResolverShadow` at call sites.
 */
export function sdkResolverShadowCheck(
  session: Composition,
  hfId: string,
  ops: PatchOperation[],
): SdkResolverMismatch[] {
  if (!resolveSnapshot(session, hfId)) {
    return [{ kind: "element_not_found", hfId }];
  }

  const shadowable = ops.filter(isShadowableOp);
  if (shadowable.length === 0) return [];

  // Silently skip op batches containing unmapped types — not a resolver bug.
  if (shadowable.some((op) => !MAPPED_OP_TYPES.has(op.type))) return [];

  // Capture the inverse of the shadow dispatch so we can restore the session.
  // `batch` fires a single PatchEvent whose `inversePatches` are already in
  // reverse-apply order (session.ts reverses inside buildPatchEvent), so
  // applyPatches(inverse) undoes the dispatch with no further reordering. If a
  // future SDK refactor ever coalesces batch into a composite with no per-op
  // inverse, this restore breaks — keep batch emitting inverse patches.
  const inverse: JsonPatchOp[] = [];
  const stopCapture = session.on("patch", (e) => inverse.push(...e.inversePatches));
  // restore() runs in `finally` so the patch listener is always removed and the
  // session is always undone — even if checkOpValue throws between dispatch and
  // return. A residual mutation or leaked listener on the shared session is the
  // exact cutover-coupling failure mode this module exists to avoid.
  try {
    try {
      const editOps = patchOpsToSdkEditOps(hfId, shadowable);
      session.batch(() => {
        for (const op of editOps) session.dispatch(op);
      });
    } catch (err) {
      return [{ kind: "dispatch_error", hfId, error: String(err) }];
    }

    const el = resolveSnapshot(session, hfId);
    if (!el) return [{ kind: "element_not_found", hfId }];

    return shadowable
      .map((op) => checkOpValue(op, el, hfId))
      .filter((m): m is SdkResolverMismatch => m !== null);
  } finally {
    stopCapture();
    if (inverse.length > 0) session.applyPatches(inverse);
  }
}

// ─── Telemetry ────────────────────────────────────────────────────────────────

// Redact all user-content values before telemetry: style values and text both
// carry user data. Keep only the length so we can detect truncation without
// leaking the actual bytes.
function redactValue(value: string | null | undefined): string | null | undefined {
  if (value == null) return value;
  return `[redacted len=${value.length}]`;
}

function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatch[] {
  return mismatches.map((m) => ({
    ...m,
    expected: redactValue(m.expected),
    actual: redactValue(m.actual),
  }));
}

/**
 * Run the resolver shadow and emit `sdk_resolver_shadow` telemetry.
 * No-op when `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` is false.
 * Never throws — any exception inside the shadow is swallowed.
 *
 * Side-effect-free on the live session: sdkResolverShadowCheck dispatches into
 * the session to read values back, then undoes those mutations before returning
 * (see below). The session is shared with the cutover path, so it MUST end the
 * call exactly as it started.
 */
export function runResolverShadow(
  session: Composition,
  hfId: string | null | undefined,
  ops: PatchOperation[],
): void {
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
  if (!hfId) return;
  try {
    const mismatches = sdkResolverShadowCheck(session, hfId, ops);
    // Emit only on divergence — parity is silent, matching recordResolverParity
    // and recordAnimationResolverParity. Otherwise this fires a PostHog event on
    // every style/text/attr edit (the editor's chattiest path) at default-ON.
    if (mismatches.length === 0) return;
    trackStudioEvent("sdk_resolver_shadow", {
      hfId,
      mismatchCount: mismatches.length,
      mismatches: JSON.stringify(redactMismatches(mismatches)),
    });
  } catch {
    // never propagate from the shadow path
  }
}

/**
 * Record element-resolution parity for an element-targeted op WITHOUT
 * dispatching. Read-only: emits a single `element_not_found` event when the SDK
 * can't resolve a target the server path is addressing. This extends the
 * tripwire beyond the DOM-edit path (runResolverShadow) to the other
 * element-targeted cutover chokepoints — timing, delete, GSAP-tween add — for
 * the headline resolver signal, without the cost/mutation of a value check.
 *
 * No-op when the shadow flag is off; never throws; never mutates the session.
 */
export function recordResolverParity(
  session: Composition | null | undefined,
  hfId: string | null | undefined,
  opLabel: string,
): void {
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
  if (!session || !hfId) return;
  try {
    if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
    trackStudioEvent("sdk_resolver_shadow", {
      hfId,
      opLabel,
      mismatchCount: 1,
      mismatches: JSON.stringify([
        { kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
      ]),
    });
  } catch {
    // never propagate from the shadow path
  }
}

/**
 * Record animation-resolution parity for an animationId-targeted GSAP op WITHOUT
 * dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
 * the animationId the server GSAP path is addressing — the GSAP-edit-surface
 * analogue of element_not_found. The SDK's resolvable animation ids are the
 * located ids attached to elements (buildAnimationIdMap), so a target absent
 * from every element's animationIds is a resolver divergence.
 *
 * No-op when the shadow flag is off; never throws; never mutates the session.
 */
export function recordAnimationResolverParity(
  session: Composition | null | undefined,
  animationId: string,
  opLabel: string,
): void {
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
  if (!session || !animationId) return;
  try {
    const resolves = session.getElements().some((el) => el.animationIds.includes(animationId));
    if (resolves) return; // SDK locates the animation — parity
    trackStudioEvent("sdk_resolver_shadow", {
      animationId,
      opLabel,
      mismatchCount: 1,
      mismatches: JSON.stringify([
        { kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
      ]),
    });
  } catch {
    // never propagate from the shadow path
  }
}

// ─── Soak gate ────────────────────────────────────────────────────────────────

/**
 * Evaluate the soak-gate exit criterion.
 *
 * A clean soak window has zero `element_not_found` divergences. When that
 * condition holds, resolver parity is proven and the flag can be retired.
 */
export function evaluateSoakGate(divergenceCount: number): "parity-proven" | "divergence-detected" {
  return divergenceCount === 0 ? "parity-proven" : "divergence-detected";
}
