import { describe, expect, test } from "bun:test";
import {
  capDescription,
  pickFields,
  shapeAssetHit,
  shapeForCommand,
  shapeProposalAcceptOutput,
  shapeProposalDiffOutput,
  shapeProposalEntry,
  shapeProposalListOutput,
  shapeProposalProducerOutput,
  shapeProposalRejectOutput,
  shapeProposalShowOutput,
  shapeRegistrySearchOutput,
  shapeSearchHit,
  shapeSearchHitForAgent,
  shapeSearchOutput,
  shapeShowOutput,
  truncateDescription,
} from "../src/output/shapes";

describe("pickFields", () => {
  test("returns only requested fields, in the requested order", () => {
    const source = { a: 1, b: 2, c: 3, d: 4 };
    expect(pickFields(source, ["b", "d"])).toEqual({ b: 2, d: 4 });
  });

  test("omits fields that are absent", () => {
    expect(pickFields({ a: 1 }, ["a", "b"])).toEqual({ a: 1 });
  });

  test("omits fields whose value is undefined", () => {
    expect(pickFields({ a: 1, b: undefined }, ["a", "b"])).toEqual({ a: 1 });
  });

  test("preserves null values explicitly", () => {
    expect(pickFields({ a: 1, b: null }, ["a", "b"])).toEqual({ a: 1, b: null });
  });
});

describe("truncateDescription", () => {
  test("returns short descriptions unchanged", () => {
    expect(truncateDescription("hello", 100)).toBe("hello");
  });

  test("collapses whitespace", () => {
    expect(truncateDescription("hello   world\n\nagain", 100)).toBe("hello world again");
  });

  test("truncates to a word boundary when possible", () => {
    const long = "the quick brown fox jumps over the lazy dog repeatedly";
    const result = truncateDescription(long, 25);
    // Body is up to limit-1 chars; `...` is appended → realistic max = limit+2.
    expect(result.length).toBeLessThanOrEqual(25 + 2);
    expect(result.endsWith("...")).toBe(true);
    // Should not split on a word
    const beforeEllipsis = result.slice(0, -3).trimEnd();
    expect(long).toContain(beforeEllipsis);
  });

  test("falls back to hard truncation when no word boundary is reasonable", () => {
    const noSpaces = "x".repeat(40);
    const result = truncateDescription(noSpaces, 10);
    expect(result.length).toBeLessThanOrEqual(10 + 2);
    expect(result.endsWith("...")).toBe(true);
  });
});

describe("capDescription", () => {
  test("caps a long description", () => {
    const hit = { name: "x", description: "a".repeat(500) };
    const capped = capDescription(hit, 100);
    // truncateDescription appends `...`, so the resulting body is up to limit+2.
    expect((capped.description as string).length).toBeLessThanOrEqual(100 + 2);
    expect(capped.name).toBe("x");
  });

  test("leaves a short description alone", () => {
    const hit = { name: "x", description: "short" };
    expect(capDescription(hit, 100)).toEqual({ name: "x", description: "short" });
  });

  test("ignores hits without a string description", () => {
    const hit = { name: "x", description: 42 as unknown };
    expect(capDescription(hit, 100)).toEqual(hit);
  });
});

describe("shapeSearchHit — local stash hits", () => {
  const fullHit = {
    type: "skill",
    name: "deploy",
    description: "Deploy the app",
    ref: "skill:deploy",
    action: "akm show skill:deploy",
    score: 0.42,
    estimatedTokens: 120,
    origin: "local:.",
    tags: ["ops"],
    whyMatched: "name match",
  };

  test("brief keeps only type/name/ref/action/estimatedTokens", () => {
    // REC-03: ref is now included at brief so agents can run `akm show <ref>`
    expect(shapeSearchHit(fullHit, "brief")).toEqual({
      type: "skill",
      name: "deploy",
      ref: "skill:deploy",
      action: "akm show skill:deploy",
      estimatedTokens: 120,
    });
  });

  test("normal adds description and score (and caps description)", () => {
    const out = shapeSearchHit(fullHit, "normal");
    expect(out).toMatchObject({
      type: "skill",
      name: "deploy",
      description: "Deploy the app",
      action: "akm show skill:deploy",
      score: 0.42,
      estimatedTokens: 120,
    });
    expect(out).not.toHaveProperty("ref");
    expect(out).not.toHaveProperty("origin");
    expect(out).not.toHaveProperty("tags");
  });

  test("full passes the hit through verbatim", () => {
    expect(shapeSearchHit(fullHit, "full")).toEqual(fullHit);
  });
});

describe("shapeSearchHit — registry hits", () => {
  // v1 spec §4.2: registry hits no longer surface a `curated` boolean. They
  // may surface optional `warnings` when a provider has non-fatal issues.
  const registryHit = {
    type: "registry",
    name: "azure-ops",
    description: "Azure ops kit",
    action: "akm add npm:azure-ops",
    warnings: ["registry rate limit hit, results may be stale"],
    id: "npm:azure-ops",
    score: 0.7,
  };

  test("brief keeps name/score (QA #28: brief now projects name+installRef+score)", () => {
    // Brief now includes name (normalised from title if needed) and score so
    // callers can use the result without --detail full.
    const result = shapeSearchHit(registryHit, "brief");
    expect(result.name).toBe("azure-ops");
    expect(result.score).toBe(0.7);
    // action is no longer in brief output — use installRef to act
    expect(result).not.toHaveProperty("curated");
    // brief intentionally omits warnings to keep payloads small
    expect(result).not.toHaveProperty("warnings");
  });

  test("normal adds description and surfaces optional warnings", () => {
    const out = shapeSearchHit(registryHit, "normal");
    expect(out).toMatchObject({
      name: "azure-ops",
      description: "Azure ops kit",
      action: "akm add npm:azure-ops",
      warnings: ["registry rate limit hit, results may be stale"],
    });
    expect(out).not.toHaveProperty("curated");
  });

  test("full passes through and never re-adds curated", () => {
    const out = shapeSearchHit(registryHit, "full");
    expect(out).toEqual(registryHit);
    expect(out).not.toHaveProperty("curated");
  });
});

describe("shapeSearchHitForAgent", () => {
  test("includes ref + caps description", () => {
    const hit = {
      type: "skill",
      name: "deploy",
      ref: "skill:deploy",
      description: "long ".repeat(100),
      action: "akm show skill:deploy",
      score: 0.5,
      estimatedTokens: 100,
      tags: ["ops"],
      origin: "local:.",
    };
    const out = shapeSearchHitForAgent(hit);
    expect(out).toMatchObject({
      name: "deploy",
      ref: "skill:deploy",
      type: "skill",
      action: "akm show skill:deploy",
      score: 0.5,
      estimatedTokens: 100,
    });
    expect(out).not.toHaveProperty("tags");
    expect(out).not.toHaveProperty("origin");
    expect(typeof out.description).toBe("string");
  });
});

describe("shapeAssetHit", () => {
  const asset = {
    assetName: "deploy",
    assetType: "skill",
    description: "Deploy the app",
    stash: { id: "x", name: "x" },
    action: "akm show skill:deploy",
    estimatedTokens: 120,
  };

  test("brief drops description", () => {
    expect(shapeAssetHit(asset, "brief")).toEqual({
      assetName: "deploy",
      assetType: "skill",
      action: "akm show skill:deploy",
      estimatedTokens: 120,
    });
  });

  test("normal includes description + stash", () => {
    expect(shapeAssetHit(asset, "normal")).toMatchObject({
      assetName: "deploy",
      assetType: "skill",
      description: "Deploy the app",
      stash: { id: "x", name: "x" },
    });
  });
});

describe("shapeShowOutput", () => {
  const fullShow = {
    type: "skill",
    name: "deploy",
    description: "Deploy",
    action: "akm show skill:deploy",
    content: "long body...",
    template: "tpl",
    cwd: "/tmp",
    extra: "should-not-appear-in-agent-mode",
  };

  test("shape=agent picks the agent-action field set", () => {
    const out = shapeShowOutput(fullShow, "full", "agent");
    expect(out).toMatchObject({
      type: "skill",
      name: "deploy",
      description: "Deploy",
      content: "long body...",
    });
    expect(out).not.toHaveProperty("extra");
  });

  test("shape=summary picks the compact metadata field set", () => {
    const out = shapeShowOutput(fullShow, "normal", "summary");
    expect(out).toMatchObject({ type: "skill", name: "deploy", description: "Deploy" });
    // summary omits content (compact metadata only).
    expect(out).not.toHaveProperty("content");
  });

  test("shape=human at full picks the show field set + adds schemaVersion", () => {
    const out = shapeShowOutput(fullShow, "full", "human");
    expect(out.schemaVersion).toBe(1);
    expect(out).toMatchObject({
      type: "skill",
      name: "deploy",
      description: "Deploy",
      content: "long body...",
      template: "tpl",
      cwd: "/tmp",
    });
    // `extra` was not in the picked field set, even at full.
    expect(out).not.toHaveProperty("extra");
  });

  test("shape=human at brief omits schemaVersion (only added at full)", () => {
    const out = shapeShowOutput(fullShow, "brief", "human");
    expect(out).not.toHaveProperty("schemaVersion");
    expect(out.name).toBe("deploy");
  });
});

describe("shapeForCommand", () => {
  test("routes search results through shapeSearchOutput", () => {
    const out = shapeForCommand(
      "search",
      { hits: [{ type: "skill", name: "x", action: "a", estimatedTokens: 1 }], registryHits: [] },
      "brief",
      "human",
    ) as Record<string, unknown>;
    expect(Array.isArray(out.hits)).toBe(true);
    expect((out.hits as unknown[])[0]).toEqual({
      type: "skill",
      name: "x",
      action: "a",
      estimatedTokens: 1,
    });
  });

  test("routes show results through shapeShowOutput at full + shape=agent", () => {
    const out = shapeForCommand(
      "show",
      { type: "skill", name: "deploy", action: "a", extra: "drop me" },
      "full",
      "agent",
    ) as Record<string, unknown>;
    expect(out).not.toHaveProperty("extra");
  });

  test("non-search/show commands pass through unmodified", () => {
    const result = { something: "untouched" };
    expect(shapeForCommand("info", result, "full", "human")).toEqual(result);
    expect(shapeForCommand("health", result, "full", "human")).toEqual(result);
  });

  test("--shape summary on a non-show command throws INVALID_SHAPE_VALUE", () => {
    expect(() => shapeForCommand("search", { hits: [], registryHits: [] }, "normal", "summary")).toThrow(
      /not supported for 'akm search'/,
    );
    expect(() => shapeForCommand("info", { x: 1 }, "normal", "summary")).toThrow(/only available on 'akm show'/);
  });

  test("--shape summary on show is allowed", () => {
    expect(() => shapeForCommand("show", { type: "skill", name: "deploy" }, "normal", "summary")).not.toThrow();
  });
});

describe("shapeSearchOutput", () => {
  test("respects detail level for hits", () => {
    const result = {
      hits: [{ type: "skill", name: "x", action: "a", estimatedTokens: 1, description: "desc" }],
      registryHits: [],
    };
    const brief = shapeSearchOutput(result, "brief", "human") as { hits: Record<string, unknown>[] };
    const normal = shapeSearchOutput(result, "normal", "human") as { hits: Record<string, unknown>[] };
    expect((brief.hits[0] as Record<string, unknown>).description).toBeUndefined();
    expect((normal.hits[0] as Record<string, unknown>).description).toBe("desc");
  });

  test("shape=agent overrides detail and uses the agent shape", () => {
    const result = {
      hits: [
        {
          type: "skill",
          name: "x",
          action: "a",
          ref: "skill:x",
          description: "d",
          score: 0.1,
          estimatedTokens: 1,
        },
      ],
      registryHits: [],
    };
    const out = shapeSearchOutput(result, "brief", "agent") as { hits: Record<string, unknown>[] };
    expect((out.hits[0] as Record<string, unknown>).ref).toBe("skill:x");
  });
});

describe("shapeRegistrySearchOutput", () => {
  test("shapes registry hits at the requested detail level", () => {
    const result = {
      hits: [
        {
          name: "azure-ops",
          description: "Azure ops kit",
          action: "akm add npm:azure-ops",
          // v1 §4.2: no more `curated` key.
          score: 0.5,
          id: "npm:azure-ops",
        },
      ],
      registryHits: [],
    };
    const brief = shapeRegistrySearchOutput(result, "brief") as { hits: Record<string, unknown>[] };
    // QA #28: brief now projects name + score at minimum (not just name/action)
    expect(brief.hits[0]).toMatchObject({ name: "azure-ops", score: 0.5 });
  });
});

// ── Output-shape registry exhaustiveness (#274) ─────────────────────────────

describe("shapeForCommand: unknown command", () => {
  test("throws with the command name in the message instead of silently passing the result through", () => {
    // v1 spec §9: the output shape registry is exhaustive. A missing case is
    // a registration bug (silent JSON.stringify fallback was the old
    // behaviour). The throw must include the unknown command name so the
    // caller / test sees the missing registration.
    expect(() => shapeForCommand("definitely-not-a-real-command", { foo: "bar" }, "normal")).toThrow(
      "output shape not registered for command: definitely-not-a-real-command",
    );
  });
});

// ── #284 GAP-MED 1: shapeProposal* — proposal commands ─────────────────────

describe("shapeProposal* — proposal commands", () => {
  const fullProposal: Record<string, unknown> = {
    id: "uuid-1",
    ref: "lesson:rg-over-grep",
    status: "pending",
    source: "reflect",
    sourceRun: "run-7",
    createdAt: "2026-04-27T00:00:00Z",
    updatedAt: "2026-04-27T00:00:01Z",
    payload: { content: "BODY", frontmatter: { description: "d" } },
    review: undefined,
  };

  test("shapeProposalEntry brief drops payload + sourceRun", () => {
    const out = shapeProposalEntry(fullProposal, "brief");
    expect(out).toEqual({
      id: "uuid-1",
      ref: "lesson:rg-over-grep",
      status: "pending",
      source: "reflect",
      createdAt: "2026-04-27T00:00:00Z",
    });
    expect(out).not.toHaveProperty("payload");
    expect(out).not.toHaveProperty("sourceRun");
  });

  test("shapeProposalEntry normal keeps metadata + sourceRun + updatedAt; still drops payload", () => {
    const out = shapeProposalEntry(fullProposal, "normal");
    expect(out).toMatchObject({
      id: "uuid-1",
      ref: "lesson:rg-over-grep",
      status: "pending",
      source: "reflect",
      sourceRun: "run-7",
      createdAt: "2026-04-27T00:00:00Z",
      updatedAt: "2026-04-27T00:00:01Z",
    });
    expect(out).not.toHaveProperty("payload");
  });

  test("shapeProposalEntry full keeps payload", () => {
    const out = shapeProposalEntry(fullProposal, "full");
    expect(out).toHaveProperty("payload");
    expect((out.payload as Record<string, unknown>).content).toBe("BODY");
  });

  test("shapeProposalListOutput shapes nested proposals + carries totalCount", () => {
    const result = { schemaVersion: 1, totalCount: 2, proposals: [fullProposal, fullProposal] };
    const brief = shapeProposalListOutput(result, "brief");
    expect(brief.totalCount).toBe(2);
    expect(Array.isArray(brief.proposals)).toBe(true);
    const list = brief.proposals as Record<string, unknown>[];
    expect(list).toHaveLength(2);
    expect(list[0]).not.toHaveProperty("payload");
    // full level adds schemaVersion
    const full = shapeProposalListOutput(result, "full");
    expect(full.schemaVersion).toBe(1);
  });

  test("shapeProposalShowOutput surfaces validation alongside the entry", () => {
    const validation = { ok: true, findings: [] };
    const out = shapeProposalShowOutput({ schemaVersion: 1, proposal: fullProposal, validation }, "normal");
    expect(out.validation).toEqual(validation);
    expect((out.proposal as Record<string, unknown>).ref).toBe("lesson:rg-over-grep");
    expect(out).not.toHaveProperty("schemaVersion");
    // full adds schemaVersion
    const full = shapeProposalShowOutput({ schemaVersion: 1, proposal: fullProposal, validation }, "full");
    expect(full.schemaVersion).toBe(1);
  });

  test("shapeProposalAcceptOutput projects ok+id+ref+assetPath at every detail", () => {
    const result = {
      schemaVersion: 1,
      ok: true,
      id: "uuid-1",
      ref: "lesson:rg-over-grep",
      assetPath: "/tmp/stash/lessons/rg.md",
      proposal: fullProposal,
    };
    for (const detail of ["brief", "normal", "full"] as const) {
      const out = shapeProposalAcceptOutput(result, detail);
      expect(out.ok).toBe(true);
      expect(out.id).toBe("uuid-1");
      expect(out.ref).toBe("lesson:rg-over-grep");
      expect(out.assetPath).toBe("/tmp/stash/lessons/rg.md");
    }
  });

  test("shapeProposalRejectOutput threads `reason` only when present", () => {
    const withReason = shapeProposalRejectOutput(
      { schemaVersion: 1, ok: true, id: "uuid-1", ref: "lesson:x", reason: "duplicate", proposal: fullProposal },
      "normal",
    );
    expect(withReason.reason).toBe("duplicate");
    const withoutReason = shapeProposalRejectOutput(
      { schemaVersion: 1, ok: true, id: "uuid-1", ref: "lesson:x", proposal: fullProposal },
      "normal",
    );
    expect(withoutReason).not.toHaveProperty("reason");
  });

  test("shapeProposalDiffOutput projects id/ref/isNew/unified", () => {
    const result = {
      schemaVersion: 1,
      id: "uuid-1",
      ref: "lesson:x",
      isNew: true,
      unified: "--- /dev/null\n+++ a\n",
      targetPath: "/tmp/x",
    };
    const brief = shapeProposalDiffOutput(result, "brief");
    expect(brief).toMatchObject({ id: "uuid-1", isNew: true, targetPath: "/tmp/x" });
    expect(brief).not.toHaveProperty("schemaVersion");
    const full = shapeProposalDiffOutput(result, "full");
    expect(full.schemaVersion).toBe(1);
  });

  test("shapeProposalProducerOutput happy: ok=true with shaped proposal", () => {
    const result = {
      schemaVersion: 1,
      ok: true,
      ref: "lesson:rg",
      agentProfile: "claude",
      durationMs: 12,
      proposal: fullProposal,
    };
    const out = shapeProposalProducerOutput(result, "normal");
    expect(out.ok).toBe(true);
    expect(out.ref).toBe("lesson:rg");
    expect(out.agentProfile).toBe("claude");
    expect(out.durationMs).toBe(12);
    expect((out.proposal as Record<string, unknown>).id).toBe("uuid-1");
  });

  test("shapeProposalProducerOutput failure: surfaces reason/error/exitCode; full adds stdout/stderr", () => {
    const failure = {
      schemaVersion: 1,
      ok: false,
      reason: "non_zero_exit",
      error: "agent failed",
      ref: "lesson:rg",
      exitCode: 7,
      stdout: "captured-out",
      stderr: "captured-err",
    };
    const normal = shapeProposalProducerOutput(failure, "normal");
    expect(normal.ok).toBe(false);
    expect(normal.reason).toBe("non_zero_exit");
    expect(normal.error).toBe("agent failed");
    expect(normal.ref).toBe("lesson:rg");
    expect(normal.exitCode).toBe(7);
    // normal omits stdio (large payload); full retains them
    expect(normal).not.toHaveProperty("stdout");
    expect(normal).not.toHaveProperty("stderr");
    const full = shapeProposalProducerOutput(failure, "full");
    expect(full.stdout).toBe("captured-out");
    expect(full.stderr).toBe("captured-err");
    expect(full.schemaVersion).toBe(1);
  });

  test("shapeForCommand routes proposal-* arms through their dedicated shapers", () => {
    const list = shapeForCommand(
      "proposal-list",
      { schemaVersion: 1, totalCount: 1, proposals: [fullProposal] },
      "brief",
    ) as Record<string, unknown>;
    expect(list.totalCount).toBe(1);
    const show = shapeForCommand(
      "proposal-show",
      { schemaVersion: 1, proposal: fullProposal, validation: { ok: true, findings: [] } },
      "normal",
    ) as Record<string, unknown>;
    expect((show.proposal as Record<string, unknown>).ref).toBe("lesson:rg-over-grep");
    const accept = shapeForCommand(
      "proposal-accept",
      {
        schemaVersion: 1,
        ok: true,
        id: "uuid-1",
        ref: "lesson:x",
        assetPath: "/tmp/x",
        proposal: fullProposal,
      },
      "brief",
    ) as Record<string, unknown>;
    expect(accept.assetPath).toBe("/tmp/x");
    const reject = shapeForCommand(
      "proposal-reject",
      { schemaVersion: 1, ok: true, id: "uuid-1", ref: "lesson:x", proposal: fullProposal },
      "brief",
    ) as Record<string, unknown>;
    expect(reject.id).toBe("uuid-1");
    const diff = shapeForCommand(
      "proposal-diff",
      { schemaVersion: 1, id: "uuid-1", ref: "lesson:x", isNew: true, unified: "+++" },
      "brief",
    ) as Record<string, unknown>;
    expect(diff.isNew).toBe(true);
    const reflect = shapeForCommand(
      "reflect",
      { schemaVersion: 1, ok: true, ref: "lesson:x", proposal: fullProposal, agentProfile: "p", durationMs: 1 },
      "normal",
    ) as Record<string, unknown>;
    expect(reflect.ok).toBe(true);
  });
});
