import { ValidationError } from '../errors/index.js';

/**
 * Grading rules (SPEC.md §20).
 *
 * No scale is hard-coded. A `GradeScale` is a database record bound to a
 * program, so the Philippine 1.00–5.00 convention used by most of the
 * institution and any different scale used by the College of Medicine coexist
 * without a code branch (ASSUMPTIONS.md F1).
 */

/** One valid mark on a scale. */
export interface GradeScaleItem {
  /** What the faculty member enters and the student sees, e.g. `1.25` or `INC`. */
  readonly value: string;
  /** Numeric weight for averaging. `null` excludes the mark from the GWA. */
  readonly numericValue: number | null;
  readonly label: string;
  readonly isPassing: boolean;
  /** True for INC, DRP, W — marks that are not an assessment of performance. */
  readonly isAdministrative: boolean;
  readonly sortOrder: number;
}

export interface GradeScale {
  readonly id: string;
  readonly code: string;
  readonly name: string;
  /** Whether a lower number is a better grade, as in the 1.00-highest scale. */
  readonly lowerIsBetter: boolean;
  readonly items: readonly GradeScaleItem[];
}

export function findScaleItem(scale: GradeScale, value: string): GradeScaleItem | undefined {
  const normalized = value.trim().toUpperCase();
  return scale.items.find((item) => item.value.toUpperCase() === normalized);
}

/**
 * Validates a mark against its scale. Rejecting unknown values here is what
 * stops a typo — `1.O0` instead of `1.00` — from reaching a permanent record.
 */
export function validateGradeValue(scale: GradeScale, value: string): GradeScaleItem {
  const item = findScaleItem(scale, value);
  if (!item) {
    const permitted = scale.items.map((entry) => entry.value).join(', ');
    throw new ValidationError(
      `"${value}" is not a valid mark on the ${scale.name} scale. Permitted marks: ${permitted}.`,
      { details: [{ path: 'value', message: `Permitted marks: ${permitted}` }] },
    );
  }
  return item;
}

export interface GradeEntry {
  readonly courseCode: string;
  readonly units: number;
  readonly value: string;
}

export interface GeneralWeightedAverage {
  /** Units-weighted average, or null when no numeric grade contributes. */
  readonly gwa: number | null;
  readonly unitsIncluded: number;
  readonly unitsExcluded: number;
  readonly coursesIncluded: number;
}

/**
 * Units-weighted average of numeric marks. Administrative marks (INC, DRP, W)
 * carry no numeric value and are excluded, with their units reported separately
 * so the student can see why their average covers fewer units than they took.
 */
export function computeGWA(
  scale: GradeScale,
  entries: readonly GradeEntry[],
): GeneralWeightedAverage {
  let weightedSum = 0;
  let unitsIncluded = 0;
  let unitsExcluded = 0;
  let coursesIncluded = 0;

  for (const entry of entries) {
    const item = findScaleItem(scale, entry.value);
    if (!item || item.numericValue === null) {
      unitsExcluded += entry.units;
      continue;
    }
    weightedSum += item.numericValue * entry.units;
    unitsIncluded += entry.units;
    coursesIncluded += 1;
  }

  return {
    gwa: unitsIncluded === 0 ? null : Math.round((weightedSum / unitsIncluded) * 1000) / 1000,
    unitsIncluded,
    unitsExcluded,
    coursesIncluded,
  };
}

export interface GradeSheetRow {
  readonly studentId: string;
  readonly value: string | null;
}

export interface GradeSheetReadiness {
  readonly isComplete: boolean;
  readonly total: number;
  readonly entered: number;
  readonly missingStudentIds: readonly string[];
}

/**
 * Whether every student in the class list has a mark. Submission requires a
 * complete sheet — a blank cell in an official record is ambiguous in a way an
 * explicit INC is not.
 */
export function evaluateGradeSheet(rows: readonly GradeSheetRow[]): GradeSheetReadiness {
  const missing = rows.filter((row) => row.value === null || row.value.trim() === '');
  return {
    isComplete: rows.length > 0 && missing.length === 0,
    total: rows.length,
    entered: rows.length - missing.length,
    missingStudentIds: missing.map((row) => row.studentId),
  };
}

/**
 * The standard Philippine numeric scale, used to seed the default. It lives
 * here only so the seed has a starting point — the runtime always reads the
 * scale from the database.
 */
export const DEFAULT_NUMERIC_SCALE_ITEMS: readonly Omit<GradeScaleItem, 'sortOrder'>[] = [
  { value: '1.00', numericValue: 1.0, label: 'Excellent', isPassing: true, isAdministrative: false },
  { value: '1.25', numericValue: 1.25, label: 'Very Good', isPassing: true, isAdministrative: false },
  { value: '1.50', numericValue: 1.5, label: 'Very Good', isPassing: true, isAdministrative: false },
  { value: '1.75', numericValue: 1.75, label: 'Good', isPassing: true, isAdministrative: false },
  { value: '2.00', numericValue: 2.0, label: 'Good', isPassing: true, isAdministrative: false },
  { value: '2.25', numericValue: 2.25, label: 'Satisfactory', isPassing: true, isAdministrative: false },
  { value: '2.50', numericValue: 2.5, label: 'Satisfactory', isPassing: true, isAdministrative: false },
  { value: '2.75', numericValue: 2.75, label: 'Fair', isPassing: true, isAdministrative: false },
  { value: '3.00', numericValue: 3.0, label: 'Passing', isPassing: true, isAdministrative: false },
  { value: '5.00', numericValue: 5.0, label: 'Failed', isPassing: false, isAdministrative: false },
  { value: 'INC', numericValue: null, label: 'Incomplete', isPassing: false, isAdministrative: true },
  { value: 'DRP', numericValue: null, label: 'Dropped', isPassing: false, isAdministrative: true },
  { value: 'W', numericValue: null, label: 'Withdrawn', isPassing: false, isAdministrative: true },
];
