import { describe, expect, it } from 'vitest';

import { ForbiddenError, PreconditionFailedError, ValidationError } from '../errors/index.js';
import { testCashier, testDean, testRegistrar, testStudent } from '../testing/context.js';
import { applicationWorkflow, type ApplicationStatus } from './application.js';
import {
  DEFAULT_ENROLLMENT_POLICY,
  enrollmentWorkflow,
  resolveEnrollmentConditions,
} from './enrollment.js';
import { gradeSubmissionWorkflow, isGradeVisibleToStudent, isGradeSheetLocked } from './grade.js';
import { paymentWorkflow, paymentCountsTowardBalance } from './finance.js';
import { evaluateClearance } from './services.js';
import { evaluateRequirementCompletion, requirementWorkflow } from './requirement.js';
import { leadWorkflow } from './lead.js';
import { availableTransitions, canTransition, nextStates, transition } from './machine.js';
import type { WorkflowDefinition } from './machine.js';

const ALL_WORKFLOWS: WorkflowDefinition<string>[] = [
  applicationWorkflow as WorkflowDefinition<string>,
  requirementWorkflow as WorkflowDefinition<string>,
  enrollmentWorkflow as WorkflowDefinition<string>,
  gradeSubmissionWorkflow as WorkflowDefinition<string>,
  paymentWorkflow as WorkflowDefinition<string>,
  leadWorkflow as WorkflowDefinition<string>,
];

describe('workflow definitions are internally consistent', () => {
  for (const workflow of ALL_WORKFLOWS) {
    describe(workflow.name, () => {
      it('declares an initial state that exists', () => {
        expect(workflow.states).toContain(workflow.initial);
      });

      it('only references declared states in its transition table', () => {
        const declared = new Set<string>(workflow.states);
        for (const [from, targets] of Object.entries(workflow.transitions)) {
          expect(declared.has(from), `unknown source state ${from}`).toBe(true);
          for (const to of Object.keys(targets ?? {})) {
            expect(declared.has(to), `unknown target state ${to}`).toBe(true);
          }
        }
      });

      it('has no outgoing transitions from a terminal state', () => {
        for (const terminal of workflow.terminal) {
          expect(nextStates(workflow, terminal)).toEqual([]);
        }
      });

      it('gives every transition a label', () => {
        for (const targets of Object.values(workflow.transitions)) {
          for (const rule of Object.values(targets ?? {})) {
            expect(rule?.label?.length ?? 0).toBeGreaterThan(0);
          }
        }
      });

      it('makes every non-initial, non-terminal state reachable from the initial state', () => {
        const reachable = new Set<string>([workflow.initial]);
        const queue = [workflow.initial];
        while (queue.length > 0) {
          const current = queue.shift() as string;
          for (const next of nextStates(workflow, current)) {
            if (!reachable.has(next)) {
              reachable.add(next);
              queue.push(next);
            }
          }
        }
        for (const state of workflow.states) {
          expect(reachable.has(state), `${state} is unreachable`).toBe(true);
        }
      });
    });
  }
});

describe('transition — declared moves only', () => {
  it('rejects a move that is not in the table', () => {
    expect(() =>
      transition(applicationWorkflow, {
        from: 'SUBMITTED',
        to: 'ENROLLED',
        ctx: testRegistrar(),
      }),
    ).toThrow(ValidationError);
  });

  it('lists the permitted next states when a move is rejected', () => {
    try {
      transition(applicationWorkflow, { from: 'SUBMITTED', to: 'ACCEPTED', ctx: testRegistrar() });
      expect.unreachable('should have thrown');
    } catch (error) {
      const meta = (error as ValidationError).toPublicJSON().meta as { allowed: string[] };
      expect(meta.allowed).toContain('UNDER_REVIEW');
    }
  });

  it('rejects a no-op transition', () => {
    expect(() =>
      transition(applicationWorkflow, { from: 'SUBMITTED', to: 'SUBMITTED', ctx: testRegistrar() }),
    ).toThrow(/already in/);
  });

  it('refuses to move out of a terminal state', () => {
    expect(() =>
      transition(applicationWorkflow, {
        from: 'ENROLLED',
        to: 'WITHDRAWN',
        ctx: testRegistrar(),
      }),
    ).toThrow(/final state/);
  });
});

describe('transition — permissions', () => {
  it('allows an actor with the required permission', () => {
    const outcome = transition(applicationWorkflow, {
      from: 'SUBMITTED',
      to: 'UNDER_REVIEW',
      ctx: testRegistrar({ permissions: ['application.review'] }),
    });
    expect(outcome.to).toBe('UNDER_REVIEW');
  });

  it('rejects an actor without it', () => {
    expect(() =>
      transition(applicationWorkflow, {
        from: 'SUBMITTED',
        to: 'UNDER_REVIEW',
        ctx: testCashier(),
      }),
    ).toThrow(ForbiddenError);
  });

  it('prevents a cashier from publishing grades', () => {
    expect(() =>
      transition(gradeSubmissionWorkflow, {
        from: 'SUBMITTED',
        to: 'PUBLISHED',
        ctx: testCashier(),
        satisfied: ['GRADES_SUBMITTED'],
      }),
    ).toThrow(ForbiddenError);
  });

  it('prevents a student from approving their own pre-registration', () => {
    expect(() =>
      transition(enrollmentWorkflow, {
        from: 'PENDING_DEAN_APPROVAL',
        to: 'DEAN_APPROVED',
        ctx: testStudent('student-1'),
      }),
    ).toThrow(ForbiddenError);
  });
});

describe('transition — scope', () => {
  it('rejects a Dean acting outside their college', () => {
    expect(() =>
      transition(applicationWorkflow, {
        from: 'DEAN_INTERVIEW_COMPLETED',
        to: 'ACCEPTED',
        ctx: testDean(['college-medicine']),
        target: { collegeId: 'college-nursing' },
      }),
    ).toThrow(ForbiddenError);
  });

  it('allows a Dean acting within their college', () => {
    expect(() =>
      transition(applicationWorkflow, {
        from: 'DEAN_INTERVIEW_COMPLETED',
        to: 'ACCEPTED',
        ctx: testDean(['college-medicine']),
        target: { collegeId: 'college-medicine' },
      }),
    ).not.toThrow();
  });
});

describe('transition — mandatory reasons (SPEC.md §33.13)', () => {
  it('rejects a rejection without a reason', () => {
    expect(() =>
      transition(applicationWorkflow, {
        from: 'DEAN_INTERVIEW_COMPLETED',
        to: 'REJECTED',
        ctx: testDean(['college-medicine']),
      }),
    ).toThrow(/reason is required/i);
  });

  it('rejects a whitespace-only reason', () => {
    expect(() =>
      transition(requirementWorkflow, {
        from: 'SUBMITTED',
        to: 'REJECTED',
        ctx: testRegistrar(),
        reason: '   ',
      }),
    ).toThrow(ValidationError);
  });

  it('accepts and trims a real reason', () => {
    const outcome = transition(requirementWorkflow, {
      from: 'SUBMITTED',
      to: 'REJECTED',
      ctx: testRegistrar(),
      reason: '  Document is illegible.  ',
    });
    expect(outcome.reason).toBe('Document is illegible.');
  });

  it('reports a null reason when none was required', () => {
    const outcome = transition(requirementWorkflow, {
      from: 'SUBMITTED',
      to: 'ACCEPTED',
      ctx: testRegistrar(),
    });
    expect(outcome.reason).toBeNull();
  });
});

describe('transition — preconditions', () => {
  it('blocks when a required condition is unmet, and names it', () => {
    try {
      transition(enrollmentWorkflow, {
        from: 'PENDING_REGISTRAR_VALIDATION',
        to: 'VALIDATED',
        ctx: testRegistrar(),
        satisfied: ['CLEARANCE_COMPLETE'],
      });
      expect.unreachable('should have thrown');
    } catch (error) {
      expect(error).toBeInstanceOf(PreconditionFailedError);
      const json = (error as PreconditionFailedError).toPublicJSON();
      expect(json.details?.map((detail) => detail.path)).toEqual([
        'REQUIREMENTS_COMPLETE',
        'PRE_REGISTRATION_APPROVED',
      ]);
      expect(json.details?.[0]?.message).toMatch(/requirements/i);
    }
  });

  it('passes when every condition is satisfied', () => {
    expect(() =>
      transition(enrollmentWorkflow, {
        from: 'PENDING_REGISTRAR_VALIDATION',
        to: 'VALIDATED',
        ctx: testRegistrar(),
        satisfied: ['REQUIREMENTS_COMPLETE', 'CLEARANCE_COMPLETE', 'PRE_REGISTRATION_APPROVED'],
      }),
    ).not.toThrow();
  });
});

describe('availableTransitions', () => {
  it('lists only the moves this actor could make', () => {
    const available = availableTransitions(
      applicationWorkflow,
      'DEAN_INTERVIEW_COMPLETED',
      testDean(['college-medicine']),
    );
    expect(available.map((entry) => entry.to).sort()).toEqual(['ACCEPTED', 'REJECTED', 'WAITLISTED']);
  });

  it('is empty for an actor with no relevant permission', () => {
    expect(availableTransitions(applicationWorkflow, 'DEAN_INTERVIEW_COMPLETED', testCashier())).toEqual(
      [],
    );
  });
});

describe('admission tracks reach enrollment', () => {
  const walk = (path: readonly ApplicationStatus[]): void => {
    for (let i = 0; i < path.length - 1; i += 1) {
      const from = path[i] as ApplicationStatus;
      const to = path[i + 1] as ApplicationStatus;
      expect(canTransition(applicationWorkflow, from, to), `${from} → ${to}`).toBe(true);
    }
  };

  it('supports the freshman path', () => {
    walk([
      'DRAFT',
      'SUBMITTED',
      'UNDER_REVIEW',
      'EXAM_SCHEDULED',
      'EXAM_COMPLETED',
      'REQUIREMENTS_PENDING',
      'REQUIREMENTS_COMPLETE',
      'FOR_INTERVIEW',
      'DEAN_INTERVIEW_SCHEDULED',
      'DEAN_INTERVIEW_COMPLETED',
      'ACCEPTED',
      'ENROLLMENT_IN_PROGRESS',
      'ENROLLED',
    ]);
  });

  it('supports the transferee path through Dean subject evaluation', () => {
    walk([
      'SUBMITTED',
      'UNDER_REVIEW',
      'FOR_EVALUATION',
      'EVALUATED',
      'REQUIREMENTS_PENDING',
      'REQUIREMENTS_COMPLETE',
      'FOR_INTERVIEW',
      'DEAN_INTERVIEW_SCHEDULED',
      'DEAN_INTERVIEW_COMPLETED',
      'ACCEPTED',
      'ENROLLMENT_IN_PROGRESS',
      'ENROLLED',
    ]);
  });

  it('supports the Doctor of Medicine path with NMAT screening and a panel interview', () => {
    walk([
      'SUBMITTED',
      'UNDER_REVIEW',
      'SCREENING',
      'SCREENING_PASSED',
      'REQUIREMENTS_PENDING',
      'REQUIREMENTS_COMPLETE',
      'FOR_INTERVIEW',
      'PANEL_INTERVIEW_SCHEDULED',
      'PANEL_INTERVIEW_COMPLETED',
      'DEAN_INTERVIEW_SCHEDULED',
      'DEAN_INTERVIEW_COMPLETED',
      'ACCEPTED',
      'ENROLLMENT_IN_PROGRESS',
      'ENROLLED',
    ]);
  });

  it('never lets a Medicine applicant be forced through the undergraduate exam step', () => {
    // Screening and the entrance examination are alternative branches from
    // UNDER_REVIEW, never sequential — SPEC.md §13.D.
    expect(canTransition(applicationWorkflow, 'SCREENING_PASSED', 'EXAM_SCHEDULED')).toBe(false);
  });
});

describe('enrollment completion policy', () => {
  const baseInput = {
    isContinuingStudent: false,
    requirementsComplete: true,
    clearanceComplete: false,
    preRegistrationApproved: true,
    assessmentGenerated: true,
    paidPercent: 25,
    hasScheduleConflict: false,
    totalUnits: 21,
    maxUnits: 27,
    prerequisitesSatisfied: true,
  };

  it('does not require clearance from a new student', () => {
    const satisfied = resolveEnrollmentConditions(baseInput);
    expect(satisfied).toContain('CLEARANCE_COMPLETE');
  });

  it('requires clearance from a continuing student when the rule is enabled', () => {
    const satisfied = resolveEnrollmentConditions({ ...baseInput, isContinuingStudent: true });
    expect(satisfied).not.toContain('CLEARANCE_COMPLETE');
  });

  it('stops requiring clearance when the institution disables that rule', () => {
    const satisfied = resolveEnrollmentConditions(
      { ...baseInput, isContinuingStudent: true },
      { ...DEFAULT_ENROLLMENT_POLICY, requireClearanceForContinuing: false },
    );
    expect(satisfied).toContain('CLEARANCE_COMPLETE');
  });

  it('honours a configured minimum down payment', () => {
    expect(resolveEnrollmentConditions({ ...baseInput, paidPercent: 10 })).not.toContain(
      'MINIMUM_PAYMENT_MET',
    );
    expect(
      resolveEnrollmentConditions(
        { ...baseInput, paidPercent: 10 },
        { ...DEFAULT_ENROLLMENT_POLICY, minimumDownPaymentPercent: 10 },
      ),
    ).toContain('MINIMUM_PAYMENT_MET');
  });

  it('blocks an overloaded unit count', () => {
    expect(resolveEnrollmentConditions({ ...baseInput, totalUnits: 30 })).not.toContain(
      'UNIT_LOAD_WITHIN_LIMIT',
    );
  });

  it('allows a prerequisite override when enforcement is switched off', () => {
    expect(
      resolveEnrollmentConditions(
        { ...baseInput, prerequisitesSatisfied: false },
        { ...DEFAULT_ENROLLMENT_POLICY, enforcePrerequisites: false },
      ),
    ).toContain('PREREQUISITES_SATISFIED');
  });
});

describe('requirement completion', () => {
  it('ignores optional requirements', () => {
    const result = evaluateRequirementCompletion([
      { status: 'ACCEPTED', isMandatory: true },
      { status: 'NOT_SUBMITTED', isMandatory: false },
    ]);
    expect(result.isComplete).toBe(true);
    expect(result.mandatoryTotal).toBe(1);
  });

  it('counts a waiver as satisfied', () => {
    const result = evaluateRequirementCompletion([
      { status: 'WAIVED', isMandatory: true },
      { status: 'ACCEPTED', isMandatory: true },
    ]);
    expect(result.isComplete).toBe(true);
  });

  it('reports what is outstanding', () => {
    const result = evaluateRequirementCompletion([
      { status: 'ACCEPTED', isMandatory: true },
      { status: 'NEEDS_RESUBMISSION', isMandatory: true },
      { status: 'REJECTED', isMandatory: true },
    ]);
    expect(result.isComplete).toBe(false);
    expect(result.outstanding).toBe(2);
  });
});

describe('clearance evaluation', () => {
  it('is complete when every required step is cleared or waived', () => {
    expect(
      evaluateClearance([
        { status: 'CLEARED', isRequired: true },
        { status: 'WAIVED', isRequired: true },
        { status: 'PENDING', isRequired: false },
      ]).isComplete,
    ).toBe(true);
  });

  it('counts blocked offices so the student can see what to resolve', () => {
    const summary = evaluateClearance([
      { status: 'CLEARED', isRequired: true },
      { status: 'BLOCKED', isRequired: true },
    ]);
    expect(summary.isComplete).toBe(false);
    expect(summary.blocked).toBe(1);
  });
});

describe('grade visibility (SPEC.md §33.9)', () => {
  it('hides draft and submitted grades from students', () => {
    expect(isGradeVisibleToStudent('DRAFT')).toBe(false);
    expect(isGradeVisibleToStudent('SUBMITTED')).toBe(false);
    expect(isGradeVisibleToStudent('RETURNED')).toBe(false);
  });

  it('shows published and locked grades', () => {
    expect(isGradeVisibleToStudent('PUBLISHED')).toBe(true);
    expect(isGradeVisibleToStudent('LOCKED')).toBe(true);
  });

  it('locks the sheet once submitted', () => {
    expect(isGradeSheetLocked('DRAFT')).toBe(false);
    expect(isGradeSheetLocked('SUBMITTED')).toBe(true);
    expect(isGradeSheetLocked('PUBLISHED')).toBe(true);
  });

  it('requires a complete sheet before submission', () => {
    expect(() =>
      transition(gradeSubmissionWorkflow, {
        from: 'DRAFT',
        to: 'SUBMITTED',
        ctx: testRegistrar({ permissions: ['grade.submit'] }),
        satisfied: [],
      }),
    ).toThrow(PreconditionFailedError);
  });
});

describe('payments are immutable (SPEC.md §33.6)', () => {
  it('offers no transition that deletes a payment', () => {
    expect(nextStates(paymentWorkflow, 'POSTED').sort()).toEqual(['REVERSED', 'VOIDED']);
  });

  it('requires elevated permission and a reason to void', () => {
    expect(() =>
      transition(paymentWorkflow, { from: 'POSTED', to: 'VOIDED', ctx: testCashier() }),
    ).toThrow(ForbiddenError);

    expect(() =>
      transition(paymentWorkflow, {
        from: 'POSTED',
        to: 'VOIDED',
        ctx: testCashier({ permissions: ['payment.create', 'payment.void'] }),
      }),
    ).toThrow(/reason is required/i);
  });

  it('excludes voided and reversed payments from balances', () => {
    expect(paymentCountsTowardBalance('POSTED')).toBe(true);
    expect(paymentCountsTowardBalance('VOIDED')).toBe(false);
    expect(paymentCountsTowardBalance('REVERSED')).toBe(false);
  });
});
