import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import {
  createTestDb,
  truncateAllTables,
  type TestDb,
} from "@/test-utils/pglite";
import {
  buildWriteLayer,
  makeRun,
  ndjson,
  one,
  seedWrite,
  type RunResult,
  type WriteSeed,
} from "./cli-write-test-harness";

// ===========================================================================
// cvm WRITE verbs — lesson create + video create/move/update
// (Split from cli-integration.test.ts to stay under the per-file token budget.)
// ===========================================================================

let testDb: TestDb;
let run: (argv: ReadonlyArray<string>) => Promise<RunResult>;

beforeAll(async () => {
  const result = await createTestDb();
  testDb = result.testDb;
  run = makeRun(buildWriteLayer(testDb));
});

let s: WriteSeed;
beforeEach(async () => {
  await truncateAllTables(testDb);
  s = await seedWrite(testDb);
});

describe("lesson create", () => {
  interface Lesson {
    id: string;
    sectionId: string;
    title: string;
    order: number;
    authoringStatus: string | null;
    archived: boolean;
  }

  it("creates a lesson appended to the section, echoing the row", async () => {
    const { stdout, stderr, exitCode } = await run([
      "lesson",
      "create",
      "--section",
      s.draftSectionId,
      "--title",
      "Intro to Effect",
    ]);
    expect(exitCode).toBe(0);
    expect(stderr).toBe("");
    expect(stdout).toMatch(/^\{\n/); // single pretty object, not NDJSON
    const lesson = one<Lesson>(stdout);
    expect(lesson.sectionId).toBe(s.draftSectionId);
    expect(lesson.title).toBe("Intro to Effect");
    expect(lesson.authoringStatus).toBe("todo");
    expect(lesson.archived).toBe(false);
    expect(lesson.order).toBeGreaterThan(1); // appended after the seed lesson
    const list = ndjson(
      (await run(["lesson", "list", "--section", s.draftSectionId])).stdout
    ) as Lesson[];
    expect(list.map((l) => l.id)).toContain(lesson.id);
  });

  it("--before places the new lesson before the anchor", async () => {
    const before = one<Lesson>(
      (
        await run([
          "lesson",
          "create",
          "--section",
          s.draftSectionId,
          "--title",
          "Goes First",
          "--before",
          s.lessonId,
        ])
      ).stdout
    );
    const list = ndjson(
      (await run(["lesson", "list", "--section", s.draftSectionId])).stdout
    ) as Lesson[];
    const ids = list.map((l) => l.id);
    expect(ids.indexOf(before.id)).toBeLessThan(ids.indexOf(s.lessonId));
  });

  it("--after places the new lesson after the anchor", async () => {
    const after = one<Lesson>(
      (
        await run([
          "lesson",
          "create",
          "--section",
          s.draftSectionId,
          "--title",
          "Goes After",
          "--after",
          s.lessonId,
        ])
      ).stdout
    );
    const list = ndjson(
      (await run(["lesson", "list", "--section", s.draftSectionId])).stdout
    ) as Lesson[];
    const ids = list.map((l) => l.id);
    expect(ids.indexOf(after.id)).toBeGreaterThan(ids.indexOf(s.lessonId));
  });

  it("both --before and --after => invalid input, exit 3", async () => {
    const { exitCode, stdout, stderr } = await run([
      "lesson",
      "create",
      "--section",
      s.draftSectionId,
      "--title",
      "X",
      "--before",
      s.lessonId,
      "--after",
      s.lessonId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
    expect((JSON.parse(stderr.trim()) as { _tag: string })._tag).toBe(
      "ParseError"
    );
  });

  it("unknown section => NotFoundError(section), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "lesson",
      "create",
      "--section",
      "sec_missing",
      "--title",
      "X",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "section"
    );
  });

  it("unknown anchor => NotFoundError(lesson), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "lesson",
      "create",
      "--section",
      s.draftSectionId,
      "--title",
      "X",
      "--before",
      "les_missing",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "lesson"
    );
  });

  it("duplicate titles are allowed (uniqueness is on order, not path)", async () => {
    await run([
      "lesson",
      "create",
      "--section",
      s.draftSectionId,
      "--title",
      "Duplicate",
    ]);
    const { exitCode } = await run([
      "lesson",
      "create",
      "--section",
      s.draftSectionId,
      "--title",
      "Duplicate",
    ]);
    expect(exitCode).toBe(0);
  });
});

describe("lesson update", () => {
  interface Lesson {
    id: string;
    title: string;
    authoringStatus: string | null;
  }
  const lobj = (stdout: string): Lesson => one<Lesson>(stdout);

  it("--authoring-status done marks the lesson done, echoing the row", async () => {
    // The seed lesson starts as "done"; flip it to todo first so we can prove
    // the write, then back to done.
    await run(["lesson", "update", "--authoring-status", "todo", s.lessonId]);
    const updated = lobj(
      (
        await run([
          "lesson",
          "update",
          "--authoring-status",
          "done",
          s.lessonId,
        ])
      ).stdout
    );
    expect(updated.id).toBe(s.lessonId);
    expect(updated.authoringStatus).toBe("done");
  });

  it("--authoring-status todo marks a done lesson back to todo", async () => {
    const updated = lobj(
      (
        await run([
          "lesson",
          "update",
          "--authoring-status",
          "todo",
          s.lessonId,
        ])
      ).stdout
    );
    expect(updated.authoringStatus).toBe("todo");
  });

  it("patches title and authoring status together, leaving the slug", async () => {
    const updated = lobj(
      (
        await run([
          "lesson",
          "update",
          "--title",
          "A Clearer Title",
          "--authoring-status",
          "todo",
          s.lessonId,
        ])
      ).stdout
    );
    expect(updated.title).toBe("A Clearer Title");
    expect(updated.authoringStatus).toBe("todo");
  });

  it("--title only leaves the authoring status untouched", async () => {
    // Seed lesson is "done"; renaming must not reset it to todo.
    const updated = lobj(
      (await run(["lesson", "update", "--title", "Renamed Only", s.lessonId]))
        .stdout
    );
    expect(updated.title).toBe("Renamed Only");
    expect(updated.authoringStatus).toBe("done");
  });

  it("with neither --title nor --authoring-status => invalid input, exit 3", async () => {
    const { exitCode, stdout, stderr } = await run([
      "lesson",
      "update",
      s.lessonId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
    expect((JSON.parse(stderr.trim()) as { _tag: string })._tag).toBe(
      "ParseError"
    );
  });

  it("an unknown --authoring-status value => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run([
      "lesson",
      "update",
      "--authoring-status",
      "in-progress",
      s.lessonId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("an empty --title => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run([
      "lesson",
      "update",
      "--title",
      "  ",
      s.lessonId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("update an unknown lesson => NotFoundError(lesson), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "lesson",
      "update",
      "--authoring-status",
      "todo",
      "les_missing",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "lesson"
    );
  });
});

describe("video create / move / update", () => {
  interface Video {
    id: string;
    title: string;
    lessonId: string | null;
    pitchId: string | null;
    archived: boolean;
    body: string | null;
    description: string | null;
  }
  const vobj = (stdout: string): Video => one<Video>(stdout);

  it("create --name (standalone) has no lesson or pitch parent", async () => {
    const { stdout, stderr, exitCode } = await run([
      "video",
      "create",
      "--name",
      "New Standalone",
    ]);
    expect(exitCode).toBe(0);
    expect(stderr).toBe("");
    const v = vobj(stdout);
    expect(v.title).toBe("New Standalone");
    expect(v.lessonId).toBeNull();
    expect(v.pitchId).toBeNull();
    expect(v.archived).toBe(false);
  });

  it("create --lesson attaches to the lesson", async () => {
    const v = vobj(
      (
        await run([
          "video",
          "create",
          "--name",
          "02-part",
          "--lesson",
          s.lessonId,
        ])
      ).stdout
    );
    expect(v.lessonId).toBe(s.lessonId);
    expect(v.pitchId).toBeNull();
  });

  it("create --pitch attaches to the pitch, name required and honored", async () => {
    const v = vobj(
      (
        await run([
          "video",
          "create",
          "--name",
          "My Pitch Cut",
          "--pitch",
          s.pitchActiveId,
        ])
      ).stdout
    );
    expect(v.pitchId).toBe(s.pitchActiveId);
    expect(v.lessonId).toBeNull();
    expect(v.title).toBe("My Pitch Cut");
  });

  it("create with missing --name => invalid input, exit 3", async () => {
    const { exitCode } = await run(["video", "create"]);
    expect(exitCode).toBe(3);
  });

  it("create with an empty --name => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run(["video", "create", "--name", "  "]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("create with both --lesson and --pitch => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run([
      "video",
      "create",
      "--name",
      "X",
      "--lesson",
      s.lessonId,
      "--pitch",
      s.pitchActiveId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("create --lesson with unknown lesson => NotFoundError(lesson), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "video",
      "create",
      "--name",
      "X",
      "--lesson",
      "les_missing",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "lesson"
    );
  });

  it("create --pitch with unknown pitch => NotFoundError(pitch), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "video",
      "create",
      "--name",
      "X",
      "--pitch",
      "pit_missing",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "pitch"
    );
  });

  it("create --lesson with a name already taken in the lesson => invalid input, exit 3", async () => {
    // s.lessonVideoId already has title "intro.mp4" in the lesson.
    const { exitCode, stdout } = await run([
      "video",
      "create",
      "--name",
      "intro.mp4",
      "--lesson",
      s.lessonId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("move re-homes a standalone video into a lesson", async () => {
    const moved = vobj(
      (
        await run([
          "video",
          "move",
          "--lesson",
          s.lessonId,
          s.standaloneActiveId,
        ])
      ).stdout
    );
    expect(moved.id).toBe(s.standaloneActiveId);
    expect(moved.lessonId).toBe(s.lessonId);
    expect(moved.pitchId).toBeNull();
  });

  it("move to a pitch clears any lesson parent (single-parent invariant)", async () => {
    const moved = vobj(
      (
        await run([
          "video",
          "move",
          "--pitch",
          s.pitchActiveId,
          s.lessonVideoId,
        ])
      ).stdout
    );
    expect(moved.id).toBe(s.lessonVideoId);
    expect(moved.pitchId).toBe(s.pitchActiveId);
    expect(moved.lessonId).toBeNull(); // lesson association cleared
  });

  it("move with neither --lesson nor --pitch => invalid input, exit 3", async () => {
    const { exitCode } = await run(["video", "move", s.standaloneActiveId]);
    expect(exitCode).toBe(3);
  });

  it("move with both --lesson and --pitch => invalid input, exit 3", async () => {
    const { exitCode } = await run([
      "video",
      "move",
      "--lesson",
      s.lessonId,
      "--pitch",
      s.pitchActiveId,
      s.standaloneActiveId,
    ]);
    expect(exitCode).toBe(3);
  });

  it("move an unknown video => NotFoundError(video), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "video",
      "move",
      "--lesson",
      s.lessonId,
      "vid_missing",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "video"
    );
  });

  it("move into a lesson where the name is taken => invalid input, exit 3", async () => {
    // Rename the standalone video to collide with the existing lesson video,
    // then try to move it into that lesson.
    await run(["video", "update", "--name", "intro.mp4", s.standaloneActiveId]);
    const { exitCode, stdout } = await run([
      "video",
      "move",
      "--lesson",
      s.lessonId,
      s.standaloneActiveId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("update --name renames the video, echoing the row", async () => {
    const updated = vobj(
      (
        await run([
          "video",
          "update",
          "--name",
          "renamed.mp4",
          s.standaloneActiveId,
        ])
      ).stdout
    );
    expect(updated.id).toBe(s.standaloneActiveId);
    expect(updated.title).toBe("renamed.mp4");
  });

  it("update with no --name => invalid input, exit 3", async () => {
    const { exitCode } = await run(["video", "update", s.standaloneActiveId]);
    expect(exitCode).toBe(3);
  });

  it("update with an empty --name => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run([
      "video",
      "update",
      "--name",
      "  ",
      s.standaloneActiveId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("update an unknown video => NotFoundError(video), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "video",
      "update",
      "--name",
      "x",
      "vid_missing",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "video"
    );
  });

  it("update --description sets the SEO description, echoing the row", async () => {
    const updated = vobj(
      (
        await run([
          "video",
          "update",
          "--description",
          "Learn to refactor a reducer",
          s.standaloneActiveId,
        ])
      ).stdout
    );
    expect(updated.id).toBe(s.standaloneActiveId);
    expect(updated.description).toBe("Learn to refactor a reducer");
    // Leaves the name/title untouched.
    expect(updated.title).toBe("standalone-active.mp4");
  });

  it("update --body sets the markdown body from inline text", async () => {
    const updated = vobj(
      (
        await run([
          "video",
          "update",
          "--body",
          "# Intro\n\nWelcome",
          s.standaloneActiveId,
        ])
      ).stdout
    );
    expect(updated.body).toBe("# Intro\n\nWelcome");
  });

  it("update patches name, body and description together", async () => {
    const updated = vobj(
      (
        await run([
          "video",
          "update",
          "--name",
          "renamed.mp4",
          "--body",
          "body text",
          "--description",
          "seo text",
          s.standaloneActiveId,
        ])
      ).stdout
    );
    expect(updated.title).toBe("renamed.mp4");
    expect(updated.body).toBe("body text");
    expect(updated.description).toBe("seo text");
  });

  it("update with no fields => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run([
      "video",
      "update",
      s.standaloneActiveId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("update with both --body and --body-file => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run([
      "video",
      "update",
      "--body",
      "x",
      "--body-file",
      "/tmp/does-not-matter.md",
      s.standaloneActiveId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("update --body-file reads the markdown body from a file", async () => {
    const dir = mkdtempSync(join(tmpdir(), "cvm-body-"));
    const file = join(dir, "notes.md");
    writeFileSync(file, "# From file\n\nContents", "utf8");
    const updated = vobj(
      (
        await run([
          "video",
          "update",
          "--body-file",
          file,
          s.standaloneActiveId,
        ])
      ).stdout
    );
    expect(updated.body).toBe("# From file\n\nContents");
  });

  it("update --body-file with an unreadable path => invalid input, exit 3", async () => {
    const { exitCode, stdout } = await run([
      "video",
      "update",
      "--body-file",
      "/no/such/file/anywhere.md",
      s.standaloneActiveId,
    ]);
    expect(exitCode).toBe(3);
    expect(stdout).toBe("");
  });

  it("update --description on an unknown video => NotFoundError(video), exit 2", async () => {
    const { exitCode, stderr } = await run([
      "video",
      "update",
      "--description",
      "x",
      "vid_missing",
    ]);
    expect(exitCode).toBe(2);
    expect((JSON.parse(stderr.trim()) as { entity: string }).entity).toBe(
      "video"
    );
  });
});
