<?php
/**
 * Programs (v1.7.0): named curriculum + tuition container.
 *
 * - A program has classes attached (m:m). Classes still belong to a school year.
 * - Students enroll in a program (concurrent enrollments allowed). Enrollment can
 *   be billed in three ways: full invoice, payment plan, or manual (no invoice yet).
 *
 * @package TradeSchoolManager
 */

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

/*
 * Programs manages its own custom tables. Direct $wpdb queries are
 * required; table names come from $wpdb->prefix + literal strings only.
 * Hook names like `tsm_program_*` use the registered `tsm` prefix
 * (see phpcs.xml.dist)  -  kept stable so add-ons keep working.
 */
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

class TSM_Programs {

	/* ---------------- Tables ---------------- */

	public static function t_programs()    { global $wpdb; return $wpdb->prefix . 'tsm_programs'; }
	public static function t_pclasses()    { global $wpdb; return $wpdb->prefix . 'tsm_program_classes'; }
	public static function t_enrollments() { global $wpdb; return $wpdb->prefix . 'tsm_program_enrollments'; }

	/* ---------------- Programs CRUD ---------------- */

	public static function all( $args = array() ) {
		global $wpdb;
		$only_active = ! empty( $args['active'] );
		$sql = 'SELECT * FROM ' . self::t_programs();
		if ( $only_active ) {
			$sql .= " WHERE status = 'active'";
		}
		$sql .= ' ORDER BY name ASC';
		return $wpdb->get_results( $sql ); // phpcs:ignore
	}

	public static function get( $id ) {
		global $wpdb;
		return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . self::t_programs() . ' WHERE id = %d', absint( $id ) ) ); // phpcs:ignore
	}

	public static function save( $data, $id = 0 ) {
		global $wpdb;

		// Archived-year lockdown: block program create/edit when viewing an archived year.
		if ( class_exists( 'TSM_School_Years' ) && TSM_School_Years::is_active_year_archived() ) {
			return new WP_Error( 'tsm_year_archived', __( 'You are viewing an archived school year. Switch to a current year before adding or editing programs.', 'tradeschool-manager' ) );
		}
		$row = array(
			'name'                 => sanitize_text_field( $data['name'] ?? '' ),
			'code'                 => sanitize_text_field( $data['code'] ?? '' ),
			'description'          => wp_kses_post( $data['description'] ?? '' ),
			'total_cost'           => isset( $data['total_cost'] ) ? floatval( $data['total_cost'] ) : 0,
			'default_billing'      => self::sanitize_billing( $data['default_billing'] ?? 'manual' ),
			'default_installments' => max( 1, absint( $data['default_installments'] ?? 1 ) ),
			'default_frequency'    => self::sanitize_frequency( $data['default_frequency'] ?? 'monthly' ),
			'default_fee_mode'     => in_array( $data['default_fee_mode'] ?? 'none', array( 'none', 'flat', 'percent' ), true ) ? ( $data['default_fee_mode'] ?? 'none' ) : 'none',
			'default_fee_value'    => max( 0, floatval( $data['default_fee_value'] ?? 0 ) ),
			'default_deposit'      => max( 0, floatval( $data['default_deposit'] ?? 0 ) ),
			'default_application_fee' => max( 0, floatval( $data['default_application_fee'] ?? 0 ) ),
			'start_date'           => self::sanitize_date( $data['start_date'] ?? '' ),
			'end_date'             => self::sanitize_date( $data['end_date'] ?? '' ),
			'due_date_policy'      => self::sanitize_due_policy( $data['due_date_policy'] ?? 'on_start' ),
			'due_date_offset_days' => max( 0, absint( $data['due_date_offset_days'] ?? 0 ) ),
			'passing_pct'          => isset( $data['passing_pct'] ) ? max( 0, min( 100, floatval( $data['passing_pct'] ) ) ) : 70.00,
			'status'               => in_array( ( $data['status'] ?? 'active' ), array( 'active', 'archived' ), true ) ? $data['status'] : 'active',
		);
		if ( '' === $row['name'] ) {
			return new WP_Error( 'tsm_program_name', __( 'Program name is required.', 'tradeschool-manager' ) );
		}
		if ( $id ) {
			$wpdb->update( self::t_programs(), $row, array( 'id' => absint( $id ) ) );
			return absint( $id );
		}
		$wpdb->insert( self::t_programs(), $row );
		return (int) $wpdb->insert_id;
	}

	/**
	 * Mark a program completed and fire the auto-issue trigger.
	 */
	public static function mark_completed( $program_id ) {
		global $wpdb;
		$program_id = absint( $program_id );
		if ( ! $program_id ) { return false; }
		$wpdb->update( self::t_programs(), array(
			'completed_at' => current_time( 'mysql' ),
		), array( 'id' => $program_id ) );
		do_action( 'tradeschool_manager_program_completed', $program_id );

		// Cascade: mark every class linked to this program as completed too.
		// Classes that belong to a program are completed by the program; they
		// should never be acted on independently.
		if ( class_exists( 'TSM_Classes' ) ) {
			$classes = self::classes_for( $program_id );
			foreach ( (array) $classes as $c ) {
				if ( ! empty( $c->id ) && ( $c->status ?? '' ) !== 'completed' ) {
					TSM_Classes::mark_completed( (int) $c->id );
				}
			}
		}
		return true;
	}

	/**
	 * Program IDs a class belongs to (any school year).
	 * Used to hide the per-class "Mark Completed" action when the
	 * class is owned by a program.
	 */
	public static function programs_for_class( $class_id ) {
		global $wpdb;
		$class_id = absint( $class_id );
		if ( ! $class_id ) { return array(); }
		$pc = self::t_pclasses();
		return array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( // phpcs:ignore
			"SELECT DISTINCT program_id FROM {$pc} WHERE class_id = %d", $class_id
		) ) );
	}

	public static function delete( $id ) {
		global $wpdb;
		$id = absint( $id );
		$wpdb->delete( self::t_programs(), array( 'id' => $id ) );
		$wpdb->delete( self::t_pclasses(), array( 'program_id' => $id ) );
		// Leave enrollments + invoices intact for historical tracking; flag program rows as gone via FK absence.
	}

	public static function sanitize_billing( $b ) {
		return in_array( $b, array( 'full', 'plan', 'manual' ), true ) ? $b : 'manual';
	}

	public static function sanitize_frequency( $f ) {
		return in_array( $f, array( 'weekly', 'biweekly', 'monthly', 'quarterly' ), true ) ? $f : 'monthly';
	}

	public static function sanitize_due_policy( $p ) {
		return in_array( $p, array( 'on_start', 'on_approval', 'days_before_start' ), true ) ? $p : 'on_start';
	}

	public static function sanitize_date( $d ) {
		$d = trim( (string) $d );
		if ( '' === $d ) return null;
		// Accept YYYY-MM-DD only.
		return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $d ) ? $d : null;
	}

	/**
	 * Compute the due date for an auto-generated invoice based on the program's policy.
	 *
	 * @param string $start_date  Program start date (YYYY-MM-DD). May be empty.
	 * @param string $policy      one_of: on_start | on_approval | days_before_start
	 * @param int    $offset_days Used only when policy is days_before_start.
	 * @return string YYYY-MM-DD
	 */
	public static function compute_due_date( $start_date, $policy = 'on_start', $offset_days = 0 ) {
		$today = current_time( 'Y-m-d' );
		$start = self::sanitize_date( $start_date );
		if ( 'on_approval' === $policy || empty( $start ) ) {
			return $today;
		}
		if ( 'days_before_start' === $policy ) {
			return gmdate( 'Y-m-d', strtotime( $start . ' -' . max( 0, (int) $offset_days ) . ' days' ) );
		}
		return $start; // on_start
	}

	private static function frequency_step( $f ) {
		switch ( $f ) {
			case 'weekly':    return '+1 week';
			case 'biweekly':  return '+2 weeks';
			case 'quarterly': return '+3 months';
			case 'monthly':
			default:          return '+1 month';
		}
	}

	/* ---------------- Program <> Classes ---------------- */

	public static function classes_for( $program_id, $school_year_id = 0 ) {
		global $wpdb;
		$pc  = self::t_pclasses();
		$cls = $wpdb->prefix . 'tsm_classes';
		$school_year_id = absint( $school_year_id );
		if ( $school_year_id ) {
			return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore
				"SELECT c.* FROM {$pc} pc INNER JOIN {$cls} c ON c.id = pc.class_id WHERE pc.program_id = %d AND c.school_year_id = %d ORDER BY pc.sort_order, c.name",
				absint( $program_id ), $school_year_id
			) );
		}
		return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore
			"SELECT c.* FROM {$pc} pc INNER JOIN {$cls} c ON c.id = pc.class_id WHERE pc.program_id = %d ORDER BY pc.sort_order, c.name",
			absint( $program_id )
		) );
	}

	public static function set_classes( $program_id, $class_ids, $school_year_id = 0 ) {
		global $wpdb;
		$program_id     = absint( $program_id );
		$school_year_id = absint( $school_year_id );
		$pc             = self::t_pclasses();
		$cls            = $wpdb->prefix . 'tsm_classes';

		if ( $school_year_id ) {
			// Only clear pivots for classes belonging to this year; preserve other years' attachments.
			$old_ids = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore
				"SELECT pc.class_id FROM {$pc} pc INNER JOIN {$cls} c ON c.id = pc.class_id WHERE pc.program_id = %d AND c.school_year_id = %d",
				$program_id, $school_year_id
			) );
			if ( ! empty( $old_ids ) ) {
				$in = implode( ',', array_map( 'absint', $old_ids ) );
				$wpdb->query( $wpdb->prepare( "DELETE FROM {$pc} WHERE program_id = %d AND class_id IN ({$in})", $program_id ) ); // phpcs:ignore
			}
			// Determine starting sort_order so we append after preserved rows.
			$order = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(MAX(sort_order),-1)+1 FROM {$pc} WHERE program_id = %d", $program_id ) ); // phpcs:ignore
		} else {
			$wpdb->delete( $pc, array( 'program_id' => $program_id ) );
			$order = 0;
		}

		foreach ( (array) $class_ids as $cid ) {
			$cid = absint( $cid );
			if ( ! $cid ) continue;
			$wpdb->insert( $pc, array(
				'program_id' => $program_id,
				'class_id'   => $cid,
				'sort_order' => $order++,
			) );
		}
	}

	/* ---------------- Enrollments ---------------- */

	public static function enrollments_for_student( $student_id ) {
		global $wpdb;
		$en = self::t_enrollments();
		$pr = self::t_programs();
		return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore
			"SELECT e.*, p.name AS program_name, p.code AS program_code
			   FROM {$en} e LEFT JOIN {$pr} p ON p.id = e.program_id
			  WHERE e.student_id = %d
			  ORDER BY e.status='active' DESC, e.created_at DESC",
			absint( $student_id )
		) );
	}

	public static function enrollments_for_program( $program_id ) {
		global $wpdb;
		$en = self::t_enrollments();
		$st = $wpdb->prefix . 'tsm_students';
		return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore
			"SELECT e.*, s.first_name, s.last_name FROM {$en} e
			   LEFT JOIN {$st} s ON s.id = e.student_id
			  WHERE e.program_id = %d
			  ORDER BY s.last_name, s.first_name",
			absint( $program_id )
		) );
	}

	public static function get_enrollment( $id ) {
		global $wpdb;
		return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . self::t_enrollments() . ' WHERE id = %d', absint( $id ) ) ); // phpcs:ignore
	}

	/**
	 * Enroll a student in a program. Optionally bill them according to billing_mode.
	 *
	 * @param array $args student_id, program_id, school_year_id, tuition_amount,
	 *                    billing_mode (full|plan|manual), installments, frequency,
	 *                    start_date, notes.
	 * @return int|WP_Error enrollment id
	 */
	public static function enroll( $args ) {
		global $wpdb;
		$program = self::get( absint( $args['program_id'] ?? 0 ) );
		if ( ! $program ) {
			return new WP_Error( 'tsm_program_missing', __( 'Program not found.', 'tradeschool-manager' ) );
		}
		$student_id = absint( $args['student_id'] ?? 0 );
		if ( ! $student_id ) {
			return new WP_Error( 'tsm_student_missing', __( 'Student is required.', 'tradeschool-manager' ) );
		}

		$tuition = isset( $args['tuition_amount'] ) && '' !== $args['tuition_amount']
			? floatval( $args['tuition_amount'] )
			: floatval( $program->total_cost );
		$billing      = self::sanitize_billing( $args['billing_mode'] ?? $program->default_billing );
		$installments = max( 1, absint( $args['installments'] ?? $program->default_installments ?? 1 ) );
		$frequency    = self::sanitize_frequency( $args['frequency'] ?? $program->default_frequency ?? 'monthly' );
		$deposit      = max( 0, floatval( $args['deposit'] ?? ( $program->default_deposit ?? 0 ) ) );
		$fee_mode     = in_array( $args['fee_mode'] ?? ( $program->default_fee_mode ?? 'none' ), array( 'none', 'flat', 'percent' ), true ) ? ( $args['fee_mode'] ?? $program->default_fee_mode ?? 'none' ) : 'none';
		$fee_value    = max( 0, floatval( $args['fee_value'] ?? ( $program->default_fee_value ?? 0 ) ) );
		$first_pay    = self::sanitize_date( $args['first_payment_date'] ?? '' );

		$row = array(
			'program_id'         => (int) $program->id,
			'student_id'         => $student_id,
			'school_year_id'     => isset( $args['school_year_id'] ) && $args['school_year_id'] ? absint( $args['school_year_id'] ) : null,
			'tuition_amount'     => $tuition,
			'billing_mode'       => $billing,
			'installments'       => $installments,
			'frequency'          => $frequency,
			'deposit'            => $deposit,
			'fee_mode'           => $fee_mode,
			'fee_value'          => $fee_value,
			'first_payment_date' => $first_pay,
			'contract_id'        => isset( $args['contract_id'] ) ? absint( $args['contract_id'] ) : null,
			'status'             => 'active',
			'start_date'         => ! empty( $args['start_date'] ) ? sanitize_text_field( $args['start_date'] ) : current_time( 'Y-m-d' ),
			'end_date'           => null,
			'notes'              => sanitize_textarea_field( $args['notes'] ?? '' ),
		);
		$wpdb->insert( self::t_enrollments(), $row );
		$enrollment_id = (int) $wpdb->insert_id;

		// Cascade: enroll the student into every class attached to this program.
		// If a school year is specified, only classes in that year are used;
		// otherwise all attached classes are enrolled.
		if ( class_exists( 'TSM_Classes' ) ) {
			$year_for_cascade = isset( $row['school_year_id'] ) ? (int) $row['school_year_id'] : 0;
			$program_classes  = self::classes_for( (int) $program->id, $year_for_cascade );
			foreach ( (array) $program_classes as $pc_cls ) {
				TSM_Classes::enroll( (int) $pc_cls->id, $student_id );
			}
			do_action( 'tradeschool_manager_after_program_class_cascade', $enrollment_id, (int) $program->id, $student_id, $program_classes );
		}

		// Generate invoices according to billing mode.
		if ( class_exists( 'TSMP_Invoices' ) && $tuition > 0 ) {
			if ( 'full' === $billing ) {
				self::generate_full_invoice( $enrollment_id, $program, $student_id, $tuition, $args );
			} elseif ( 'plan' === $billing ) {
				// Deposit/application fee is its own invoice. The plan then bills the
				// remaining tuition plus any financing fee.
				self::maybe_create_enrollment_setup_invoice(
					$enrollment_id,
					$program,
					$student_id,
					$deposit,
					/* translators: placeholder(s) for dynamic value(s). */
					sprintf( __( '%s  -  deposit/application fee', 'tradeschool-manager' ), $program->name ),
					/* translators: placeholder(s) for dynamic value(s). */
					sprintf( __( 'Deposit / application fee  -  %s', 'tradeschool-manager' ), $program->name ),
					$args
				);

				// Amount to schedule = (tuition - deposit) + financing fee.
				$financed = max( 0, round( $tuition - $deposit, 2 ) );
				$fee      = ( 'flat' === $fee_mode )
					? round( $fee_value, 2 )
					: ( ( 'percent' === $fee_mode ) ? round( $financed * ( $fee_value / 100 ), 2 ) : 0.0 );
				$plan_total = round( $financed + $fee, 2 );
				if ( $plan_total > 0 ) {
					$plan_args = $args;
					if ( ! empty( $first_pay ) && empty( $plan_args['start_date'] ) ) {
						$plan_args['start_date'] = $first_pay;
					}
					self::generate_payment_plan( $enrollment_id, $program, $student_id, $plan_total, $installments, $frequency, $plan_args );
				}
			}
		}

		return $enrollment_id;
	}

	public static function update_enrollment_status( $enrollment_id, $status ) {
		global $wpdb;
		$status = in_array( $status, array( 'active', 'completed', 'withdrawn', 'archived' ), true ) ? $status : 'active';
		$end    = ( 'active' === $status ) ? null : current_time( 'Y-m-d' );
		$wpdb->update( self::t_enrollments(),
			array( 'status' => $status, 'end_date' => $end ),
			array( 'id' => absint( $enrollment_id ) )
		);
	}

	public static function delete_enrollment( $enrollment_id ) {
		global $wpdb;
		$wpdb->delete( self::t_enrollments(), array( 'id' => absint( $enrollment_id ) ) );
	}

	/**
	 * v1.10.29  -  Update enrollment terms (tuition, deposit, fee, installments,
	 * frequency, first payment date) and regenerate any *unpaid* invoices for
	 * this enrollment so the schedule matches the new terms.
	 *
	 * Paid invoices and any invoice with one or more recorded payments are
	 * preserved untouched  -  we only rebuild the still-open balance.
	 */
	public static function update_enrollment_terms( $enrollment_id, $args ) {
		global $wpdb;
		$enr = self::get_enrollment( $enrollment_id );
		if ( ! $enr ) { return new WP_Error( 'tsm_enrollment_missing', __( 'Enrollment not found.', 'tradeschool-manager' ) ); }
		$program = self::get( $enr->program_id );

		$tuition      = isset( $args['tuition_amount'] ) && '' !== $args['tuition_amount'] ? floatval( $args['tuition_amount'] ) : (float) $enr->tuition_amount;
		$billing      = self::sanitize_billing( $args['billing_mode'] ?? $enr->billing_mode );
		$installments = max( 1, absint( $args['installments'] ?? ( $enr->installments ?: 1 ) ) );
		$frequency    = self::sanitize_frequency( $args['frequency'] ?? ( $enr->frequency ?: 'monthly' ) );
		$deposit      = max( 0, floatval( $args['deposit'] ?? ( $enr->deposit ?? 0 ) ) );
		$fee_mode     = in_array( $args['fee_mode'] ?? ( $enr->fee_mode ?? 'none' ), array( 'none', 'flat', 'percent' ), true ) ? ( $args['fee_mode'] ?? $enr->fee_mode ?? 'none' ) : 'none';
		$fee_value    = max( 0, floatval( $args['fee_value'] ?? ( $enr->fee_value ?? 0 ) ) );
		$first_pay    = self::sanitize_date( $args['first_payment_date'] ?? ( $enr->first_payment_date ?? '' ) );

		$wpdb->update( self::t_enrollments(), array(
			'tuition_amount'     => $tuition,
			'billing_mode'       => $billing,
			'installments'       => $installments,
			'frequency'          => $frequency,
			'deposit'            => $deposit,
			'fee_mode'           => $fee_mode,
			'fee_value'          => $fee_value,
			'first_payment_date' => $first_pay,
		), array( 'id' => (int) $enr->id ) );

		// Regenerate unpaid invoices.
		$paid = self::delete_unpaid_invoices_for_enrollment( $enr->id );
		$remaining = max( 0, round( $tuition - $deposit - $paid, 2 ) );

		if ( class_exists( 'TSMP_Invoices' ) && $remaining > 0 ) {
			$args2 = array( 'start_date' => $first_pay );
			if ( 'full' === $billing ) {
				self::generate_full_invoice( $enr->id, $program, $enr->student_id, $remaining, $args2 );
			} elseif ( 'plan' === $billing ) {
				self::maybe_create_enrollment_setup_invoice(
					$enr->id,
					$program,
					$enr->student_id,
					$deposit,
					/* translators: placeholder(s) for dynamic value(s). */
					sprintf( __( '%s  -  deposit/application fee', 'tradeschool-manager' ), $program->name ),
					/* translators: placeholder(s) for dynamic value(s). */
					sprintf( __( 'Deposit / application fee  -  %s', 'tradeschool-manager' ), $program->name ),
					$args2
				);

				// Apply financing fee on top of the remaining amount.
				$fee = ( 'flat' === $fee_mode ) ? round( $fee_value, 2 ) : ( ( 'percent' === $fee_mode ) ? round( $remaining * ( $fee_value / 100 ), 2 ) : 0.0 );
				$plan_total = round( $remaining + $fee, 2 );
				if ( $plan_total > 0 ) {
					self::generate_payment_plan( $enr->id, $program, $enr->student_id, $plan_total, $installments, $frequency, $args2 );
				}
			}
		}
		return true;
	}

	/**
	 * v1.10.29  -  Delete every UNPAID invoice (no payments and not paid) tied to
	 * this enrollment. Returns the dollar total of payments already credited
	 * against deleted invoices (always 0 here, but we sum the paid+partial
	 * invoices' totals separately to compute the remaining balance).
	 */
	public static function delete_unpaid_invoices_for_enrollment( $enrollment_id ) {
		global $wpdb;
		if ( ! class_exists( 'TSMP_Invoices' ) ) return 0;
		$inv_t  = $wpdb->prefix . 'tsm_invoices';
		$pay_t  = $wpdb->prefix . 'tsm_invoice_payments';
		$rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore
			"SELECT id, total, amount_paid, status FROM {$inv_t} WHERE program_enrollment_id = %d", absint( $enrollment_id )
		) );
		$preserved_paid = 0.0;
		foreach ( (array) $rows as $r ) {
			$has_pmt = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$pay_t} WHERE invoice_id = %d", (int) $r->id ) ); // phpcs:ignore
			$untouched = ( $has_pmt === 0 && (float) $r->amount_paid <= 0 && in_array( $r->status, array( 'open', 'sent', 'draft' ), true ) );
			if ( $untouched ) {
				TSMP_Invoices::delete( (int) $r->id );
			} else {
				$preserved_paid += (float) $r->amount_paid;
			}
		}
		return $preserved_paid;
	}

	/**
	 * v1.10.29  -  Sync an enrollment's payment plan to a SIGNED tuition contract.
	 * Replaces unpaid invoices with one row per scheduled installment using the
	 * exact dates and amounts from the contract's stored schedule.
	 */
	public static function sync_from_contract( $enrollment_id, $contract_id ) {
		if ( ! class_exists( 'TSM_Contracts' ) || ! class_exists( 'TSMP_Invoices' ) ) {
			return new WP_Error( 'tsm_sync_unavailable', __( 'Contracts module is not available.', 'tradeschool-manager' ) );
		}
		$enr = self::get_enrollment( $enrollment_id );
		if ( ! $enr ) { return new WP_Error( 'tsm_enrollment_missing', __( 'Enrollment not found.', 'tradeschool-manager' ) ); }
		$c = TSM_Contracts::get( $contract_id );
		if ( ! $c || 'signed' !== $c->status ) {
			return new WP_Error( 'tsm_contract_unsigned', __( 'Contract is not signed.', 'tradeschool-manager' ) );
		}
		$schedule = json_decode( (string) $c->schedule_json, true );
		$rows = is_array( $schedule['rows'] ?? null ) ? $schedule['rows'] : ( is_array( $schedule ) ? $schedule : array() );
		if ( empty( $rows ) ) {
			return new WP_Error( 'tsm_contract_no_schedule', __( 'Contract has no payment schedule.', 'tradeschool-manager' ) );
		}

		global $wpdb;
		$wpdb->update( self::t_enrollments(), array(
			'tuition_amount'     => (float) $c->principal,
			'billing_mode'       => 'plan',
			'installments'       => (int) $c->installments,
			'frequency'          => (string) $c->frequency,
			'deposit'            => (float) $c->deposit,
			'fee_mode'           => (string) $c->fee_mode,
			'fee_value'          => (float) $c->fee_value,
			'first_payment_date' => $c->first_payment_date,
			'contract_id'        => (int) $c->id,
		), array( 'id' => (int) $enr->id ) );

		// Wipe unpaid invoices, create a separate deposit/application invoice if owed,
		// then create one invoice per contract installment row.
		self::delete_unpaid_invoices_for_enrollment( $enr->id );
		$program = self::get( $enr->program_id );
		if ( (float) $c->deposit > 0 ) {
			TSMP_Invoices::create( array(
				'student_id'            => (int) $enr->student_id,
				'status'                => 'open',
				'affects_balance'       => 1,
				'issue_date'            => current_time( 'Y-m-d' ),
				'due_date'              => current_time( 'Y-m-d' ),
				'memo'                  => sprintf(
					/* translators: 1: program name, 2: contract id */
					__( '%1$s  -  deposit/application fee (Contract #%2$d)', 'tradeschool-manager' ),
					$program ? $program->name : __( 'Tuition', 'tradeschool-manager' ),
					(int) $c->id
				),
				'program_id'            => (int) $enr->program_id,
				'program_enrollment_id' => (int) $enr->id,
				'invoice_type'          => 'deposit',
				'items'                 => array(
					array(
						/* translators: placeholder(s) for dynamic value(s). */
						'description' => sprintf( __( 'Deposit / application fee per signed contract #%d', 'tradeschool-manager' ), (int) $c->id ),
						'quantity'    => 1,
						'unit_price'  => (float) $c->deposit,
					),
				),
			) );
		}
		$total_n = count( $rows );
		$idx = 0;
		foreach ( $rows as $r ) {
			$idx++;
			$amt = isset( $r['amount'] ) ? (float) $r['amount'] : ( isset( $r['total'] ) ? (float) $r['total'] : 0.0 );
			$due = isset( $r['date'] ) ? sanitize_text_field( $r['date'] ) : current_time( 'Y-m-d' );
			TSMP_Invoices::create( array(
				'student_id'            => (int) $enr->student_id,
				'status'                => 'open',
				'affects_balance'       => 1,
				'issue_date'            => current_time( 'Y-m-d' ),
				'due_date'              => $due,
				'memo'                  => sprintf(
					/* translators: 1: program name, 2: current installment, 3: total installments, 4: contract id */
					__( '%1$s  -  installment %2$d of %3$d (Contract #%4$d)', 'tradeschool-manager' ),
					$program ? $program->name : __( 'Tuition', 'tradeschool-manager' ),
					$idx, $total_n, (int) $c->id
				),
				'program_id'            => (int) $enr->program_id,
				'program_enrollment_id' => (int) $enr->id,
				'items'                 => array(
					array(
						/* translators: placeholder(s) for dynamic value(s). */
						'description' => sprintf( __( 'Per signed contract  -  installment %1$d of %2$d', 'tradeschool-manager' ), $idx, $total_n ),
						'quantity'    => 1,
						'unit_price'  => $amt,
					),
				),
			) );
		}
		return true;
	}

	/* ---------------- Invoice generation ---------------- */

	private static function generate_full_invoice( $enrollment_id, $program, $student_id, $amount, $args ) {
		if ( ! empty( $args['due_date'] ) ) {
			$due = sanitize_text_field( $args['due_date'] );
		} else {
			$due = self::compute_due_date(
				$program->start_date ?? '',
				$program->due_date_policy ?? 'on_start',
				$program->due_date_offset_days ?? 0
			);
		}
		TSMP_Invoices::create( array(
			'student_id'            => $student_id,
			'status'                => 'open',
			'affects_balance'       => 1,
			'issue_date'            => current_time( 'Y-m-d' ),
			'due_date'              => $due,
			'memo'                  => sprintf( /* translators: %s program name */ __( 'Tuition: %s', 'tradeschool-manager' ), $program->name ),
			'program_id'            => (int) $program->id,
			'program_enrollment_id' => $enrollment_id,
			'items'                 => array(
				array(
					/* translators: placeholder(s) for dynamic value(s). */
					'description' => sprintf( __( 'Program tuition  -  %s', 'tradeschool-manager' ), $program->name ),
					'quantity'    => 1,
					'unit_price'  => $amount,
				),
			),
		) );
	}

	private static function generate_payment_plan( $enrollment_id, $program, $student_id, $total, $installments, $frequency, $args ) {
		$base = round( $total / $installments, 2 );
		$amounts = array_fill( 0, $installments, $base );
		// Adjust last installment so totals reconcile to the penny.
		$amounts[ $installments - 1 ] = round( $total - ( $base * ( $installments - 1 ) ), 2 );

		// Anchor the first installment to the program's due-date policy unless the
		// caller explicitly provided a start_date for billing.
		if ( ! empty( $args['start_date'] ) ) {
			$start = sanitize_text_field( $args['start_date'] );
		} else {
			$start = self::compute_due_date(
				$program->start_date ?? '',
				$program->due_date_policy ?? 'on_start',
				$program->due_date_offset_days ?? 0
			);
		}
		$step  = self::frequency_step( $frequency );
		$due   = $start;

		foreach ( $amounts as $i => $amt ) {
			$n = $i + 1;
			TSMP_Invoices::create( array(
				'student_id'            => $student_id,
				'status'                => 'open',
				'affects_balance'       => 1,
				'issue_date'            => current_time( 'Y-m-d' ),
				'due_date'              => $due,
				'memo'                  => sprintf(
					/* translators: 1: program name, 2: current installment, 3: total installments */
					__( '%1$s  -  installment %2$d of %3$d', 'tradeschool-manager' ),
					$program->name, $n, $installments
				),
				'program_id'            => (int) $program->id,
				'program_enrollment_id' => $enrollment_id,
				'items'                 => array(
					array(
						/* translators: placeholder(s) for dynamic value(s). */
						'description' => sprintf( __( '%1$s  -  installment %2$d of %3$d', 'tradeschool-manager' ), $program->name, $n, $installments ),
						'quantity'    => 1,
						'unit_price'  => $amt,
					),
				),
			) );
			$due = gmdate( 'Y-m-d', strtotime( $due . ' ' . $step ) );
		}
	}

	private static function maybe_create_enrollment_setup_invoice( $enrollment_id, $program, $student_id, $amount, $label, $description, $args = array() ) {
		$amount = round( (float) $amount, 2 );
		if ( $amount <= 0 || ! class_exists( 'TSMP_Invoices' ) ) {
			return 0;
		}

		$due = ! empty( $args['setup_due_date'] ) ? sanitize_text_field( $args['setup_due_date'] ) : current_time( 'Y-m-d' );
		return TSMP_Invoices::create( array(
			'student_id'            => $student_id,
			'status'                => 'open',
			'affects_balance'       => 1,
			'issue_date'            => current_time( 'Y-m-d' ),
			'due_date'              => $due,
			'memo'                  => $label,
			'program_id'            => (int) $program->id,
			'program_enrollment_id' => $enrollment_id,
			'invoice_type'          => 'deposit',
			'items'                 => array(
				array(
					'description' => $description,
					'quantity'    => 1,
					'unit_price'  => $amount,
				),
			),
		) );
	}

	/* ---------------- Balances / audit ---------------- */

	/**
	 * Sum of tuition (charged) and amount_paid for each enrollment.
	 */
	public static function balance_for_enrollment( $enrollment_id ) {
		global $wpdb;
		$inv = $wpdb->prefix . 'tsm_invoices';
		$row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore
			"SELECT COALESCE(SUM(total),0) AS billed, COALESCE(SUM(amount_paid),0) AS paid
			   FROM {$inv}
			  WHERE program_enrollment_id = %d AND status NOT IN ('void','refunded')",
			absint( $enrollment_id )
		) );
		$billed = $row ? (float) $row->billed : 0;
		$paid   = $row ? (float) $row->paid   : 0;
		$enr    = self::get_enrollment( $enrollment_id );
		$tuition = $enr ? (float) $enr->tuition_amount : 0;
		// Outstanding balance = (total tuition - paid). Billed shows what's been invoiced so far.
		return array(
			'tuition'    => $tuition,
			'billed'     => $billed,
			'paid'       => $paid,
			'outstanding'=> max( 0, $tuition - $paid ),
			'unbilled'   => max( 0, $tuition - $billed ),
		);
	}

	/**
	 * Roll-up totals across all enrollments for a student.
	 */
	public static function rollup_for_student( $student_id ) {
		$enrollments = self::enrollments_for_student( $student_id );
		$out = array(
			'tuition' => 0, 'billed' => 0, 'paid' => 0, 'outstanding' => 0,
			'enrollments' => array(),
		);
		foreach ( $enrollments as $e ) {
			$bal = self::balance_for_enrollment( $e->id );
			$out['tuition']     += $bal['tuition'];
			$out['billed']      += $bal['billed'];
			$out['paid']        += $bal['paid'];
			$out['outstanding'] += $bal['outstanding'];
			$out['enrollments'][] = array( 'enrollment' => $e, 'balance' => $bal );
		}
		return $out;
	}

	/**
	 * For the Tuition Audit report  -  every student who has at least one enrollment.
	 */
	public static function audit_rows() {
		global $wpdb;
		$en = self::t_enrollments();
		$st = $wpdb->prefix . 'tsm_students';
		$ids = $wpdb->get_col( "SELECT DISTINCT student_id FROM {$en}" ); // phpcs:ignore
		$out = array();
		foreach ( $ids as $sid ) {
			$student = TSM_Students::get( $sid );
			if ( ! $student ) continue;
			$rollup = self::rollup_for_student( $sid );
			$out[] = array( 'student' => $student, 'rollup' => $rollup );
		}
		usort( $out, function( $a, $b ) {
			return strcmp(
				strtolower( $a['student']->last_name . $a['student']->first_name ),
				strtolower( $b['student']->last_name . $b['student']->first_name )
			);
		} );
		return $out;
	}
}
