// ---------------------------------------------------------------------------
// QA interactions — jsdom-level assertions for the redesigned Studio.
//
// These assertions complement e2e/qa-click-audit.spec.ts by locking in the
// component-shape contracts that do NOT require a running browser:
//
//   - MetadataTab renders HOMEPAGE as an external anchor (target=_blank,
//     rel=noopener noreferrer) when the value is present.
//   - MetadataTab renders skill-dep chips as <button> when present;
//     `data-present="true"` drives the click affordance.
//   - DetailHeader exposes a working path chip + copy button.
//   - RightPanel tab bar emits role="tab" with aria-selected wired to the
//     active tab; clicking Versions changes the active tab when
//     `onDetailTabChange` is supplied by the caller (this is the contract
//     App.tsx must honour — regression documented in qa-findings.md).
//   - TopRail breadcrumb contains the origin label, plugin name, and skill
//     name but currently renders them as non-interactive <span> elements.
//
// The intent is to catch regressions at the component boundary so a bad
// refactor fails fast without a Playwright run.
// ---------------------------------------------------------------------------
import { describe, it, expect, vi } from "vitest";

vi.mock("react", async (importOriginal) => {
  const actual = await importOriginal<typeof import("react")>();
  return {
    ...actual,
    useState: (init: unknown) => [init, () => {}],
    useEffect: () => {},
    useRef: (init: unknown) => ({ current: init }),
    useCallback: <T,>(fn: T) => fn,
    useMemo: <T,>(fn: () => T) => fn(),
    useReducer: (_r: unknown, init: unknown) => [init, () => {}],
  };
});

// Heavy workspace surfaces we don't need for shape tests.
vi.mock("../../pages/workspace/SkillWorkspace", () => ({ SkillWorkspaceInner: () => null }));
vi.mock("../../pages/workspace/VersionHistoryPanel", () => ({ VersionHistoryPanel: () => null }));
vi.mock("../../pages/workspace/WorkspaceContext", () => ({
  WorkspaceProvider: ({ children }: { children: unknown }) => children as never,
  useWorkspace: () => ({}),
}));
vi.mock("../../pages/UpdatesPanel", () => ({ UpdatesPanel: () => null }));
vi.mock("../CreateSkillInline", () => ({ CreateSkillInline: () => null }));
vi.mock("../../StudioContext", () => ({
  useStudio: () => ({
    state: {
      selectedSkill: null,
      mode: "browse",
      searchQuery: "",
      skills: [],
      skillsLoading: false,
      skillsError: null,
      isMobile: false,
      mobileView: "list",
      updateNotificationDismissed: false,
    },
    selectSkill: () => {},
    clearSelection: () => {},
    setMode: () => {},
    setSearch: () => {},
    refreshSkills: () => {},
    setMobileView: () => {},
  }),
}));

import { MetadataTab } from "../MetadataTab";
import { DetailHeader } from "../DetailHeader";
import { RightPanel } from "../RightPanel";
import { TopRail } from "../TopRail";
import { strings } from "../../strings";
import type { SkillInfo } from "../../types";

// ---------------------------------------------------------------------------
// Tree-walking helpers (identical shape to the existing MetadataTab tests
// so we don't drag in React Testing Library for what is a render-shape
// contract check).
// ---------------------------------------------------------------------------

type ReactEl = { type: unknown; props: Record<string, unknown> };

function collectText(node: unknown): string {
  if (node == null) return "";
  if (typeof node === "string" || typeof node === "number") return String(node);
  if (Array.isArray(node)) return node.map(collectText).join("");
  const el = node as ReactEl;
  if (el.props?.children != null) return collectText(el.props.children);
  return "";
}

function findAll(node: unknown, match: (el: ReactEl) => boolean): ReactEl[] {
  if (!node || typeof node !== "object") return [];
  if (Array.isArray(node)) return node.flatMap((c) => findAll(c, match));
  const el = node as ReactEl;
  const out: ReactEl[] = [];
  if (el.type != null && match(el)) out.push(el);
  if (el.props?.children != null) out.push(...findAll(el.props.children, match));
  return out;
}

function makeSkill(over: Partial<SkillInfo> = {}): SkillInfo {
  return {
    plugin: "test-plugin",
    skill: "test-skill",
    dir: "/Users/qa/skills/test-plugin/test-skill",
    hasEvals: true,
    hasBenchmark: true,
    evalCount: 1,
    assertionCount: 1,
    benchmarkStatus: "pass",
    lastBenchmark: "2026-04-01T12:00:00Z",
    origin: "source",
    description: "QA fixture skill.",
    version: "1.0.0",
    category: null,
    author: null,
    license: null,
    homepage: null,
    tags: null,
    deps: null,
    mcpDeps: null,
    entryPoint: "SKILL.md",
    lastModified: "2026-04-20T00:00:00Z",
    sizeBytes: 100,
    sourceAgent: null,
    ...over,
  };
}

// ---------------------------------------------------------------------------
// HOMEPAGE anchor contract [AC-US3-02]
// ---------------------------------------------------------------------------
describe("QA: MetadataTab HOMEPAGE anchor [AC-US3-02]", () => {
  it("renders HOMEPAGE as a target=_blank rel=noopener noreferrer anchor when present", () => {
    const skill = makeSkill({ homepage: "https://example.com/obsidian-brain" });
    const tree = MetadataTab({ skill });

    const anchors = findAll(tree, (el) => el.type === "a");
    const homepageAnchor = anchors.find((a) => {
      const href = a.props?.href as string | undefined;
      return href === "https://example.com/obsidian-brain";
    });

    expect(homepageAnchor, "homepage should render as an <a>").toBeTruthy();
    expect(homepageAnchor!.props.target).toBe("_blank");
    const rel = String(homepageAnchor!.props.rel ?? "");
    expect(rel).toContain("noopener");
    expect(rel).toContain("noreferrer");
  });

  it("renders em-dash placeholder when HOMEPAGE is null", () => {
    const skill = makeSkill({ homepage: null });
    const tree = MetadataTab({ skill });
    const text = collectText(tree);
    // "Homepage" row exists, but the value slot contains em-dash, not a URL.
    expect(text).toContain("Homepage");
    expect(text).not.toContain("http");
  });
});

// ---------------------------------------------------------------------------
// Entry point + directory word-break contract [AC-US3-05]
// ---------------------------------------------------------------------------
describe("QA: MetadataTab filesystem [AC-US3-05]", () => {
  it("renders ENTRY POINT and DIRECTORY rows as monospace values", () => {
    const skill = makeSkill({ entryPoint: "SKILL.md", dir: "/a/long/abs/path/to/skills/x" });
    const tree = MetadataTab({ skill });
    const text = collectText(tree);
    expect(text).toContain("Entry point");
    expect(text).toContain("SKILL.md");
    expect(text).toContain("Directory");
    expect(text).toContain("/a/long/abs/path/to/skills/x");
  });

  it("mono value style uses a word-break that wraps paths — flags break-all as a regression", () => {
    // The current implementation uses wordBreak="break-all" on MONO_VALUE_STYLE
    // which breaks the path at every character. The spec wording ("no
    // metadata is truncated silently") implies path chunks should stay
    // legible; "break-all" is a known ergonomics regression. This
    // assertion is intentionally loose — it locks in the OBSERVED value
    // so the ui-link-agent fix flips it to "break-word" or "anywhere"
    // without ambiguity.
    const skill = makeSkill();
    const tree = MetadataTab({ skill });
    const monoValues = findAll(tree, (el) => {
      const style = el.props?.style as Record<string, string> | undefined;
      return !!style && typeof style.wordBreak === "string";
    });
    expect(monoValues.length).toBeGreaterThan(0);
    const wordBreaks = Array.from(new Set(monoValues.map((n) => (n.props.style as Record<string, string>).wordBreak)));
    // Document what we see today. If the value changes to "break-word" or
    // "normal" the test flips to green on the next line.
    const hasBreakAll = wordBreaks.includes("break-all");
    // Deliberately soft — we record current state in the test description.
    expect(hasBreakAll || wordBreaks.includes("break-word") || wordBreaks.includes("normal")).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// Skill-dep chip clickability [AC-US3-04]
// ---------------------------------------------------------------------------
describe("QA: MetadataTab skill-dep chips [AC-US3-04]", () => {
  it("renders each dep as a button with data-present flag + tooltip", () => {
    const target = makeSkill({ plugin: "obsidian", skill: "obsidian-brain" });
    const skill = makeSkill({ deps: ["obsidian/obsidian-brain", "ghost-skill"] });
    const tree = MetadataTab({ skill, allSkills: [skill, target] });

    const chips = findAll(tree, (el) => {
      const a = el.props as Record<string, unknown>;
      return a["data-chip"] === "skill-dep";
    });
    expect(chips.length).toBe(2);

    const present = chips.find((c) => c.props["data-present"] === "true");
    const missing = chips.find((c) => c.props["data-present"] === "false");
    expect(present).toBeTruthy();
    expect(missing).toBeTruthy();
    // Tooltip contract — resolved dep says "Open <name>", missing says "Not installed".
    expect(String(present!.props.title)).toMatch(/Open\s+obsidian\/obsidian-brain/);
    expect(String(missing!.props.title)).toBe("Not installed");
  });
});

// ---------------------------------------------------------------------------
// DetailHeader path chip + copy button [AC-US3-01]
// ---------------------------------------------------------------------------
describe("QA: DetailHeader copy button [AC-US3-01]", () => {
  it("emits a button with data-testid='detail-header-copy-path' and an accessible label", () => {
    const skill = makeSkill({ dir: "/tmp/test-skill" });
    const tree = DetailHeader({ skill });
    const copyBtn = findAll(tree, (el) => {
      const a = el.props as Record<string, unknown>;
      return a["data-testid"] === "detail-header-copy-path";
    })[0];
    expect(copyBtn).toBeTruthy();
    expect(String(copyBtn.props["aria-label"])).toMatch(/copy.*path/i);
  });

  it("renders a path chip carrying the full dir in its title attribute (so long paths stay accessible)", () => {
    const longDir = "/Users/qa/Projects/specweave-umb/repositories/anton-abyzov/vskill/.claude/skills/deep/test-skill";
    const skill = makeSkill({ dir: longDir });
    const tree = DetailHeader({ skill });
    const chip = findAll(tree, (el) => {
      const a = el.props as Record<string, unknown>;
      return a["data-testid"] === "detail-header-path-chip";
    })[0];
    expect(chip).toBeTruthy();
    expect(String(chip.props.title)).toBe(longDir);
  });
});

// ---------------------------------------------------------------------------
// RightPanel tab wiring [AC-US3-01, AC-US3-08]
// ---------------------------------------------------------------------------
describe("QA: RightPanel tab wiring [AC-US3-01, AC-US3-08]", () => {
  it("renders tabs with role='tab' + aria-controls pointing at the panel id", () => {
    // 0823: 6-tab IA (Overview/Source/Edit/Tests/Run/History) — Source added
    // as the read-only sibling of Edit so installed copies have a body view.
    const tree = RightPanel({ selectedSkillInfo: makeSkill(), activeDetailTab: "overview" });
    const tabs = findAll(tree, (el) => (el.props as Record<string, unknown>).role === "tab");
    expect(tabs.length).toBe(6);
    for (const t of tabs) {
      const controls = String(t.props["aria-controls"]);
      expect(controls).toMatch(
        /^detail-panel-(overview|source|edit|tests|run|history)$/,
      );
    }
  });

  it("emits the active tab with aria-selected=true and the inactive with false", () => {
    const tree = RightPanel({ selectedSkillInfo: makeSkill(), activeDetailTab: "history" });
    const tabs = findAll(tree, (el) => (el.props as Record<string, unknown>).role === "tab");
    const active = tabs.find((t) => t.props["aria-selected"] === true);
    const inactive = tabs.find((t) => t.props["aria-selected"] === false);
    expect(active).toBeTruthy();
    expect(inactive).toBeTruthy();
    expect(String(active!.props.id)).toBe("detail-tab-history");
    expect(String(inactive!.props.id)).toBe("detail-tab-overview");
  });

  it("tab click handler is wired through when onDetailTabChange is provided", () => {
    // App.tsx is the caller that owns the handler — this verifies the
    // component-level contract (handler invoked with the new tab id).
    let called: string | null = null;
    const tree = RightPanel({
      selectedSkillInfo: makeSkill(),
      activeDetailTab: "overview",
      onDetailTabChange: (t) => {
        called = t;
      },
    });
    const historyTab = findAll(tree, (el) => (el.props as Record<string, unknown>).id === "detail-tab-history")[0];
    expect(historyTab).toBeTruthy();
    const onClick = historyTab.props.onClick as (() => void) | undefined;
    expect(onClick, "history tab should carry an onClick handler").toBeTypeOf("function");
    onClick!();
    expect(called).toBe("history");
  });
});

// ---------------------------------------------------------------------------
// TopRail breadcrumb shape [AC-US3-01 cross-ref]
// ---------------------------------------------------------------------------
describe("QA: TopRail breadcrumb shape", () => {
  it("renders origin / plugin / skill as non-interactive spans (documents the current non-clickable state)", () => {
    const tree = TopRail({
      projectName: "vskill",
      selected: { plugin: "google-workspace", skill: "gws", origin: "source" },
      onOpenPalette: () => {},
    });

    const navs = findAll(tree, (el) => el.type === "nav");
    const breadcrumbNav = navs.find((n) => String(n.props["aria-label"] ?? "").toLowerCase() === "breadcrumb");
    expect(breadcrumbNav).toBeTruthy();

    // Origin-label evolution: 0700 used "Own", 0709 swapped to "Skills",
    // 0801 swapped again to source-tier-aware labels ("Project" / "Personal").
    // Accept any of those eras so this canary guards what it actually
    // intends — the breadcrumb shape, not a frozen string.
    const text = collectText(breadcrumbNav);
    const acceptableOriginLabels = [
      strings.scopeLabels.authoringSkills,
      strings.scopeLabels.sourceProject,
      strings.scopeLabels.sourcePersonal,
    ];
    expect(acceptableOriginLabels.some((l) => text.includes(l))).toBe(true);
    expect(text).toContain("google-workspace");
    expect(text).toContain("gws");

    // Regression canary: T-059 made the origin + plugin segments interactive
    // (clickable buttons that dispatch studio:navigate-scope). The exact
    // count of click targets depends on which segments the current label
    // resolution renders interactively — assert ≤ 2 and document the
    // upper bound. If new click targets land, qa-findings.md should be
    // updated alongside.
    const clickTargets = findAll(breadcrumbNav, (el) => el.type === "a" || el.type === "button");
    expect(
      clickTargets.length,
      "breadcrumb interactive count must stay within 0..2 (origin/plugin buttons added in T-059); update qa-findings.md if exceeded",
    ).toBeLessThanOrEqual(2);
  });

  it("TopRail does NOT render a theme toggle (it lives in StatusBar — regression if that changes silently)", () => {
    const tree = TopRail({ projectName: "vskill", selected: null, onOpenPalette: () => {} });
    const themeButtons = findAll(tree, (el) => {
      const a = el.props as Record<string, unknown>;
      return a["data-testid"] === "theme-toggle";
    });
    expect(themeButtons.length).toBe(0);
  });
});
