<?php
/**
 * Gradebook calculations and grade CRUD.
 *
 * Status-driven (v1.19.32+):
 *   ungraded          -  blank, no effect on average
 *   completed_ontime  -  counts; total = raw*(1 - lp%) + bonus
 *   completed_late    -  counts; total = raw*(1 - lp%) + bonus
 *   incomplete        -  counts as 0
 *   missing           -  counts as 0
 *   excused           -  skipped (no effect)
 *   excluded          -  skipped (no effect)
 *
 * @package TradeSchoolManager
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin-owned custom tables; table names interpolated only; user values always pass through $wpdb->prepare().

class TSM_Gradebook {

	public static function table() {
		global $wpdb;
		return $wpdb->prefix . 'tsm_grades';
	}

	/**
	 * Status registry: slug => array( label, counts, zero ).
	 *   counts = does this row contribute to points_possible totals?
	 *   zero   = if counted, treat earned as 0 regardless of raw score?
	 */
	public static function statuses() {
		return array(
			''                 => array( 'label' => __( 'Ungraded',         'tradeschool-manager' ), 'counts' => false, 'zero' => false ),
			'ungraded'         => array( 'label' => __( 'Ungraded',         'tradeschool-manager' ), 'counts' => false, 'zero' => false ),
			'completed_ontime' => array( 'label' => __( 'Completed On-time','tradeschool-manager' ), 'counts' => true,  'zero' => false ),
			'completed_late'   => array( 'label' => __( 'Completed Late',   'tradeschool-manager' ), 'counts' => true,  'zero' => false ),
			'incomplete'       => array( 'label' => __( 'Incomplete',       'tradeschool-manager' ), 'counts' => true,  'zero' => true  ),
			'excused'          => array( 'label' => __( 'Excused',          'tradeschool-manager' ), 'counts' => false, 'zero' => false ),
			'missing'          => array( 'label' => __( 'Missing',          'tradeschool-manager' ), 'counts' => true,  'zero' => true  ),
			'excluded'         => array( 'label' => __( 'Excluded',         'tradeschool-manager' ), 'counts' => false, 'zero' => false ),
		);
	}

	public static function get_grade( $assignment_id, $student_id ) {
		global $wpdb;
		$t = self::table();
		return $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$t} WHERE assignment_id = %d AND student_id = %d",
				absint( $assignment_id ),
				absint( $student_id )
			)
		);
	}

	/**
	 * Compute the total points earned for a graded row, after bonus + late penalty.
	 *
	 * @param object|array $grade
	 * @return float|null  null means "not counted".
	 */
	public static function total_for( $grade ) {
		if ( ! $grade ) { return null; }
		$g       = is_object( $grade ) ? get_object_vars( $grade ) : (array) $grade;
		$status  = isset( $g['status'] ) ? (string) $g['status'] : '';
		$reg     = self::statuses();
		$info    = $reg[ $status ] ?? $reg[''];
		if ( ! $info['counts'] ) { return null; }
		if ( $info['zero'] )     { return 0.0; }
		$raw    = isset( $g['points_earned'] ) && '' !== $g['points_earned'] && null !== $g['points_earned'] ? (float) $g['points_earned'] : 0.0;
		$bonus  = isset( $g['bonus'] )         && '' !== $g['bonus']         && null !== $g['bonus']         ? (float) $g['bonus']         : 0.0;
		$lp_pct = isset( $g['late_penalty'] )  && '' !== $g['late_penalty']  && null !== $g['late_penalty']  ? (float) $g['late_penalty']  : 0.0;
		$lp_pct = max( 0, min( 100, $lp_pct ) );
		$net    = $raw * ( 1 - $lp_pct / 100 ) + $bonus;
		return max( 0, $net );
	}

	public static function save_grade( $assignment_id, $student_id, $data ) {
		global $wpdb;
		$assignment = class_exists( 'TSM_Assignments' ) ? TSM_Assignments::get( absint( $assignment_id ) ) : null;

		// Archived-year lockdown  -  refuse writes targeting a class whose
		// school year has been archived (rule follows the data, not the
		// active-year switcher, so historical years are read-only everywhere).
		if ( class_exists( 'TSM_Assignments' ) && class_exists( 'TSM_Classes' ) && class_exists( 'TSM_School_Years' ) ) {
			$c = $assignment ? TSM_Classes::get( (int) $assignment->class_id ) : null;
			if ( $c && TSM_School_Years::is_archived( (int) $c->school_year_id ) ) {
				return new WP_Error( 'tsm_year_archived', __( 'This school year is archived; grades cannot be modified. Unarchive the year to make changes.', 'tradeschool-manager' ) );
			}
		}

		$status = isset( $data['status'] ) ? sanitize_key( $data['status'] ) : '';
		if ( ! array_key_exists( $status, self::statuses() ) ) { $status = ''; }

		// Legacy callers pass 'excused' bool  -  translate.
		if ( '' === $status && ! empty( $data['excused'] ) ) { $status = 'excused'; }

		$raw   = isset( $data['points_earned'] ) && '' !== $data['points_earned'] ? (float) $data['points_earned'] : null;
		$bonus = isset( $data['bonus'] )         && '' !== $data['bonus']         ? (float) $data['bonus']         : null;
		$lp    = isset( $data['late_penalty'] )  && '' !== $data['late_penalty']  ? (float) $data['late_penalty']  : null;

		// Legacy path: if no status sent but raw score present, treat as on-time.
		$defaulted_ontime = false;
		if ( '' === $status && null !== $raw ) {
			$status = 'completed_ontime';
			$defaulted_ontime = true;
		}

		// Pass/Fail classes: the gradebook stores 1pt per pass-fail assignment
		// (points_possible = 1). If a status implies a result but no raw score
		// was provided (e.g. the simplified pass/fail UI, or a CSV row using
		// just `status=pass`), fill in the implied earned value so totals and
		// class averages compute correctly.
		if ( class_exists( 'TSM_Assignments' ) && class_exists( 'TSM_Classes' ) ) {
			$c = $assignment ? TSM_Classes::get( (int) $assignment->class_id ) : null;
			if ( $c && 'pass_fail' === $c->grading_method && null === $raw ) {
				if ( in_array( $status, array( 'completed_ontime', 'completed_late' ), true ) ) {
					$raw = (float) ( $assignment->points_possible > 0 ? $assignment->points_possible : 1 );
				} elseif ( in_array( $status, array( 'missing', 'incomplete' ), true ) ) {
					$raw = 0.0;
				}
			}
		}

		$submitted = isset( $data['submitted_date'] ) && '' !== $data['submitted_date']
			? TSM_Date_Utils::normalize( $data['submitted_date'] )
			: null;
		// Auto-stamp submitted_date when a counted-completion status is set without a date.
		if ( null === $submitted && in_array( $status, array( 'completed_ontime', 'completed_late', 'incomplete', 'missing' ), true ) ) {
			$submitted = ( $defaulted_ontime && $assignment && ! empty( $assignment->due_date ) )
				? sanitize_text_field( $assignment->due_date )
				: current_time( 'Y-m-d' );
		}
		if ( in_array( $status, array( '', 'ungraded', 'excluded' ), true ) ) {
			$submitted = null;
		}

		$record = array(
			'assignment_id'  => absint( $assignment_id ),
			'student_id'     => absint( $student_id ),
			'status'         => $status,
			'points_earned'  => $raw,
			'bonus'          => $bonus,
			'excused'        => ( 'excused' === $status ) ? 1 : 0,
			'late_penalty'   => $lp,
			'submitted_date' => $submitted,
			'comments'       => isset( $data['comments'] ) ? sanitize_textarea_field( $data['comments'] ) : '',
		);

		// If the row is fully blank (no status, no values, no comment), delete it instead of storing noise.
		if ( '' === $status && null === $raw && null === $bonus && null === $lp && empty( $record['comments'] ) ) {
			$wpdb->delete( self::table(), array(
				'assignment_id' => $record['assignment_id'],
				'student_id'    => $record['student_id'],
			) );
			return 0;
		}

		$wpdb->replace( self::table(), $record );
		return (int) $wpdb->insert_id;
	}

	/**
	 * Calculate a student's overall grade in a class.
	 *
	 * @return array{percent:?float,letter:string,earned:float,possible:float}
	 */
	public static function calc_class_grade( $class_id, $student_id ) {
		$class       = TSM_Classes::get( $class_id );
		$assignments = TSM_Assignments::for_class( $class_id );
		$earned      = 0.0;
		$possible    = 0.0;
		$any         = false;

		$method = $class && $class->grading_method ? $class->grading_method : 'points';

		if ( 'weighted' === $method ) {
			$config  = $class && $class->grading_config ? json_decode( $class->grading_config, true ) : array();
			$weights = isset( $config['categories'] ) ? $config['categories'] : array();
			$by_cat  = array();
			foreach ( $assignments as $a ) {
				$grade = self::get_grade( $a->id, $student_id );
				$total = self::total_for( $grade );
				if ( null === $total ) { continue; } // not counted (ungraded/excused/excluded)
				$any = true;
				$cat = $a->category ?: 'Uncategorized';
				if ( ! isset( $by_cat[ $cat ] ) ) {
					$by_cat[ $cat ] = array( 'earned' => 0, 'possible' => 0 );
				}
				$by_cat[ $cat ]['earned']   += $total;
				$by_cat[ $cat ]['possible'] += $a->extra_credit ? 0 : (float) $a->points_possible;
			}
			$weighted_total = 0;
			$weight_sum     = 0;
			foreach ( $by_cat as $cat => $vals ) {
				if ( $vals['possible'] <= 0 ) { continue; }
				$w               = isset( $weights[ $cat ] ) ? (float) $weights[ $cat ] : 0;
				$weighted_total += ( $vals['earned'] / $vals['possible'] ) * $w;
				$weight_sum     += $w;
			}
			$percent = $weight_sum > 0 ? ( $weighted_total / $weight_sum ) * 100 : null;
		} else {
			foreach ( $assignments as $a ) {
				$grade = self::get_grade( $a->id, $student_id );
				$total = self::total_for( $grade );
				if ( null === $total ) { continue; }
				$any       = true;
				$earned   += $total;
				$possible += $a->extra_credit ? 0 : (float) $a->points_possible;
			}
			$percent = ( $any && $possible > 0 ) ? ( $earned / $possible ) * 100 : null;
		}

		if ( 'pass_fail' === $method ) {
			if ( ! $any || null === $percent ) {
				$letter = '';
			} else {
				$pass_at = $class && isset( $class->passing_pct ) ? (float) $class->passing_pct : 70;
				$letter  = ( $percent >= $pass_at ) ? __( 'Pass', 'tradeschool-manager' ) : __( 'Fail', 'tradeschool-manager' );
			}
		} else {
			$letter = self::percent_to_letter( $percent );
		}

		return array(
			'percent'  => null === $percent ? null : round( $percent, 2 ),
			'letter'   => $letter,
			'earned'   => $earned,
			'possible' => $possible,
		);
	}

	public static function percent_to_letter( $percent ) {
		if ( null === $percent ) {
			return ' - ';
		}
		$scale = tsm_get_setting( 'grading_scale', "A: 90\nB: 80\nC: 70\nD: 60\nF: 0" );
		$lines = preg_split( '/\r?\n/', $scale );
		$pairs = array();
		foreach ( $lines as $l ) {
			if ( preg_match( '/^\s*([A-Z][+\-]?)\s*:\s*([0-9.]+)/', $l, $m ) ) {
				$pairs[] = array( $m[1], (float) $m[2] );
			}
		}
		usort( $pairs, function ( $a, $b ) {
			return $b[1] <=> $a[1];
		} );
		foreach ( $pairs as $p ) {
			if ( $percent >= $p[1] ) {
				return $p[0];
			}
		}
		return 'F';
	}
}
