import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

/**
 * Program scoping is an authorization boundary over identifiable academic
 * records, so it is tested against a REAL database rather than with mocks: two
 * programs are populated, and every read path is asserted to return only the
 * caller's own program.
 *
 * The database path is set before any module imports `@/lib/db`, because the
 * connection is created lazily and cached for the process.
 */

const dbFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "occp-scope-")), "scope.sqlite");
process.env.OCCP_SQLITE_PATH = dbFile;
delete process.env.DATABASE_URL;

// Imported dynamically so the env var above is in place first.
type Mod = {
  db: typeof import("@/lib/db");
  scope: typeof import("@/lib/auth/scope");
  students: typeof import("@/lib/services/students");
  curricula: typeof import("@/lib/services/curricula");
  reports: typeof import("@/lib/exports/reports");
  completion: typeof import("@/lib/services/completion");
  assessment: typeof import("@/lib/services/assessment");
};
let m: Mod;

const MED = "program-med";
const NUR = "program-nur";

const ids = {
  medCurriculum: "curr-med",
  nurCurriculum: "curr-nur",
  medStudent: "student-med",
  nurStudent: "student-nur",
  medRule: "rule-med",
  nurRule: "rule-nur",
};

beforeAll(async () => {
  m = {
    db: await import("@/lib/db"),
    scope: await import("@/lib/auth/scope"),
    students: await import("@/lib/services/students"),
    curricula: await import("@/lib/services/curricula"),
    reports: await import("@/lib/exports/reports"),
    completion: await import("@/lib/services/completion"),
    assessment: await import("@/lib/services/assessment"),
  };

  const db = await m.db.getDb();
  const schema = fs.readFileSync(path.join(process.cwd(), "db", "schema.sqlite.sql"), "utf8");
  await db.exec(schema);

  const ts = "2026-01-01T00:00:00.000Z";
  const program = async (id: string, code: string, name: string) =>
    db.run(
      "INSERT INTO programs (id, code, name, active, notes, created_at, updated_at) VALUES (?, ?, ?, 1, NULL, ?, ?)",
      [id, code, name, ts, ts],
    );
  await program(MED, "MED", "College of Medicine");
  await program(NUR, "NUR", "College of Nursing");

  const curriculum = async (id: string, code: string, programId: string) => {
    await db.run(
      `INSERT INTO curricula (id, code, name, effective_year, version, status, year_long_credit_policy,
         program_id, created_at, updated_at)
       VALUES (?, ?, ?, 2025, '1', 'PUBLISHED', 'ON_FINAL_PASS', ?, ?, ?)`,
      [id, code, `${code} curriculum`, programId, ts, ts],
    );
    await db.run(
      `INSERT INTO curriculum_courses (id, curriculum_id, course_code, course_code_key, course_title, units,
         intended_year, term, required, counts_toward_program_units, is_year_long_component, sort_order, active,
         created_at, updated_at)
       VALUES (?, ?, ?, ?, ?, 10, 1, '1ST SEMESTER', 1, 1, 0, 0, 1, ?, ?)`,
      [`${id}-course`, id, `${code} 101`, `${code}101`, `${code} Course`, ts, ts],
    );
  };
  await curriculum(ids.medCurriculum, "MED", MED);
  await curriculum(ids.nurCurriculum, "NUR", NUR);

  const student = async (id: string, number: string, last: string, curriculumId: string, programId: string) =>
    db.run(
      `INSERT INTO students (id, student_number, student_number_key, last_name, first_name,
         curriculum_id, curriculum_assignment_method, active, program_id, created_at, updated_at)
       VALUES (?, ?, ?, ?, 'Test', ?, 'AUTO', 1, ?, ?, ?)`,
      [id, number, number.replace(/-/g, ""), last, curriculumId, programId, ts, ts],
    );
  await student(ids.medStudent, "MED-0001", "Medico", ids.medCurriculum, MED);
  await student(ids.nurStudent, "NUR-0001", "Nurso", ids.nurCurriculum, NUR);

  const rule = async (id: string, name: string, curriculumId: string, prefix: string) =>
    db.run(
      `INSERT INTO curriculum_assignment_rules (id, name, curriculum_id, rule_type, prefix, priority, active,
         created_at, updated_at)
       VALUES (?, ?, ?, 'PREFIX', ?, 100, 1, ?, ?)`,
      [id, name, curriculumId, prefix, ts, ts],
    );
  await rule(ids.medRule, "MED prefix", ids.medCurriculum, "MED");
  await rule(ids.nurRule, "NUR prefix", ids.nurCurriculum, "NUR");
});

afterAll(async () => {
  const db = await m.db.getDb();
  await db.close();
  fs.rmSync(path.dirname(dbFile), { recursive: true, force: true });
});

const medScope = () => ({ programId: MED });
const nurScope = () => ({ programId: NUR });
const globalScope = () => ({ programId: null });

describe("programFilter", () => {
  it("is a no-op for a global scope", () => {
    const filter = m.scope.programFilter(globalScope(), "s.program_id");
    expect(filter.sql).toBe("1 = 1");
    expect(filter.params).toEqual([]);
  });

  it("narrows to the scope's program otherwise", () => {
    const filter = m.scope.programFilter(medScope(), "s.program_id");
    expect(filter.sql).toBe("s.program_id = ?");
    expect(filter.params).toEqual([MED]);
  });
});

describe("student list isolation", () => {
  it("a global user sees every program", async () => {
    const { rows } = await m.students.listStudents({}, globalScope());
    expect(rows.map((r) => r.student_number).sort()).toEqual(["MED-0001", "NUR-0001"]);
  });

  it("a Medicine user sees only Medicine students", async () => {
    const { rows, total } = await m.students.listStudents({}, medScope());
    expect(rows.map((r) => r.student_number)).toEqual(["MED-0001"]);
    expect(total).toBe(1);
  });

  it("a Nursing user sees only Nursing students", async () => {
    const { rows } = await m.students.listStudents({}, nurScope());
    expect(rows.map((r) => r.student_number)).toEqual(["NUR-0001"]);
  });

  it("a search cannot reach across programs", async () => {
    const { rows } = await m.students.listStudents({ search: "Nurso" }, medScope());
    expect(rows).toHaveLength(0);
  });
});

describe("curriculum isolation", () => {
  it("a scoped user sees only their own curricula", async () => {
    expect((await m.curricula.listCurricula(medScope())).map((c) => c.code)).toEqual(["MED"]);
    expect((await m.curricula.listCurricula(nurScope())).map((c) => c.code)).toEqual(["NUR"]);
    expect((await m.curricula.listCurricula(globalScope())).map((c) => c.code).sort()).toEqual(["MED", "NUR"]);
  });

  it("a curriculum from another program is invisible by id", async () => {
    expect(await m.curricula.getCurriculumSummary(ids.nurCurriculum, medScope())).toBeUndefined();
    expect(await m.curricula.getCurriculumSummary(ids.nurCurriculum, nurScope())).toBeDefined();
  });
});

describe("assignment rules isolation", () => {
  it("only rules pointing at in-scope curricula are returned", async () => {
    expect((await m.students.listActiveRules(medScope())).map((r) => r.name)).toEqual(["MED prefix"]);
    expect((await m.students.listAllRules(nurScope())).map((r) => r.name)).toEqual(["NUR prefix"]);
    expect((await m.students.listAllRules(globalScope())).map((r) => r.name).sort()).toEqual([
      "MED prefix",
      "NUR prefix",
    ]);
  });

  it("resolution cannot assign another program's curriculum", async () => {
    // "NUR-0002" matches the Nursing rule, but a Medicine user must not see it.
    const asMedicine = await m.students.resolveForStudentNumber("NUR-0002", medScope());
    expect(asMedicine.outcome).toBe("UNRESOLVED");

    const asNursing = await m.students.resolveForStudentNumber("NUR-0002", nurScope());
    expect(asNursing.outcome).toBe("MATCHED");
    expect(asNursing.curriculumId).toBe(ids.nurCurriculum);
  });
});

describe("guards fail closed", () => {
  it("assertStudentInScope rejects a student from another program", async () => {
    const db = await m.db.getDb();
    await expect(m.scope.assertStudentInScope(db, ids.nurStudent, medScope())).rejects.toThrow(
      m.scope.ProgramScopeError,
    );
    await expect(m.scope.assertStudentInScope(db, ids.nurStudent, nurScope())).resolves.toBeUndefined();
  });

  it("assertStudentInScope rejects an unknown id rather than allowing it", async () => {
    const db = await m.db.getDb();
    await expect(m.scope.assertStudentInScope(db, "does-not-exist", medScope())).rejects.toThrow(
      m.scope.ProgramScopeError,
    );
  });

  it("assertCurriculumInScope behaves the same way", async () => {
    const db = await m.db.getDb();
    await expect(m.scope.assertCurriculumInScope(db, ids.nurCurriculum, medScope())).rejects.toThrow(
      m.scope.ProgramScopeError,
    );
    await expect(m.scope.assertCurriculumInScope(db, "nope", globalScope())).resolves.toBeUndefined();
  });
});

describe("reports and exports are scoped", () => {
  it("the master list only contains the caller's program", async () => {
    const medicine = await m.reports.buildMasterList({}, medScope());
    expect(medicine.map((r) => r.student.student_number)).toEqual(["MED-0001"]);

    const everyone = await m.reports.buildMasterList({}, globalScope());
    expect(everyone).toHaveLength(2);
  });

  it("allStudentIds is scoped", async () => {
    expect(await m.completion.allStudentIds(true, medScope())).toEqual([ids.medStudent]);
    expect((await m.completion.allStudentIds(true, globalScope())).sort()).toEqual(
      [ids.medStudent, ids.nurStudent].sort(),
    );
  });
});

describe("single-record reads fail closed", () => {
  /**
   * Regression: list views were correctly filtered, but a DIRECT URL to another
   * program's student still rendered its name, curriculum and completion,
   * because the shared student layout loaded the record without a scope check.
   * Every single-record read path must guard, not just the lists.
   */
  it("the assessment view refuses a student from another program", async () => {
    await expect(m.assessment.getAssessmentView(ids.nurStudent, medScope())).rejects.toThrow(
      m.scope.ProgramScopeError,
    );
    await expect(m.assessment.getAssessmentView(ids.medStudent, medScope())).resolves.toBeTruthy();
  });

  it("a completion summary is only reachable through a scoped id list", async () => {
    // computeSummaries takes ids that a scoped query produced; the scoped
    // id source must never hand back another program's student.
    const medicineIds = await m.completion.allStudentIds(true, medScope());
    expect(medicineIds).not.toContain(ids.nurStudent);
  });

  it("a curriculum summary is undefined rather than readable across programs", async () => {
    expect(await m.curricula.getCurriculumSummary(ids.medCurriculum, nurScope())).toBeUndefined();
  });
});

describe("resolveOwningProgram", () => {
  it("forces a scoped user's own program, ignoring any requested value", () => {
    expect(m.scope.resolveOwningProgram(medScope(), NUR)).toBe(MED);
    expect(m.scope.resolveOwningProgram(medScope(), null)).toBe(MED);
  });

  it("requires a global user to choose explicitly", () => {
    expect(m.scope.resolveOwningProgram(globalScope(), null)).toBeNull();
    expect(m.scope.resolveOwningProgram(globalScope(), NUR)).toBe(NUR);
  });
});
