import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, rm, writeFile, readFile, mkdir, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { StateManager } from "../state/manager.js";
describe("StateManager", () => {
    let tempDir;
    let manager;
    beforeEach(async () => {
        tempDir = await mkdtemp(join(tmpdir(), "inkos-test-"));
        manager = new StateManager(tempDir);
    });
    afterEach(async () => {
        await rm(tempDir, { recursive: true, force: true });
    });
    // -------------------------------------------------------------------------
    // BookConfig persistence
    // -------------------------------------------------------------------------
    describe("saveBookConfig / loadBookConfig", () => {
        const bookConfig = {
            id: "test-book",
            title: "Test Novel",
            platform: "tomato",
            genre: "xuanhuan",
            status: "active",
            targetChapters: 200,
            chapterWordCount: 3000,
            createdAt: "2026-01-01T00:00:00Z",
            updatedAt: "2026-01-01T00:00:00Z",
        };
        it("round-trips a BookConfig through save and load", async () => {
            await manager.saveBookConfig("test-book", bookConfig);
            const loaded = await manager.loadBookConfig("test-book");
            expect(loaded).toEqual(bookConfig);
        });
        it("creates the book directory on save", async () => {
            await manager.saveBookConfig("new-book", {
                ...bookConfig,
                id: "new-book",
            });
            const dirStat = await stat(manager.bookDir("new-book"));
            expect(dirStat.isDirectory()).toBe(true);
        });
        it("throws when loading a non-existent book", async () => {
            await expect(manager.loadBookConfig("nope")).rejects.toThrow();
        });
    });
    // -------------------------------------------------------------------------
    // ChapterIndex persistence
    // -------------------------------------------------------------------------
    describe("saveChapterIndex / loadChapterIndex", () => {
        const chapters = [
            {
                number: 1,
                title: "Ch1",
                status: "drafted",
                wordCount: 3000,
                createdAt: "2026-01-01T00:00:00Z",
                updatedAt: "2026-01-01T00:00:00Z",
                auditIssues: [],
                lengthWarnings: [],
            },
            {
                number: 2,
                title: "Ch2",
                status: "drafting",
                wordCount: 0,
                createdAt: "2026-01-02T00:00:00Z",
                updatedAt: "2026-01-02T00:00:00Z",
                auditIssues: ["pacing issue"],
                lengthWarnings: [],
            },
        ];
        it("round-trips chapter index through save and load", async () => {
            await manager.saveChapterIndex("book-a", chapters);
            const loaded = await manager.loadChapterIndex("book-a");
            expect(loaded).toEqual(chapters);
        });
        it("returns empty array when no index exists", async () => {
            const loaded = await manager.loadChapterIndex("nonexistent");
            expect(loaded).toEqual([]);
        });
        it("creates the chapters directory on save", async () => {
            await manager.saveChapterIndex("book-b", []);
            const dirStat = await stat(join(manager.bookDir("book-b"), "chapters"));
            expect(dirStat.isDirectory()).toBe(true);
        });
    });
    // -------------------------------------------------------------------------
    // getNextChapterNumber
    // -------------------------------------------------------------------------
    describe("getNextChapterNumber", () => {
        it("returns 1 for an empty book (no chapters)", async () => {
            const next = await manager.getNextChapterNumber("empty-book");
            expect(next).toBe(1);
        });
        it("returns the first missing chapter when the chapter index has gaps", async () => {
            const chapters = [
                {
                    number: 1,
                    title: "Ch1",
                    status: "published",
                    wordCount: 3000,
                    createdAt: "2026-01-01T00:00:00Z",
                    updatedAt: "2026-01-01T00:00:00Z",
                    auditIssues: [],
                    lengthWarnings: [],
                },
                {
                    number: 5,
                    title: "Ch5",
                    status: "drafted",
                    wordCount: 2800,
                    createdAt: "2026-01-05T00:00:00Z",
                    updatedAt: "2026-01-05T00:00:00Z",
                    auditIssues: [],
                    lengthWarnings: [],
                },
                {
                    number: 3,
                    title: "Ch3",
                    status: "approved",
                    wordCount: 3100,
                    createdAt: "2026-01-03T00:00:00Z",
                    updatedAt: "2026-01-03T00:00:00Z",
                    auditIssues: [],
                    lengthWarnings: [],
                },
            ];
            await manager.saveChapterIndex("book-x", chapters);
            const next = await manager.getNextChapterNumber("book-x");
            expect(next).toBe(2);
        });
        it("returns 2 when only chapter 1 exists", async () => {
            const chapters = [
                {
                    number: 1,
                    title: "Ch1",
                    status: "drafted",
                    wordCount: 3000,
                    createdAt: "2026-01-01T00:00:00Z",
                    updatedAt: "2026-01-01T00:00:00Z",
                    auditIssues: [],
                    lengthWarnings: [],
                },
            ];
            await manager.saveChapterIndex("book-y", chapters);
            const next = await manager.getNextChapterNumber("book-y");
            expect(next).toBe(2);
        });
        it("uses durable story progress when chapter index lags behind persisted chapter files", async () => {
            const bookId = "stale-index-book";
            const bookDir = manager.bookDir(bookId);
            const chaptersDir = join(bookDir, "chapters");
            const storyDir = join(bookDir, "story");
            await mkdir(chaptersDir, { recursive: true });
            await mkdir(storyDir, { recursive: true });
            await Promise.all([
                manager.saveChapterIndex(bookId, [
                    {
                        number: 1,
                        title: "Ch1",
                        status: "ready-for-review",
                        wordCount: 3000,
                        createdAt: "2026-01-01T00:00:00Z",
                        updatedAt: "2026-01-01T00:00:00Z",
                        auditIssues: [],
                        lengthWarnings: [],
                    },
                    {
                        number: 2,
                        title: "Ch2",
                        status: "ready-for-review",
                        wordCount: 3000,
                        createdAt: "2026-01-02T00:00:00Z",
                        updatedAt: "2026-01-02T00:00:00Z",
                        auditIssues: [],
                        lengthWarnings: [],
                    },
                ]),
                writeFile(join(chaptersDir, "0003_Lantern_Vault.md"), "# Chapter 3: Lantern Vault\n\nPersisted body.", "utf-8"),
                writeFile(join(storyDir, "current_state.md"), [
                    "# Current State",
                    "",
                    "| Field | Value |",
                    "| --- | --- |",
                    "| Current Chapter | 3 |",
                    "| Current Goal | Enter the vault without alerting the wardens |",
                    "",
                ].join("\n"), "utf-8"),
            ]);
            const next = await manager.getNextChapterNumber(bookId);
            expect(next).toBe(4);
        });
        it("ignores non-contiguous poisoned chapter numbers when calculating the next chapter", async () => {
            const bookId = "poisoned-next-chapter-book";
            const bookDir = manager.bookDir(bookId);
            const chaptersDir = join(bookDir, "chapters");
            const storyDir = join(bookDir, "story");
            const stateDir = join(storyDir, "state");
            await mkdir(chaptersDir, { recursive: true });
            await mkdir(stateDir, { recursive: true });
            const indexedChapters = [
                ...Array.from({ length: 12 }, (_, index) => ({
                    number: index + 1,
                    title: `Ch${index + 1}`,
                    status: "ready-for-review",
                    wordCount: 3000,
                    createdAt: "2026-01-01T00:00:00Z",
                    updatedAt: "2026-01-01T00:00:00Z",
                    auditIssues: [],
                    lengthWarnings: [],
                })),
                {
                    number: 142,
                    title: "Poisoned Ch142",
                    status: "audit-failed",
                    wordCount: 3200,
                    createdAt: "2026-01-13T00:00:00Z",
                    updatedAt: "2026-01-13T00:00:00Z",
                    auditIssues: [],
                    lengthWarnings: [],
                },
            ];
            await manager.saveChapterIndex(bookId, indexedChapters);
            await Promise.all([
                ...Array.from({ length: 12 }, (_, index) => writeFile(join(chaptersDir, `${String(index + 1).padStart(4, "0")}_Ch${index + 1}.md`), `# Chapter ${index + 1}\n\nStable body.`, "utf-8")),
                writeFile(join(chaptersDir, "0142_Poisoned.md"), "# Chapter 142\n\nPoisoned body.", "utf-8"),
                writeFile(join(storyDir, "current_state.md"), [
                    "# Current State",
                    "",
                    "| Field | Value |",
                    "| --- | --- |",
                    "| Current Chapter | 12 |",
                    "| Current Goal | Enter the next true chapter cleanly |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "pending_hooks.md"), [
                    "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
                    "| --- | --- | --- | --- | --- | --- | --- |",
                    "| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明继续展开 | Reveal the true enemy | Narrative text must not drive chapter progress |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "chapter_summaries.md"), [
                    "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
                    "| --- | --- | --- | --- | --- | --- | --- | --- |",
                    ...Array.from({ length: 12 }, (_, index) => `| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`),
                    "| 142 | Poisoned Ch142 | Lin Yue | Poisoned event | Poisoned shift | Poisoned hook | tense | mainline |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(stateDir, "manifest.json"), JSON.stringify({
                    schemaVersion: 2,
                    language: "en",
                    lastAppliedChapter: 141,
                    projectionVersion: 1,
                    migrationWarnings: [],
                }, null, 2), "utf-8"),
            ]);
            const next = await manager.getNextChapterNumber(bookId);
            expect(next).toBe(13);
        });
    });
    // -------------------------------------------------------------------------
    // listBooks
    // -------------------------------------------------------------------------
    describe("listBooks", () => {
        it("returns empty array when no books directory exists", async () => {
            const books = await manager.listBooks();
            expect(books).toEqual([]);
        });
        it("returns book IDs for directories with book.json", async () => {
            const bookConfig = {
                id: "alpha",
                title: "Alpha",
                platform: "tomato",
                genre: "urban",
                status: "active",
                targetChapters: 100,
                chapterWordCount: 3000,
                createdAt: "2026-01-01T00:00:00Z",
                updatedAt: "2026-01-01T00:00:00Z",
            };
            await manager.saveBookConfig("alpha", bookConfig);
            await manager.saveBookConfig("beta", { ...bookConfig, id: "beta", title: "Beta" });
            // Create a decoy directory without book.json
            await mkdir(join(manager.booksDir, "not-a-book"), { recursive: true });
            const books = await manager.listBooks();
            expect(books).toContain("alpha");
            expect(books).toContain("beta");
            expect(books).not.toContain("not-a-book");
            expect(books).toHaveLength(2);
        });
    });
    // -------------------------------------------------------------------------
    // snapshotState / restoreState
    // -------------------------------------------------------------------------
    describe("snapshotState / restoreState", () => {
        const bookId = "snap-book";
        beforeEach(async () => {
            const storyDir = join(manager.bookDir(bookId), "story");
            await mkdir(storyDir, { recursive: true });
            await writeFile(join(storyDir, "current_state.md"), "# State at ch1", "utf-8");
            await writeFile(join(storyDir, "particle_ledger.md"), "# Ledger at ch1", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks at ch1", "utf-8");
        });
        it("snapshots current state files to a numbered directory", async () => {
            await manager.snapshotState(bookId, 1);
            const snapshotDir = join(manager.bookDir(bookId), "story", "snapshots", "1");
            const state = await readFile(join(snapshotDir, "current_state.md"), "utf-8");
            expect(state).toBe("# State at ch1");
            const ledger = await readFile(join(snapshotDir, "particle_ledger.md"), "utf-8");
            expect(ledger).toBe("# Ledger at ch1");
            const hooks = await readFile(join(snapshotDir, "pending_hooks.md"), "utf-8");
            expect(hooks).toBe("# Hooks at ch1");
        });
        it("copies structured runtime state into snapshot/state when present", async () => {
            const stateDir = manager.stateDir(bookId);
            await mkdir(stateDir, { recursive: true });
            await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
                schemaVersion: 2,
                language: "en",
                lastAppliedChapter: 1,
                projectionVersion: 1,
                migrationWarnings: [],
            }, null, 2), "utf-8");
            await manager.snapshotState(bookId, 1);
            const snapshotManifest = await readFile(join(manager.bookDir(bookId), "story", "snapshots", "1", "state", "manifest.json"), "utf-8");
            expect(snapshotManifest).toContain("\"schemaVersion\": 2");
        });
        it("restores state from a previous snapshot", async () => {
            await manager.snapshotState(bookId, 1);
            // Modify the current state files
            const storyDir = join(manager.bookDir(bookId), "story");
            await writeFile(join(storyDir, "current_state.md"), "# State at ch2 (modified)", "utf-8");
            await writeFile(join(storyDir, "particle_ledger.md"), "# Ledger at ch2 (modified)", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks at ch2 (modified)", "utf-8");
            const restored = await manager.restoreState(bookId, 1);
            expect(restored).toBe(true);
            // Verify restored content
            const state = await readFile(join(storyDir, "current_state.md"), "utf-8");
            expect(state).toBe("# State at ch1");
            const ledger = await readFile(join(storyDir, "particle_ledger.md"), "utf-8");
            expect(ledger).toBe("# Ledger at ch1");
        });
        it("removes live optional truth files that are absent from the snapshot", async () => {
            const storyDir = join(manager.bookDir(bookId), "story");
            await rm(join(storyDir, "particle_ledger.md"));
            await manager.snapshotState(bookId, 1);
            await writeFile(join(storyDir, "particle_ledger.md"), "# Ledger added after snapshot", "utf-8");
            const restored = await manager.restoreState(bookId, 1);
            expect(restored).toBe(true);
            await expect(stat(join(storyDir, "particle_ledger.md"))).rejects.toThrow();
        });
        it("restores structured runtime state files from snapshot/state", async () => {
            const stateDir = manager.stateDir(bookId);
            await mkdir(stateDir, { recursive: true });
            await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
                schemaVersion: 2,
                language: "en",
                lastAppliedChapter: 1,
                projectionVersion: 1,
                migrationWarnings: [],
            }, null, 2), "utf-8");
            await manager.snapshotState(bookId, 1);
            await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
                schemaVersion: 2,
                language: "en",
                lastAppliedChapter: 9,
                projectionVersion: 1,
                migrationWarnings: [],
            }, null, 2), "utf-8");
            const restored = await manager.restoreState(bookId, 1);
            expect(restored).toBe(true);
            const manifest = await readFile(join(stateDir, "manifest.json"), "utf-8");
            expect(manifest).toContain("\"lastAppliedChapter\": 1");
        });
        it("returns false when restoring from non-existent snapshot", async () => {
            const restored = await manager.restoreState(bookId, 999);
            expect(restored).toBe(false);
        });
        it("rewrite chapter 2 then getNextChapterNumber returns 2", async () => {
            const rwBookId = "rewrite-book";
            const chapDir = join(manager.bookDir(rwBookId), "chapters");
            const storyDir = join(manager.bookDir(rwBookId), "story");
            await mkdir(chapDir, { recursive: true });
            await mkdir(storyDir, { recursive: true });
            // Simulate 3 chapters written
            await writeFile(join(chapDir, "0001_ch1.md"), "# Chapter 1\nContent 1", "utf-8");
            await writeFile(join(chapDir, "0002_ch2.md"), "# Chapter 2\nContent 2", "utf-8");
            await writeFile(join(chapDir, "0003_ch3.md"), "# Chapter 3\nContent 3", "utf-8");
            const mkEntry = (n) => ({
                number: n, title: `Ch${n}`, status: "approved", wordCount: 100,
                createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [],
            });
            const fullIndex = [mkEntry(1), mkEntry(2), mkEntry(3)];
            await manager.saveChapterIndex(rwBookId, fullIndex);
            // Snapshot state at chapter 1 (before chapter 2)
            await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
            await manager.snapshotState(rwBookId, 1);
            // Simulate rewrite of chapter 2: trim index, delete ch2+ch3, restore state
            const trimmed = fullIndex.filter((ch) => ch.number < 2);
            await manager.saveChapterIndex(rwBookId, trimmed);
            const { rm } = await import("node:fs/promises");
            await rm(join(chapDir, "0002_ch2.md"));
            await rm(join(chapDir, "0003_ch3.md"));
            await manager.restoreState(rwBookId, 1);
            // Next chapter should be 2, not 4
            const next = await manager.getNextChapterNumber(rwBookId);
            expect(next).toBe(2);
        });
        it("rewrite restore drops poisoned live structured state when the snapshot only has markdown truth files", async () => {
            const rwBookId = "rewrite-book-markdown-only";
            const chapDir = join(manager.bookDir(rwBookId), "chapters");
            const storyDir = join(manager.bookDir(rwBookId), "story");
            const stateDir = join(storyDir, "state");
            await mkdir(chapDir, { recursive: true });
            await mkdir(storyDir, { recursive: true });
            await writeFile(join(chapDir, "0001_ch1.md"), "# Chapter 1\nContent 1", "utf-8");
            await writeFile(join(chapDir, "0002_ch2.md"), "# Chapter 2\nContent 2", "utf-8");
            await writeFile(join(chapDir, "0003_ch3.md"), "# Chapter 3\nContent 3", "utf-8");
            const mkEntry = (n) => ({
                number: n, title: `Ch${n}`, status: "approved", wordCount: 100,
                createdAt: "", updatedAt: "", auditIssues: [], lengthWarnings: [],
            });
            const fullIndex = [mkEntry(1), mkEntry(2), mkEntry(3)];
            await manager.saveChapterIndex(rwBookId, fullIndex);
            await writeFile(join(storyDir, "current_state.md"), "State at ch1", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "Hooks at ch1", "utf-8");
            await manager.snapshotState(rwBookId, 1);
            await mkdir(stateDir, { recursive: true });
            await writeFile(join(stateDir, "manifest.json"), JSON.stringify({
                schemaVersion: 2,
                language: "en",
                lastAppliedChapter: 4,
                projectionVersion: 1,
                migrationWarnings: [],
            }, null, 2), "utf-8");
            await writeFile(join(stateDir, "current_state.json"), JSON.stringify({
                chapter: 3,
                facts: [],
            }, null, 2), "utf-8");
            const trimmed = fullIndex.filter((ch) => ch.number < 2);
            await manager.saveChapterIndex(rwBookId, trimmed);
            const { rm } = await import("node:fs/promises");
            await rm(join(chapDir, "0002_ch2.md"));
            await rm(join(chapDir, "0003_ch3.md"));
            await manager.restoreState(rwBookId, 1);
            const next = await manager.getNextChapterNumber(rwBookId);
            expect(next).toBe(2);
        });
    });
    // -------------------------------------------------------------------------
    // acquireBookLock
    // -------------------------------------------------------------------------
    describe("acquireBookLock", () => {
        it("acquires a lock and returns a release function", async () => {
            // Ensure book directory exists
            await mkdir(manager.bookDir("lock-book"), { recursive: true });
            const release = await manager.acquireBookLock("lock-book");
            expect(typeof release).toBe("function");
            // Lock file should exist
            const lockPath = join(manager.bookDir("lock-book"), ".write.lock");
            const lockStat = await stat(lockPath);
            expect(lockStat.isFile()).toBe(true);
            // Release the lock
            await release();
            // Lock file should be gone
            await expect(stat(lockPath)).rejects.toThrow();
        });
        it("throws when lock is already held", async () => {
            await mkdir(manager.bookDir("lock-book-2"), { recursive: true });
            const release = await manager.acquireBookLock("lock-book-2");
            await expect(manager.acquireBookLock("lock-book-2")).rejects.toThrow(/is locked/);
            await release();
        });
        it("allows re-acquiring lock after release", async () => {
            await mkdir(manager.bookDir("lock-book-3"), { recursive: true });
            const release1 = await manager.acquireBookLock("lock-book-3");
            await release1();
            const release2 = await manager.acquireBookLock("lock-book-3");
            expect(typeof release2).toBe("function");
            await release2();
        });
        it("allows only one concurrent lock claimant", async () => {
            await mkdir(manager.bookDir("lock-book-4"), { recursive: true });
            const results = await Promise.allSettled([
                manager.acquireBookLock("lock-book-4"),
                manager.acquireBookLock("lock-book-4"),
            ]);
            const fulfilled = results.filter((result) => result.status === "fulfilled");
            const rejected = results.filter((result) => result.status === "rejected");
            for (const result of fulfilled) {
                await result.value();
            }
            expect(fulfilled).toHaveLength(1);
            expect(rejected).toHaveLength(1);
            expect(String(rejected[0]?.reason)).toMatch(/is locked/);
        });
        it("reclaims same-process stale lock when no active write is in progress", async () => {
            await mkdir(manager.bookDir("lock-book-self"), { recursive: true });
            const lockPath = join(manager.bookDir("lock-book-self"), ".write.lock");
            // Simulate a stale lock left by our own process (e.g. after a failed pipeline)
            await writeFile(lockPath, `pid:${process.pid} ts:${Date.now() - 60000}`, "utf-8");
            // Should auto-reclaim since our process knows it's not actively writing this book
            const release = await manager.acquireBookLock("lock-book-self");
            expect(typeof release).toBe("function");
            const lockData = await readFile(lockPath, "utf-8");
            expect(lockData).toContain(`pid:${process.pid}`);
            await release();
        });
        it("reclaims a stale lock when the recorded pid is no longer alive", async () => {
            await mkdir(manager.bookDir("lock-book-5"), { recursive: true });
            const lockPath = join(manager.bookDir("lock-book-5"), ".write.lock");
            await writeFile(lockPath, "pid:424242 ts:123", "utf-8");
            const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid) => {
                if (pid === 424242) {
                    const error = new Error("no such process");
                    error.code = "ESRCH";
                    throw error;
                }
                return true;
            }));
            try {
                const release = await manager.acquireBookLock("lock-book-5");
                const lockData = await readFile(lockPath, "utf-8");
                expect(typeof release).toBe("function");
                expect(lockData).toContain(`pid:${process.pid}`);
                await release();
            }
            finally {
                killSpy.mockRestore();
            }
        });
    });
    // -------------------------------------------------------------------------
    // Path helpers
    // -------------------------------------------------------------------------
    describe("path helpers", () => {
        it("booksDir points to <projectRoot>/books", () => {
            expect(manager.booksDir).toBe(join(tempDir, "books"));
        });
        it("bookDir returns <booksDir>/<bookId>", () => {
            expect(manager.bookDir("my-book")).toBe(join(tempDir, "books", "my-book"));
        });
        it("stateDir returns <bookDir>/story/state", () => {
            expect(manager.stateDir("my-book")).toBe(join(tempDir, "books", "my-book", "story", "state"));
        });
    });
    // -------------------------------------------------------------------------
    // Input governance control docs
    // -------------------------------------------------------------------------
    describe("ensureControlDocuments", () => {
        it("creates author intent, current focus, and runtime directory", async () => {
            await manager.ensureControlDocuments("control-book", "# Initial Brief\n\nKeep the focus on mentor conflict.\n");
            const storyDir = join(manager.bookDir("control-book"), "story");
            const authorIntent = await readFile(join(storyDir, "author_intent.md"), "utf-8");
            const currentFocus = await readFile(join(storyDir, "current_focus.md"), "utf-8");
            const runtimeStat = await stat(join(storyDir, "runtime"));
            expect(authorIntent).toContain("mentor conflict");
            expect(currentFocus).toContain("Current Focus");
            expect(runtimeStat.isDirectory()).toBe(true);
        });
        it("creates Phase 5 outline/ and roles/ directories", async () => {
            await manager.ensureControlDocuments("phase5-book");
            const storyDir = join(manager.bookDir("phase5-book"), "story");
            const outlineStat = await stat(join(storyDir, "outline"));
            const rolesMajorStat = await stat(join(storyDir, "roles", "主要角色"));
            const rolesMinorStat = await stat(join(storyDir, "roles", "次要角色"));
            expect(outlineStat.isDirectory()).toBe(true);
            expect(rolesMajorStat.isDirectory()).toBe(true);
            expect(rolesMinorStat.isDirectory()).toBe(true);
        });
        it("bootstraps and returns safe defaults for legacy books", async () => {
            const storyDir = join(manager.bookDir("legacy-book"), "story");
            await mkdir(storyDir, { recursive: true });
            await writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\nLegacy books may not have control docs yet.\n", "utf-8");
            const controlDocs = await manager.loadControlDocuments("legacy-book");
            expect(controlDocs.authorIntent).toContain("# Author Intent");
            expect(controlDocs.currentFocus).toContain("# Current Focus");
            expect(controlDocs.runtimeDir).toBe(join(storyDir, "runtime"));
        });
        it("creates localized Chinese defaults for Chinese books", async () => {
            await manager.saveBookConfig("zh-book", {
                id: "zh-book",
                title: "中文书",
                platform: "tomato",
                genre: "other",
                status: "outlining",
                targetChapters: 100,
                chapterWordCount: 2200,
                language: "zh",
                createdAt: "2026-03-24T00:00:00Z",
                updatedAt: "2026-03-24T00:00:00Z",
            });
            await manager.ensureControlDocuments("zh-book");
            const storyDir = join(manager.bookDir("zh-book"), "story");
            const authorIntent = await readFile(join(storyDir, "author_intent.md"), "utf-8");
            const currentFocus = await readFile(join(storyDir, "current_focus.md"), "utf-8");
            expect(authorIntent).toContain("# 作者意图");
            expect(currentFocus).toContain("# 当前聚焦");
            expect(currentFocus).not.toContain("# Current Focus");
        });
        it("bootstraps structured runtime state from legacy markdown truth files", async () => {
            const bookId = "runtime-state-book";
            const storyDir = join(manager.bookDir(bookId), "story");
            await mkdir(storyDir, { recursive: true });
            await Promise.all([
                writeFile(join(storyDir, "current_state.md"), [
                    "# Current State",
                    "",
                    "| Field | Value |",
                    "| --- | --- |",
                    "| Current Chapter | 3 |",
                    "| Current Goal | Trace the mentor debt |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "pending_hooks.md"), [
                    "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
                    "| --- | --- | --- | --- | --- | --- | --- |",
                    "| mentor-debt | 1 | relationship | open | 3 | 10 | Still unresolved |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "chapter_summaries.md"), [
                    "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
                    "| --- | --- | --- | --- | --- | --- | --- | --- |",
                    "| 3 | River Ledger | Lin Yue | He checks the old ledger | Debt sharpens | mentor-debt advanced | tense | mainline |",
                    "",
                ].join("\n"), "utf-8"),
            ]);
            await manager.ensureRuntimeState(bookId, 3);
            const manifest = await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8");
            const currentState = await readFile(join(manager.stateDir(bookId), "current_state.json"), "utf-8");
            expect(manifest).toContain("\"schemaVersion\": 2");
            expect(currentState).toContain("\"chapter\": 3");
        });
        it("does not treat future hook start chapters as lastAppliedChapter during bootstrap", async () => {
            const bookId = "runtime-state-future-hooks-book";
            const storyDir = join(manager.bookDir(bookId), "story");
            await mkdir(storyDir, { recursive: true });
            await Promise.all([
                writeFile(join(storyDir, "current_state.md"), [
                    "# Current State",
                    "",
                    "| Field | Value |",
                    "| --- | --- |",
                    "| Current Chapter | 1 |",
                    "| Current Goal | Survive the harbor fallout |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "pending_hooks.md"), [
                    "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
                    "| --- | --- | --- | --- | --- | --- | --- |",
                    "| long-payoff-1 | 108 | mystery | open | 1 | 108 | Future payoff anchor |",
                    "| long-payoff-2 | 181 | relationship | open | 1 | 181 | Even later payoff anchor |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "chapter_summaries.md"), [
                    "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
                    "| --- | --- | --- | --- | --- | --- | --- | --- |",
                    "| 1 | Harbor Ash | Lin Yue | He survives the harbor fallout | The debt line opens | long-payoff-1 seeded | tense | opening |",
                    "",
                ].join("\n"), "utf-8"),
            ]);
            await manager.ensureRuntimeState(bookId, 1);
            const manifest = JSON.parse(await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8"));
            expect(manifest.lastAppliedChapter).toBe(1);
        });
        it("does not treat narrative digits inside hook markdown as runtime chapter progress during bootstrap", async () => {
            const bookId = "runtime-state-narrative-digit-book";
            const storyDir = join(manager.bookDir(bookId), "story");
            await mkdir(storyDir, { recursive: true });
            await Promise.all([
                writeFile(join(storyDir, "current_state.md"), [
                    "# Current State",
                    "",
                    "| Field | Value |",
                    "| --- | --- |",
                    "| Current Chapter | 12 |",
                    "| Current Goal | Continue after the imported twelfth chapter |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "pending_hooks.md"), [
                    "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
                    "| --- | --- | --- | --- | --- | --- | --- |",
                    "| H001 | 1 | mystery | progressing | 《三体》游戏内第141号文明展开到墨子时代 | Reveal the threat | Narrative prose, not chapter metadata |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "chapter_summaries.md"), [
                    "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
                    "| --- | --- | --- | --- | --- | --- | --- | --- |",
                    ...Array.from({ length: 12 }, (_, index) => `| ${index + 1} | Ch${index + 1} | Lin Yue | Event ${index + 1} | Shift ${index + 1} | Hook ${index + 1} | tense | mainline |`),
                    "",
                ].join("\n"), "utf-8"),
            ]);
            await manager.ensureRuntimeState(bookId, 12);
            const manifest = JSON.parse(await readFile(join(manager.stateDir(bookId), "manifest.json"), "utf-8"));
            const hooks = JSON.parse(await readFile(join(manager.stateDir(bookId), "hooks.json"), "utf-8"));
            expect(manifest.lastAppliedChapter).toBe(12);
            expect(hooks.hooks[0]?.hookId).toBe("H001");
            expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(0);
        });
        it("repairs poisoned manifest chapter when it runs ahead of persisted runtime state", async () => {
            const bookId = "runtime-state-poisoned-book";
            const storyDir = join(manager.bookDir(bookId), "story");
            const stateDir = join(storyDir, "state");
            await mkdir(stateDir, { recursive: true });
            await Promise.all([
                writeFile(join(storyDir, "current_state.md"), [
                    "# Current State",
                    "",
                    "| Field | Value |",
                    "| --- | --- |",
                    "| Current Chapter | 2 |",
                    "| Current Goal | Reach the ledger vault |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "pending_hooks.md"), [
                    "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
                    "| --- | --- | --- | --- | --- | --- | --- |",
                    "| vault-ledger | 1 | mystery | progressing | 2 | 4 | Ledger trail remains open |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "chapter_summaries.md"), [
                    "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
                    "| --- | --- | --- | --- | --- | --- | --- | --- |",
                    "| 1 | Harbor Ash | Lin Yue | Survives the harbor fallout | Debt line opens | vault-ledger seeded | tense | opening |",
                    "| 2 | Lantern Wharf | Lin Yue | Tracks the ledger to the wharf | Goal narrows to the vault | vault-ledger advanced | wary | investigation |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(stateDir, "manifest.json"), JSON.stringify({
                    schemaVersion: 2,
                    language: "en",
                    lastAppliedChapter: 3,
                    projectionVersion: 1,
                    migrationWarnings: [],
                }, null, 2), "utf-8"),
                writeFile(join(stateDir, "current_state.json"), JSON.stringify({
                    chapter: 2,
                    facts: [
                        {
                            subject: "protagonist",
                            predicate: "Current Goal",
                            object: "Reach the ledger vault",
                            validFromChapter: 2,
                            validUntilChapter: null,
                            sourceChapter: 2,
                        },
                    ],
                }, null, 2), "utf-8"),
                writeFile(join(stateDir, "hooks.json"), JSON.stringify({
                    hooks: [
                        {
                            hookId: "vault-ledger",
                            startChapter: 1,
                            type: "mystery",
                            status: "progressing",
                            lastAdvancedChapter: 2,
                            expectedPayoff: "4",
                            notes: "Persisted structured hook state",
                        },
                    ],
                }, null, 2), "utf-8"),
                writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({
                    rows: [
                        {
                            chapter: 1,
                            title: "Harbor Ash",
                            characters: "Lin Yue",
                            events: "Survives the harbor fallout",
                            stateChanges: "Debt line opens",
                            hookActivity: "vault-ledger seeded",
                            mood: "tense",
                            chapterType: "opening",
                        },
                        {
                            chapter: 2,
                            title: "Lantern Wharf",
                            characters: "Lin Yue",
                            events: "Tracks the ledger to the wharf",
                            stateChanges: "Goal narrows to the vault",
                            hookActivity: "vault-ledger advanced",
                            mood: "wary",
                            chapterType: "investigation",
                        },
                    ],
                }, null, 2), "utf-8"),
            ]);
            await manager.ensureRuntimeState(bookId, 2);
            const manifest = JSON.parse(await readFile(join(stateDir, "manifest.json"), "utf-8"));
            const currentState = JSON.parse(await readFile(join(stateDir, "current_state.json"), "utf-8"));
            const hooks = JSON.parse(await readFile(join(stateDir, "hooks.json"), "utf-8"));
            const summaries = JSON.parse(await readFile(join(stateDir, "chapter_summaries.json"), "utf-8"));
            expect(manifest.lastAppliedChapter).toBe(2);
            expect(currentState.chapter).toBe(2);
            expect(currentState.facts[0]?.object).toBe("Reach the ledger vault");
            expect(hooks.hooks[0]?.lastAdvancedChapter).toBe(2);
            expect(summaries.rows.map((row) => row.chapter)).toEqual([1, 2]);
            expect(summaries.rows.at(-1)?.title).toBe("Lantern Wharf");
        });
        it("normalizes emphasized hook ids when bootstrapping structured runtime state from markdown", async () => {
            const bookId = "runtime-state-emphasized-hook-book";
            const storyDir = join(manager.bookDir(bookId), "story");
            await mkdir(storyDir, { recursive: true });
            await Promise.all([
                writeFile(join(storyDir, "current_state.md"), [
                    "# Current State",
                    "",
                    "| Field | Value |",
                    "| --- | --- |",
                    "| Current Chapter | 3 |",
                    "| Current Goal | Follow the ledger trail |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "pending_hooks.md"), [
                    "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
                    "| --- | --- | --- | --- | --- | --- | --- |",
                    "| **H009** | 3 | mystery | open | 3 | 9 | Bold markdown leaked into hook id |",
                    "",
                ].join("\n"), "utf-8"),
                writeFile(join(storyDir, "chapter_summaries.md"), [
                    "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |",
                    "| --- | --- | --- | --- | --- | --- | --- | --- |",
                    "| 3 | Lantern Wharf | Lin Yue | Follows the ledger trail | Goal narrows to the ledger trail | H009 advanced | wary | investigation |",
                    "",
                ].join("\n"), "utf-8"),
            ]);
            await manager.ensureRuntimeState(bookId, 3);
            const hooks = JSON.parse(await readFile(join(manager.stateDir(bookId), "hooks.json"), "utf-8"));
            expect(hooks.hooks.map((hook) => hook.hookId)).toEqual(["H009"]);
        });
    });
    // -------------------------------------------------------------------------
    // rollbackToChapter — reject a chapter and discard downstream state
    // -------------------------------------------------------------------------
    describe("rollbackToChapter", () => {
        const bookId = "rollback-book";
        async function setupRollbackBook() {
            await manager.saveBookConfig(bookId, {
                id: bookId,
                title: "Rollback Test",
                platform: "tomato",
                genre: "xuanhuan",
                status: "active",
                targetChapters: 10,
                chapterWordCount: 3000,
                createdAt: "2026-03-31T00:00:00Z",
                updatedAt: "2026-03-31T00:00:00Z",
            });
            const bookDir = manager.bookDir(bookId);
            const storyDir = join(bookDir, "story");
            const chaptersDir = join(bookDir, "chapters");
            const runtimeDir = join(storyDir, "runtime");
            await mkdir(runtimeDir, { recursive: true });
            await mkdir(chaptersDir, { recursive: true });
            // Write initial state (chapter 0 baseline)
            await writeFile(join(storyDir, "current_state.md"), "# State\n\n- Initial state.\n", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n", "utf-8");
            await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n", "utf-8");
            await manager.snapshotState(bookId, 0);
            // Write chapter 1 state + file
            await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 1.\n", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n", "utf-8");
            await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n", "utf-8");
            await writeFile(join(chaptersDir, "0001_Title_One.md"), "# Chapter 1\n\nContent 1.", "utf-8");
            await manager.snapshotState(bookId, 1);
            // Write chapter 2 state + file
            await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 2.\n", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n", "utf-8");
            await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n", "utf-8");
            await writeFile(join(chaptersDir, "0002_Title_Two.md"), "# Chapter 2\n\nContent 2.", "utf-8");
            await writeFile(join(runtimeDir, "chapter-002.intent.md"), "intent 2", "utf-8");
            await manager.snapshotState(bookId, 2);
            // Write chapter 3 state + file
            await writeFile(join(storyDir, "current_state.md"), "# State\n\n- After chapter 3.\n", "utf-8");
            await writeFile(join(storyDir, "pending_hooks.md"), "# Hooks\n\n- hook-1\n- hook-2\n- hook-3\n- hook-4\n", "utf-8");
            await writeFile(join(storyDir, "chapter_summaries.md"), "# Summaries\n\n| 1 | Title 1 |\n| 2 | Title 2 |\n| 3 | Title 3 |\n", "utf-8");
            await writeFile(join(chaptersDir, "0003_Title_Three.md"), "# Chapter 3\n\nContent 3.", "utf-8");
            await writeFile(join(runtimeDir, "chapter-003.intent.md"), "intent 3", "utf-8");
            await manager.snapshotState(bookId, 3);
            // Save index with all 3 chapters
            const now = "2026-03-31T00:00:00Z";
            await manager.saveChapterIndex(bookId, [
                { number: 1, title: "Title One", status: "approved", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] },
                { number: 2, title: "Title Two", status: "ready-for-review", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: [], lengthWarnings: [] },
                { number: 3, title: "Title Three", status: "audit-failed", wordCount: 100, createdAt: now, updatedAt: now, auditIssues: ["pacing"], lengthWarnings: [] },
            ]);
        }
        it("restores state to the target chapter and removes subsequent chapters", async () => {
            await setupRollbackBook();
            const discarded = await manager.rollbackToChapter(bookId, 1);
            expect(discarded).toEqual([2, 3]);
            // State should be restored to chapter 1 snapshot
            const bookDir = manager.bookDir(bookId);
            const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8");
            expect(state).toContain("After chapter 1");
            expect(state).not.toContain("After chapter 3");
            const hooks = await readFile(join(bookDir, "story", "pending_hooks.md"), "utf-8");
            expect(hooks).toContain("hook-2");
            expect(hooks).not.toContain("hook-4");
            // Chapter index should only have chapter 1
            const index = await manager.loadChapterIndex(bookId);
            expect(index).toHaveLength(1);
            expect(index[0].number).toBe(1);
            expect(index[0].status).toBe("approved");
            // Chapter files for 2 and 3 should be deleted
            const chaptersDir = join(bookDir, "chapters");
            const { readdir: rd } = await import("node:fs/promises");
            const remaining = (await rd(chaptersDir)).filter((f) => f.endsWith(".md"));
            expect(remaining).toEqual(["0001_Title_One.md"]);
            // Snapshots for 2 and 3 should be deleted
            const snapshotsDir = join(bookDir, "story", "snapshots");
            const snapshots = await rd(snapshotsDir);
            expect(snapshots.sort()).toEqual(["0", "1"]);
        });
        it("rolls back to chapter 0 (initial state) when rejecting chapter 1", async () => {
            await setupRollbackBook();
            const discarded = await manager.rollbackToChapter(bookId, 0);
            expect(discarded).toEqual([1, 2, 3]);
            const bookDir = manager.bookDir(bookId);
            const state = await readFile(join(bookDir, "story", "current_state.md"), "utf-8");
            expect(state).toContain("Initial state");
            const index = await manager.loadChapterIndex(bookId);
            expect(index).toHaveLength(0);
        });
        it("throws when the target snapshot does not exist", async () => {
            await setupRollbackBook();
            await expect(manager.rollbackToChapter(bookId, 99)).rejects.toThrow("Cannot restore snapshot");
        });
        it("removes sqlite memory files when rolling back", async () => {
            await setupRollbackBook();
            const storyDir = join(manager.bookDir(bookId), "story");
            await Promise.all([
                writeFile(join(storyDir, "memory.db"), "stale db", "utf-8"),
                writeFile(join(storyDir, "memory.db-shm"), "stale shm", "utf-8"),
                writeFile(join(storyDir, "memory.db-wal"), "stale wal", "utf-8"),
            ]);
            await manager.rollbackToChapter(bookId, 1);
            await expect(stat(join(storyDir, "memory.db"))).rejects.toThrow();
            await expect(stat(join(storyDir, "memory.db-shm"))).rejects.toThrow();
            await expect(stat(join(storyDir, "memory.db-wal"))).rejects.toThrow();
        });
    });
});
//# sourceMappingURL=state-manager.test.js.map