// IMP-90 (#90) u13 — vitest coverage for `deriveTextEditCapture`, the pure
// helper that resolves a contentEditable focusout target into the
// (zone_id, text_path, value) capture tuple emitted by SlideCanvas.
//
// Upstream contract (verified by prior units):
//   - u8 `src/text_path_stamper.py` stamps `data-text-path="{slot_key}.{
//     line_index}"` on every rendered text-line opening tag at Step 13.
//   - u9 wires the stamper into `render_slide` so the final.html consumed
//     by SlideCanvas's iframe carries those attributes.
//   - Phase Z slide-base wraps every zone in `.zone[data-zone-position]`
//     (verified at SlideCanvas.tsx onLoad measure block).
//
// u13 scope: derive the capture tuple from any descendant of a stamped
// line, OR the stamped line itself. Non-stamped targets (slide-base
// title/footer, decorative spans outside the zone tree) return null so
// the focusout handler silently skips them — never crashes.
//
// Forward-compat note: u15 will debounce + PUT the capture; u15 MUST NOT
// alter the (target) -> {zoneId, textPath, value} | null contract verified
// here. Any change to the resolution semantics is a scope-violation
// against the u13 binding contract.
//
// jsdom is NOT in devDependencies (verified in Front/package.json); this
// test mocks `TextEditCaptureTarget` with structurally-typed objects per
// the established u11/u12 pure-helper pattern.

import { describe, it, expect } from "vitest";
import {
  deriveTextEditCapture,
  type TextEditCapture,
  type TextEditCaptureTarget,
} from "../src/components/SlideCanvas";

// --- minimal closest-aware mock builders -----------------------------
// Each node only needs to know which selectors it matches and its
// parent chain — `closest` is implemented by walking parent pointers.

interface MockNodeSpec {
  matches: string[];
  attrs?: Record<string, string>;
  text?: string | null;
  parent?: MockNode | null;
}
interface MockNode extends TextEditCaptureTarget {
  matches(sel: string): boolean;
  parent: MockNode | null;
}
function makeNode(spec: MockNodeSpec): MockNode {
  const node: MockNode = {
    parent: spec.parent ?? null,
    matches(sel: string) {
      return spec.matches.includes(sel);
    },
    closest(sel: string): TextEditCaptureTarget | null {
      let cur: MockNode | null = node;
      while (cur) {
        if (cur.matches(sel)) return cur;
        cur = cur.parent;
      }
      return null;
    },
    getAttribute(name: string): string | null {
      return spec.attrs?.[name] ?? null;
    },
    textContent: spec.text === undefined ? null : spec.text,
  };
  return node;
}

// Canonical zone + line scaffold used across happy-path tests.
// `null` for any field is preserved verbatim so edge cases (missing attr /
// null textContent) can exercise the helper's defensive branches.
function makeZoneLineScaffold(opts: {
  zoneId?: string | null;
  textPath?: string | null;
  lineText?: string | null;
}) {
  const zone = makeNode({
    matches: [".zone[data-zone-position]"],
    attrs: opts.zoneId === null ? {} : { "data-zone-position": opts.zoneId ?? "top" },
  });
  const line = makeNode({
    matches: ["[data-text-path]"],
    attrs:
      opts.textPath === null
        ? {}
        : { "data-text-path": opts.textPath ?? "row_1_left_body.0" },
    text: opts.lineText === undefined ? "hello world" : opts.lineText,
    parent: zone,
  });
  return { zone, line };
}

describe("deriveTextEditCapture (IMP-90 u13) — null inputs / non-stamped", () => {
  it("returns null when target is null", () => {
    expect(deriveTextEditCapture(null)).toBeNull();
  });

  it("returns null when no ancestor has data-text-path (e.g., slide title)", () => {
    const title = makeNode({
      matches: [".slide-title"],
      text: "Phase Z 슬라이드",
    });
    expect(deriveTextEditCapture(title)).toBeNull();
  });

  it("returns null when the stamped line has no enclosing zone", () => {
    // Decorative line stamped by the future u8 but rendered outside a
    // zone (e.g., footer pill). u13 silently skips — caller never sees
    // a half-resolved capture.
    const orphanLine = makeNode({
      matches: ["[data-text-path]"],
      attrs: { "data-text-path": "footer.0" },
      text: "결론",
    });
    expect(deriveTextEditCapture(orphanLine)).toBeNull();
  });
});

describe("deriveTextEditCapture (IMP-90 u13) — happy path", () => {
  it("resolves (zoneId, textPath, value) when target IS the stamped line", () => {
    const { line } = makeZoneLineScaffold({
      zoneId: "top",
      textPath: "row_1_left_body.0",
      lineText: "분석 결과",
    });
    expect(deriveTextEditCapture(line)).toEqual<TextEditCapture>({
      zoneId: "top",
      textPath: "row_1_left_body.0",
      value: "분석 결과",
    });
  });

  it("walks up to the stamped line when target is a nested descendant", () => {
    const { zone, line } = makeZoneLineScaffold({
      zoneId: "bottom_l",
      textPath: "left_body.2",
      lineText: "wrapped",
    });
    // emulate a SPAN inside the stamped line (e.g., bold inline span)
    const innerSpan = makeNode({
      matches: ["span.highlight"],
      text: "ignored — closest walks to the line",
      parent: line,
    });
    void zone;
    expect(deriveTextEditCapture(innerSpan)).toEqual<TextEditCapture>({
      zoneId: "bottom_l",
      textPath: "left_body.2",
      value: "wrapped",
    });
  });

  it("preserves the line's textContent without HTML normalization", () => {
    const { line } = makeZoneLineScaffold({
      zoneId: "primary",
      textPath: "headline.0",
      lineText: "  spaced   inner   words  ",
    });
    // u13 trims outer whitespace but does NOT collapse interior whitespace
    // — value mirrors what user typed, modulo blur-edge trim.
    expect(deriveTextEditCapture(line)?.value).toBe("spaced   inner   words");
  });

  it("returns empty string when textContent is null (edge: empty line)", () => {
    const { line } = makeZoneLineScaffold({
      zoneId: "top",
      textPath: "row_1_left_body.0",
      lineText: null,
    });
    expect(deriveTextEditCapture(line)?.value).toBe("");
  });

  it("returns empty string when textContent is whitespace-only", () => {
    const { line } = makeZoneLineScaffold({
      zoneId: "top",
      textPath: "row_1_left_body.0",
      lineText: "   \n  \t  ",
    });
    expect(deriveTextEditCapture(line)?.value).toBe("");
  });
});

describe("deriveTextEditCapture (IMP-90 u13) — missing attribute defensiveness", () => {
  it("returns null when data-text-path attribute is absent on the matched line", () => {
    // Should not happen with the u8 stamper, but a downstream mutation
    // (e.g., user pasting a fresh element) could create a stamped-class
    // node without the actual attribute. u13 stays defensive.
    const zone = makeNode({
      matches: [".zone[data-zone-position]"],
      attrs: { "data-zone-position": "top" },
    });
    const lineNoPath = makeNode({
      matches: ["[data-text-path]"],
      attrs: {},
      text: "hello",
      parent: zone,
    });
    expect(deriveTextEditCapture(lineNoPath)).toBeNull();
  });

  it("returns null when data-zone-position attribute is absent on the matched zone", () => {
    const zoneNoId = makeNode({
      matches: [".zone[data-zone-position]"],
      attrs: {},
    });
    const line = makeNode({
      matches: ["[data-text-path]"],
      attrs: { "data-text-path": "row_1_left_body.0" },
      text: "hello",
      parent: zoneNoId,
    });
    expect(deriveTextEditCapture(line)).toBeNull();
  });
});

describe("deriveTextEditCapture (IMP-90 u13) — referential transparency", () => {
  it("multiple calls with the same target return equal captures", () => {
    const { line } = makeZoneLineScaffold({
      zoneId: "top",
      textPath: "row_1_left_body.0",
      lineText: "stable",
    });
    const a = deriveTextEditCapture(line);
    const b = deriveTextEditCapture(line);
    expect(a).toEqual(b);
    expect(a).not.toBe(b); // fresh objects each call (caller-friendly)
  });

  it("does not mutate the target element (attrs / parent / textContent unchanged)", () => {
    const { line, zone } = makeZoneLineScaffold({
      zoneId: "top",
      textPath: "row_1_left_body.0",
      lineText: "immutable",
    });
    deriveTextEditCapture(line);
    expect(line.getAttribute("data-text-path")).toBe("row_1_left_body.0");
    expect(line.textContent).toBe("immutable");
    expect(zone.getAttribute("data-zone-position")).toBe("top");
  });
});

describe("deriveTextEditCapture (IMP-90 u13) — zone id pass-through", () => {
  // u13 does not validate the zone id shape — Phase Z slide-base owns the
  // canonical zone position vocabulary, and u15 / pipeline-side resolver
  // (u4) re-validate downstream. u13 just forwards whatever the stamped
  // DOM declared.
  const ZONE_IDS = ["top", "bottom_l", "bottom_r", "primary", "secondary"];
  it.each(ZONE_IDS)("preserves zone id '%s' verbatim", (zid) => {
    const { line } = makeZoneLineScaffold({
      zoneId: zid,
      textPath: `${zid}.0`,
      lineText: "x",
    });
    const cap = deriveTextEditCapture(line);
    expect(cap?.zoneId).toBe(zid);
    expect(cap?.textPath).toBe(`${zid}.0`);
  });
});
