/**
 * Schedule Store + Types + ULID — unit tests.
 *
 * Purpose: verify the schedule storage layer contracts: append-only writes,
 * status-grouping semantics, time-based queries, ULID monotonicity, and
 * corrupt-line resilience.
 *
 * Consumer: CI test runner (vitest).
 */
import assert from "node:assert/strict";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "vitest";
import {
	createScheduleStore,
	resolveSchedulePath,
} from "../schedule/schedule-store.js";
import { isValidKind } from "../schedule/schedule-types.js";
import { generateULID } from "../schedule/schedule-ulid.js";
import { closeDatabase } from "../sf-db.js";

describe("schedule-types", () => {
	describe("isValidKind", () => {
		it("accepts known kinds", () => {
			assert.equal(isValidKind("reminder"), true);
			assert.equal(isValidKind("milestone_check"), true);
			assert.equal(isValidKind("review_due"), true);
			assert.equal(isValidKind("recurring"), true);
			assert.equal(isValidKind("review"), true);
			assert.equal(isValidKind("audit"), true);
			assert.equal(isValidKind("command"), true);
		});

		it("rejects unknown kinds", () => {
			assert.equal(isValidKind("unknown"), false);
			assert.equal(isValidKind(""), false);
			assert.equal(isValidKind(null), false);
			assert.equal(isValidKind(undefined), false);
			assert.equal(isValidKind(42), false);
		});
	});
});

describe("schedule-ulid", () => {
	describe("generateULID", () => {
		it("produces 28-character strings starting with 01", () => {
			const id = generateULID();
			assert.equal(typeof id, "string");
			assert.equal(id.length, 28);
			assert.equal(id.startsWith("01"), true);
		});

		it("uses only Crockford-Base32 characters", () => {
			const id = generateULID();
			const crockford = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]+$/;
			assert.ok(crockford.test(id), `id ${id} contains non-Crockford chars`);
		});

		it("generates unique IDs across many calls", () => {
			const ids = new Set();
			for (let i = 0; i < 1000; i++) {
				ids.add(generateULID());
			}
			assert.equal(ids.size, 1000);
		});

		it("is monotonic within the same millisecond", () => {
			const ids = [];
			// Generate several IDs in rapid succession — likely same ms
			for (let i = 0; i < 20; i++) {
				ids.push(generateULID());
			}
			for (let i = 1; i < ids.length; i++) {
				assert.ok(
					ids[i] > ids[i - 1],
					`expected ${ids[i]} > ${ids[i - 1]} at index ${i}`,
				);
			}
		});
	});
});

describe("schedule-store", () => {
	/** @type {string} */
	let testDir;
	/** @type {ReturnType<typeof createScheduleStore>} */
	let store;

	beforeEach(() => {
		testDir = join(
			tmpdir(),
			`sf-schedule-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
		);
		mkdirSync(testDir, { recursive: true });
		store = createScheduleStore(testDir);
	});

	afterEach(() => {
		closeDatabase();
		try {
			rmSync(testDir, { recursive: true });
		} catch {
			// ignore
		}
	});

	function makeEntry(overrides = {}) {
		const now = new Date().toISOString();
		return {
			id: generateULID(),
			kind: "reminder",
			status: "pending",
			due_at: now,
			created_at: now,
			payload: { message: "test" },
			created_by: "user",
			...overrides,
		};
	}

	describe("appendEntry + readEntries", () => {
		it("writes an entry and reads it back", () => {
			const entry = makeEntry();
			store.appendEntry("project", entry);
			const entries = store.readEntries("project");
			assert.equal(entries.length, 1);
			assert.equal(entries[0].id, entry.id);
		});

		it("writes DB-first without creating a legacy JSONL file", () => {
			const entry = makeEntry();
			store.appendEntry("project", entry);
			const filePath = store._filePathForScope("project");
			assert.equal(existsSync(filePath), false);
		});

		it("appends multiple entries", () => {
			const e1 = makeEntry();
			const e2 = makeEntry();
			store.appendEntry("project", e1);
			store.appendEntry("project", e2);
			const entries = store.readEntries("project");
			assert.equal(entries.length, 2);
		});

		it("groups by id keeping the latest created_at", () => {
			const id = generateULID();
			const early = makeEntry({
				id,
				status: "pending",
				created_at: "2024-01-01T00:00:00.000Z",
			});
			const late = makeEntry({
				id,
				status: "done",
				created_at: "2024-01-02T00:00:00.000Z",
			});
			store.appendEntry("project", early);
			store.appendEntry("project", late);
			const entries = store.readEntries("project");
			assert.equal(entries.length, 1);
			assert.equal(entries[0].status, "done");
		});

		it("returns empty array for missing file", () => {
			const entries = store.readEntries("project");
			assert.deepEqual(entries, []);
		});
	});

	describe("findDue", () => {
		it("returns only pending entries at or before now", () => {
			const past = makeEntry({
				due_at: "2024-01-01T00:00:00.000Z",
				status: "pending",
			});
			const future = makeEntry({
				due_at: "2030-01-01T00:00:00.000Z",
				status: "pending",
			});
			const done = makeEntry({
				due_at: "2024-01-01T00:00:00.000Z",
				status: "done",
			});
			store.appendEntry("project", past);
			store.appendEntry("project", future);
			store.appendEntry("project", done);

			const due = store.findDue("project", "2024-06-01T00:00:00.000Z");
			assert.equal(due.length, 1);
			assert.equal(due[0].id, past.id);
		});

		it("returns snoozed entries whose new due_at has passed", () => {
			const snoozed = makeEntry({
				due_at: "2024-01-01T00:00:00.000Z",
				status: "snoozed",
				snoozed_at: "2023-12-01T00:00:00.000Z",
			});
			store.appendEntry("project", snoozed);
			const due = store.findDue("project", "2024-06-01T00:00:00.000Z");
			assert.equal(due.length, 1);
			assert.equal(due[0].id, snoozed.id);
		});

		it("sorts results by due_at ascending", () => {
			const e1 = makeEntry({ due_at: "2024-01-02T00:00:00.000Z" });
			const e2 = makeEntry({ due_at: "2024-01-01T00:00:00.000Z" });
			store.appendEntry("project", e1);
			store.appendEntry("project", e2);

			const due = store.findDue("project", "2024-06-01T00:00:00.000Z");
			assert.equal(due[0].id, e2.id);
			assert.equal(due[1].id, e1.id);
		});
	});

	describe("findUpcoming", () => {
		it("returns pending entries within the window", () => {
			const past = makeEntry({
				due_at: "2024-01-01T00:00:00.000Z",
				status: "pending",
			});
			const soon = makeEntry({
				due_at: "2024-01-02T00:00:00.000Z",
				status: "pending",
			});
			const far = makeEntry({
				due_at: "2024-01-15T00:00:00.000Z",
				status: "pending",
			});
			store.appendEntry("project", past);
			store.appendEntry("project", soon);
			store.appendEntry("project", far);

			const upcoming = store.findUpcoming(
				"project",
				"2024-01-01T00:00:00.000Z",
				7,
			);
			assert.equal(upcoming.length, 1);
			assert.equal(upcoming[0].id, soon.id);
		});

		it("excludes done/cancelled entries", () => {
			const soonDone = makeEntry({
				due_at: "2024-01-02T00:00:00.000Z",
				status: "done",
			});
			store.appendEntry("project", soonDone);

			const upcoming = store.findUpcoming(
				"project",
				"2024-01-01T00:00:00.000Z",
				7,
			);
			assert.equal(upcoming.length, 0);
		});

		it("includes snoozed entries within the window", () => {
			const snoozed = makeEntry({
				due_at: "2024-01-02T00:00:00.000Z",
				status: "snoozed",
				snoozed_at: "2023-12-01T00:00:00.000Z",
			});
			store.appendEntry("project", snoozed);

			const upcoming = store.findUpcoming(
				"project",
				"2024-01-01T00:00:00.000Z",
				7,
			);
			assert.equal(upcoming.length, 1);
			assert.equal(upcoming[0].id, snoozed.id);
		});

		it("sorts results by due_at ascending", () => {
			const e1 = makeEntry({ due_at: "2024-01-03T00:00:00.000Z" });
			const e2 = makeEntry({ due_at: "2024-01-02T00:00:00.000Z" });
			store.appendEntry("project", e1);
			store.appendEntry("project", e2);

			const upcoming = store.findUpcoming(
				"project",
				"2024-01-01T00:00:00.000Z",
				7,
			);
			assert.equal(upcoming[0].id, e2.id);
			assert.equal(upcoming[1].id, e1.id);
		});
	});

	describe("legacy JSONL import", () => {
		it("skips corrupt JSONL lines and imports valid entries into DB", () => {
			const entry = makeEntry();
			const filePath = store._filePathForScope("project");
			mkdirSync(filePath.slice(0, filePath.lastIndexOf("/")), {
				recursive: true,
			});
			writeFileSync(
				filePath,
				`${JSON.stringify(entry)}\nthis is not json\n`,
				"utf-8",
			);

			const entries = store.readEntries("project");
			assert.equal(entries.length, 1);
			assert.equal(entries[0].id, entry.id);

			writeFileSync(filePath, "", "utf-8");
			const fromDb = store.readEntries("project");
			assert.equal(fromDb.length, 1);
			assert.equal(fromDb[0].id, entry.id);
		});
	});

	describe("resolveSchedulePath", () => {
		it("returns project path for project scope", () => {
			const p = resolveSchedulePath(testDir, "project");
			assert.ok(p.endsWith("schedule.jsonl"));
			assert.ok(p.includes(".sf"));
		});

		it("returns global path for global scope", () => {
			const p = resolveSchedulePath(testDir, "global");
			assert.ok(p.endsWith("schedule.jsonl"));
			assert.ok(p.includes(".sf") || p.startsWith(tmpdir()));
		});
	});
});
