<?php
/**
 * CSV import/export for students, classes, and grades.
 *
 * Rewritten in v1.19.29 to match the polished two-card layout used by
 * Assignments, with proper row validation (no more blank student rows),
 * per-import statistics, update-existing matching by email, and clearly
 * documented columns + downloadable templates for each type.
 *
 * @package TradeSchoolManager
 */

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

class TSMP_CSV {

	private static function escape_csv_cell( $value ) {
		if ( is_scalar( $value ) || null === $value ) {
			$value = (string) $value;
			if ( '' !== $value && preg_match( '/^[=\-+@]/', $value ) ) {
				return "'" . $value;
			}
		}
		return $value;
	}

	private static function putcsv( $handle, $row ) {
		fputcsv( $handle, array_map( array( __CLASS__, 'escape_csv_cell' ), (array) $row ) );
	}

	private static function date_for_csv( $value ) {
		return class_exists( 'TSM_Date_Utils' ) ? TSM_Date_Utils::csv( $value ) : $value;
	}

	private static function sanitize_grade_payload( $values ) {
		$values = (array) $values;
		return array(
			'status'         => isset( $values['status'] ) ? sanitize_key( $values['status'] ) : '',
			'points_earned'  => isset( $values['points_earned'] ) && '' !== $values['points_earned'] ? (float) $values['points_earned'] : '',
			'bonus'          => isset( $values['bonus'] ) && '' !== $values['bonus'] ? (float) $values['bonus'] : '',
			'late_penalty'   => isset( $values['late_penalty'] ) && '' !== $values['late_penalty'] ? (float) $values['late_penalty'] : '',
			'submitted_date' => isset( $values['submitted_date'] ) ? sanitize_text_field( $values['submitted_date'] ) : '',
			'comments'       => isset( $values['comments'] ) ? sanitize_textarea_field( $values['comments'] ) : '',
			'excused'        => ! empty( $values['excused'] ) ? 1 : 0,
		);
	}

	private static function student_csv_header( $custom_fields = null ) {
		$cf_defs = is_array( $custom_fields ) ? $custom_fields : ( class_exists( 'TSMP_Custom_Fields' ) ? TSMP_Custom_Fields::all_defs() : array() );
		$base    = array(
			'id', 'first_name', 'last_name', 'email', 'phone', 'username', 'status',
			'emergency_name', 'emergency_phone', 'emergency_relation', 'notes',
		);
		$header  = $base;
		$used    = array_flip( $base );
		foreach ( $cf_defs as $def ) {
			$col = trim( (string) $def->label ) !== '' ? $def->label : ( 'cf_' . $def->meta_key );
			if ( isset( $used[ $col ] ) ) { $col = $col . ' (' . $def->meta_key . ')'; }
			$used[ $col ] = true;
			$header[]     = $col;
		}
		return $header;
	}

	/* ---------------------------------------------------------------- *
	 * Admin screen
	 * ---------------------------------------------------------------- */

	public static function render_admin() {
		// Handle uploads BEFORE rendering so flash messages appear at top.
		self::maybe_handle_import();

		$flash = get_transient( 'tsmp_csv_flash' );
		if ( $flash ) {
			delete_transient( 'tsmp_csv_flash' );
			$cls = 'success' === $flash['type'] ? 'notice-success' : ( 'warning' === $flash['type'] ? 'notice-warning' : 'notice-error' );
			echo '<div class="notice ' . esc_attr( $cls ) . '"><p>' . wp_kses_post( $flash['message'] ) . '</p></div>';
		}

		$active = isset( $_GET['csv_type'] ) ? sanitize_key( wp_unslash( $_GET['csv_type'] ) ) : 'students'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$types  = self::types();
		if ( ! isset( $types[ $active ] ) ) { $active = 'students'; }

		echo '<p class="description" style="margin-top:6px;">' . esc_html__( 'Move student records, class definitions, and grade data between TradeSchool Manager and spreadsheets. Each section provides a downloadable template so your column headers always match.', 'tradeschool-manager' ) . '</p>';

		// Sub-tabs by data type.
		echo '<h2 class="nav-tab-wrapper" style="margin-top:14px;">';
		foreach ( $types as $key => $def ) {
			$url = add_query_arg( array( 'page' => 'tsm-csv', 'csv_type' => $key ), admin_url( 'admin.php' ) );
			printf(
				'<a href="%s" class="nav-tab %s">%s</a>',
				esc_url( $url ),
				$active === $key ? 'nav-tab-active' : '',
				esc_html( $def['label'] )
			);
		}
		echo '</h2>';

		$def = $types[ $active ];
		?>
		<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:18px;">
			<!-- EXPORT -->
			<div class="card" style="padding:18px;max-width:none;">
				<h2 style="margin-top:0;">
					<span class="dashicons dashicons-download" style="color:#2271b1;"></span>
					<?php
					/* translators: %s: data type label (e.g. Students) */
					echo esc_html( sprintf( __( 'Export %s', 'tradeschool-manager' ), $def['label'] ) );
					?>
				</h2>
				<p class="description"><?php echo esc_html( $def['export_desc'] ); ?></p>
				<p style="margin-top:14px;">
					<a class="button button-primary" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=tsmp_export&type=' . $active ), 'tsmp_export' ) ); ?>">
						<?php esc_html_e( ' Download CSV', 'tradeschool-manager' ); ?>
					</a>
				</p>
			</div>

			<!-- IMPORT -->
			<div class="card" style="padding:18px;max-width:none;">
				<h2 style="margin-top:0;">
					<span class="dashicons dashicons-upload" style="color:#2271b1;"></span>
					<?php
					/* translators: %s: data type label */
					echo esc_html( sprintf( __( 'Import %s', 'tradeschool-manager' ), $def['label'] ) );
					?>
				</h2>
				<p class="description"><?php echo esc_html( $def['import_desc'] ); ?></p>

				<p>
					<a class="button" href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=tsmp_template&type=' . $active ), 'tsmp_template' ) ); ?>">
						<?php esc_html_e( ' Download CSV template', 'tradeschool-manager' ); ?>
					</a>
				</p>

				<form method="post" enctype="multipart/form-data" style="margin-top:10px;">
					<?php wp_nonce_field( 'tsmp_csv_import' ); ?>
					<input type="hidden" name="tsmp_csv_type" value="<?php echo esc_attr( $active ); ?>">
					<p><input type="file" name="csv_file" accept=".csv,text/csv" required></p>
					<?php if ( ! empty( $def['supports_update'] ) ) : ?>
						<p><label><input type="checkbox" name="update_existing" value="1" checked>
							<?php echo esc_html( $def['update_label'] ); ?>
						</label></p>
					<?php endif; ?>
					<p><button class="button button-primary"><?php esc_html_e( 'Upload & Import', 'tradeschool-manager' ); ?></button></p>
				</form>

				<p class="description" style="margin-top:14px;">
					<strong><?php esc_html_e( 'CSV columns:', 'tradeschool-manager' ); ?></strong><br>
					<?php echo wp_kses_post( $def['columns_help'] ); ?>
				</p>
			</div>
		</div>
		<?php
	}

	/* ---------------------------------------------------------------- *
	 * Type registry
	 * ---------------------------------------------------------------- */

	private static function types() {
		return array(
			'students'    => array(
				'label'           => __( 'Students', 'tradeschool-manager' ),
				'export_desc'     => __( 'Download every student in your database, including custom fields, as a CSV file you can open in Excel or Google Sheets.', 'tradeschool-manager' ),
				'import_desc'     => __( 'Bulk-create or update student records. Rows missing both a name and an email are skipped automatically  -  no more blank entries.', 'tradeschool-manager' ),
				'supports_update' => true,
				'update_label'    => __( 'Update existing students (matched by id first, then email)', 'tradeschool-manager' ),
				'columns_help'    => __( 'Export/template columns: <code>id</code>, <code>first_name</code>, <code>last_name</code>, <code>email</code>, <code>phone</code>, <code>username</code>, <code>status</code> (active/inactive), <code>emergency_name</code>, <code>emergency_phone</code>, <code>emergency_relation</code>, <code>notes</code>. Required for new students: <code>first_name</code> + <code>last_name</code>, or <code>email</code>. Leave <code>id</code> blank for new students. Custom fields can use the exported label or the prefixed key (e.g. <code>cf_drivers_license</code>).', 'tradeschool-manager' ),
			),
			'classes'     => array(
				'label'           => __( 'Classes', 'tradeschool-manager' ),
				'export_desc'     => __( 'Download every class definition, including school year and grading method.', 'tradeschool-manager' ),
				'import_desc'     => __( 'Bulk-create or update class definitions. Exported class CSV files can be imported back after editing.', 'tradeschool-manager' ),
				'supports_update' => true,
				'update_label'    => __( 'Update existing classes (matched by id first, then name + school year)', 'tradeschool-manager' ),
				'columns_help'    => __( 'Export/template columns: <code>id</code>, <code>name</code>, <code>school_year_id</code>, <code>teacher_id</code>, <code>grading_method</code> (points / weighted / pass_fail). Required for new classes: <code>name</code> and <code>school_year_id</code>. Leave <code>id</code> blank for new classes.', 'tradeschool-manager' ),
			),
			'assignments' => array(
				'label'           => __( 'Assignments', 'tradeschool-manager' ),
				'export_desc'     => __( 'Download every assignment, including class, category, points, and dates.', 'tradeschool-manager' ),
				'import_desc'     => __( 'Bulk-create or update assignments. Existing assignments are matched by Class + Title. Imports into archived school years are blocked.', 'tradeschool-manager' ),
				'supports_update' => true,
				'update_label'    => __( 'Update existing assignments (matched by class + title)', 'tradeschool-manager' ),
				'columns_help'    => __( 'Required: <code>title</code>, plus <code>class_id</code> or <code>class_name</code>. Optional: <code>category</code>, <code>assigned_date</code> (MM/DD/YYYY preferred; YYYY-MM-DD also accepted), <code>due_date</code>, <code>points_possible</code>, <code>extra_credit</code> (0/1), <code>required</code> (0/1), <code>description</code>.<br><br><strong>Tip:</strong> For Pass/Fail classes, leave <code>points_possible</code> blank (or set to 1)  -  every assignment is worth a single pass/fail mark. The template includes example rows for both Points and Pass/Fail setups.', 'tradeschool-manager' ),
			),
			'grades'      => array(
				'label'           => __( 'Grades', 'tradeschool-manager' ),
				'export_desc'     => __( 'Download every grade entry for backup or analysis in a spreadsheet.', 'tradeschool-manager' ),
				'import_desc'     => __( 'Bulk-import grades. Match rows by <code>assignment_id</code> + <code>student_id</code>. Imports into archived school years are blocked. To clear a grade, leave <code>status</code> blank.', 'tradeschool-manager' ),
				'supports_update' => false,
				'columns_help'    => __( 'Required: <code>assignment_id</code>, <code>student_id</code>. Optional: <code>status</code>, <code>points_earned</code>, <code>bonus</code>, <code>late_penalty</code> (0-100), <code>submitted_date</code> (MM/DD/YYYY preferred; YYYY-MM-DD also accepted), <code>comments</code>.<br><br><strong>Status values:</strong><br>* <em>Points / Weighted classes:</em> <code>completed_ontime</code>, <code>completed_late</code>, <code>incomplete</code>, <code>missing</code>, <code>excused</code>, <code>excluded</code>, <code>ungraded</code>.<br>* <em>Pass/Fail classes:</em> use <code>pass</code> or <code>fail</code> (also accepted: <code>excused</code>, <code>ungraded</code>). Earned points are filled in automatically.', 'tradeschool-manager' ),
			),
		);
	}

	/* ---------------------------------------------------------------- *
	 * Import handler (runs on the page load that submitted the form)
	 * ---------------------------------------------------------------- */

	private static function maybe_handle_import() {
		if ( empty( $_POST['tsmp_csv_type'] ) || empty( $_POST['_wpnonce'] ) ) { return; }
		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'tsmp_csv_import' ) ) { return; }
		if ( ! current_user_can( 'manage_tradeschool' ) ) { return; }

		$type  = sanitize_key( wp_unslash( $_POST['tsmp_csv_type'] ) );
		$types = self::types();
		if ( ! isset( $types[ $type ] ) || ! empty( $types[ $type ]['no_import'] ) ) {
			self::flash( 'error', __( 'Import is not available for this type.', 'tradeschool-manager' ) );
			return;
		}

		$csv_tmp = isset( $_FILES['csv_file']['tmp_name'] ) ? sanitize_text_field( wp_unslash( $_FILES['csv_file']['tmp_name'] ) ) : '';
		if ( '' === $csv_tmp || ! is_uploaded_file( $csv_tmp ) ) {
			self::flash( 'error', __( 'No CSV file uploaded.', 'tradeschool-manager' ) );
			return;
		}
		$fh = fopen( $csv_tmp, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		if ( ! $fh ) {
			self::flash( 'error', __( 'Could not open uploaded file.', 'tradeschool-manager' ) );
			return;
		}
		$header = fgetcsv( $fh );
		if ( ! $header ) {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			self::flash( 'error', __( 'CSV is empty.', 'tradeschool-manager' ) );
			return;
		}
		// Strip BOM from first header cell.
		if ( isset( $header[0] ) ) { $header[0] = preg_replace( '/^\xEF\xBB\xBF/', '', $header[0] ); }
		$header = array_map( function( $h ) { return strtolower( trim( (string) $h ) ); }, $header );

		$update_existing = ! empty( $_POST['update_existing'] );

		if ( 'students' === $type ) {
			self::import_students( $fh, $header, $update_existing );
		} elseif ( 'classes' === $type ) {
			self::import_classes_roundtrip( $fh, $header, $update_existing );
		} elseif ( 'assignments' === $type ) {
			self::import_assignments( $fh, $header, $update_existing );
		} elseif ( 'grades' === $type ) {
			self::import_grades( $fh, $header );
		}
		fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions
	}

	private static function import_students( $fh, $header, $update_existing ) {
		$idx       = array_flip( $header );
		$created   = 0;
		$updated   = 0;
		$skipped   = 0;
		$blank     = 0;
		$errors    = array();
		$line_no   = 1;

		$cf_defs   = class_exists( 'TSMP_Custom_Fields' ) ? TSMP_Custom_Fields::all_defs() : array();
		$cf_by_key = array();
		$cf_by_lbl = array();
		foreach ( $cf_defs as $def ) {
			$cf_by_key[ $def->meta_key ]            = $def;
			$cf_by_lbl[ strtolower( $def->label ) ] = $def;
		}

		while ( ( $row = fgetcsv( $fh ) ) !== false ) {
			$line_no++;

			// Skip completely blank rows.
			$row_trim = array_map( function( $v ) { return trim( (string) $v ); }, $row );
			if ( '' === implode( '', $row_trim ) ) { $blank++; continue; }

			// Normalise row length to header.
			if ( count( $row_trim ) < count( $header ) ) {
				$row_trim = array_pad( $row_trim, count( $header ), '' );
			}
			$row_trim = array_slice( $row_trim, 0, count( $header ) );
			$assoc    = array_combine( $header, $row_trim );
			if ( ! $assoc ) { $blank++; continue; }

			$first = trim( (string) ( $assoc['first_name'] ?? '' ) );
			$last  = trim( (string) ( $assoc['last_name']  ?? '' ) );
			$email = trim( (string) ( $assoc['email']      ?? '' ) );
			$row_id = absint( $assoc['id'] ?? 0 );

			// Hard validation: must have a real name OR email.
			if ( ! $row_id && '' === $first && '' === $last && '' === $email ) {
				$skipped++;
				$errors[] = sprintf( /* translators: %d: row number */ __( 'Row %d: skipped (no name and no email).', 'tradeschool-manager' ), $line_no );
				continue;
			}
			if ( '' !== $email && ! is_email( $email ) ) {
				$skipped++;
				$errors[] = sprintf( /* translators: %d: row number */ __( 'Row %d: invalid email address.', 'tradeschool-manager' ), $line_no );
				continue;
			}

			// Update-by-email lookup.
			$existing_id = 0;
			if ( $update_existing ) {
				if ( $row_id && TSM_Students::get( $row_id ) ) {
					$existing_id = $row_id;
				} elseif ( '' !== $email ) {
					$found = TSM_Students::get_by_email( $email );
					if ( $found ) { $existing_id = (int) $found->id; }
				}
			}

			$student_id = TSM_Students::save( $assoc, $existing_id );
			if ( is_wp_error( $student_id ) || ! $student_id ) {
				$skipped++;
				$errors[] = sprintf(
					/* translators: 1: row number, 2: error message */
					__( 'Row %1$d: %2$s', 'tradeschool-manager' ),
					$line_no,
					is_wp_error( $student_id ) ? $student_id->get_error_message() : __( 'could not save.', 'tradeschool-manager' )
				);
				continue;
			}

			if ( $existing_id ) { $updated++; } else { $created++; }

			// Custom field values.
			foreach ( $assoc as $col => $val ) {
				$def = null;
				if ( 0 === strpos( $col, 'cf_' ) ) {
					$key = substr( $col, 3 );
					if ( isset( $cf_by_key[ $key ] ) ) { $def = $cf_by_key[ $key ]; }
				} elseif ( isset( $cf_by_lbl[ strtolower( $col ) ] ) ) {
					$def = $cf_by_lbl[ strtolower( $col ) ];
				}
				if ( $def && class_exists( 'TSMP_Custom_Fields' ) ) {
					TSMP_Custom_Fields::set_value( $def->id, $student_id, TSMP_Custom_Fields::sanitize_value( $def->field_type, $val ) );
				}
			}
		}

		$msg = sprintf(
			/* translators: 1: created, 2: updated, 3: skipped, 4: blank */
			__( 'Import complete. <strong>Created: %1$d</strong> - <strong>Updated: %2$d</strong> - Skipped: %3$d - Blank rows ignored: %4$d', 'tradeschool-manager' ),
			$created, $updated, $skipped, $blank
		);
		if ( $errors ) {
			$msg .= '<br><em>' . esc_html( implode( ' ', array_slice( $errors, 0, 5 ) ) ) . ( count( $errors ) > 5 ? ' ...' : '' ) . '</em>';
		}
		self::flash( $skipped ? 'warning' : 'success', $msg );
	}

	private static function import_classes( $fh, $header ) {
		$idx     = array_flip( $header );
		$created = 0; $skipped = 0; $blank = 0; $errors = array(); $line_no = 1;
		while ( ( $row = fgetcsv( $fh ) ) !== false ) {
			$line_no++;
			$row_trim = array_map( function( $v ) { return trim( (string) $v ); }, $row );
			if ( '' === implode( '', $row_trim ) ) { $blank++; continue; }
			if ( count( $row_trim ) < count( $header ) ) { $row_trim = array_pad( $row_trim, count( $header ), '' ); }
			$assoc = array_combine( $header, array_slice( $row_trim, 0, count( $header ) ) );
			if ( ! $assoc ) { $blank++; continue; }
			$name = trim( (string) ( $assoc['name'] ?? '' ) );
			$year = absint( $assoc['school_year_id'] ?? 0 );
			if ( '' === $name || ! $year ) {
				$skipped++;
				$errors[] = sprintf( /* translators: %d row */ __( 'Row %d: name and school_year_id are required.', 'tradeschool-manager' ), $line_no );
				continue;
			}
			$cid = TSM_Classes::save( array(
				'name'           => $name,
				'school_year_id' => $year,
				'teacher_id'     => absint( $assoc['teacher_id'] ?? 0 ),
				'grading_method' => sanitize_key( $assoc['grading_method'] ?? 'points' ),
			) );
			if ( $cid ) { $created++; } else { $skipped++; }
		}
		$msg = sprintf(
			/* translators: 1: created, 2: skipped, 3: blank */
			__( 'Import complete. Created: %1$d - Skipped: %2$d - Blank rows: %3$d', 'tradeschool-manager' ),
			$created, $skipped, $blank
		);
		if ( $errors ) {
			$msg .= '<br><em>' . esc_html( implode( ' ', array_slice( $errors, 0, 5 ) ) ) . ( count( $errors ) > 5 ? ' ...' : '' ) . '</em>';
		}
		self::flash( $skipped ? 'warning' : 'success', $msg );
	}

	private static function import_classes_roundtrip( $fh, $header, $update_existing ) {
		$created = 0; $updated = 0; $skipped = 0; $blank = 0; $errors = array(); $line_no = 1;
		$classes = TSM_Classes::all();
		$by_id   = array();
		$by_key  = array();
		foreach ( $classes as $c ) {
			$by_id[ (int) $c->id ] = $c;
			$by_key[ strtolower( trim( (string) $c->name ) ) . '|' . (int) $c->school_year_id ] = $c;
		}

		while ( ( $row = fgetcsv( $fh ) ) !== false ) {
			$line_no++;
			$row_trim = array_map( function( $v ) { return trim( (string) $v ); }, $row );
			if ( '' === implode( '', $row_trim ) ) { $blank++; continue; }
			if ( count( $row_trim ) < count( $header ) ) { $row_trim = array_pad( $row_trim, count( $header ), '' ); }
			$assoc = array_combine( $header, array_slice( $row_trim, 0, count( $header ) ) );
			if ( ! $assoc ) { $blank++; continue; }

			$name = trim( (string) ( $assoc['name'] ?? '' ) );
			$year = absint( $assoc['school_year_id'] ?? 0 );
			if ( '' === $name || ! $year ) {
				$skipped++;
				$errors[] = sprintf( /* translators: %d row */ __( 'Row %d: name and school_year_id are required.', 'tradeschool-manager' ), $line_no );
				continue;
			}

			$existing_id = 0;
			if ( $update_existing ) {
				$row_id = absint( $assoc['id'] ?? 0 );
				if ( $row_id && isset( $by_id[ $row_id ] ) ) {
					$existing_id = $row_id;
				} else {
					$key = strtolower( trim( (string) $name ) ) . '|' . $year;
					if ( isset( $by_key[ $key ] ) ) {
						$existing_id = (int) $by_key[ $key ]->id;
					}
				}
			}

			$cid = TSM_Classes::save( array(
				'name'           => $name,
				'school_year_id' => $year,
				'teacher_id'     => absint( $assoc['teacher_id'] ?? 0 ),
				'grading_method' => sanitize_key( $assoc['grading_method'] ?? 'points' ),
			), $existing_id );

			if ( is_wp_error( $cid ) ) {
				$skipped++;
				$errors[] = sprintf( /* translators: 1 row, 2 message */ __( 'Row %1$d: %2$s', 'tradeschool-manager' ), $line_no, $cid->get_error_message() );
				continue;
			}
			if ( $cid && $existing_id ) {
				$updated++;
			} elseif ( $cid ) {
				$created++;
				$by_id[ (int) $cid ] = (object) array( 'id' => (int) $cid, 'name' => $name, 'school_year_id' => $year );
				$by_key[ strtolower( trim( (string) $name ) ) . '|' . $year ] = $by_id[ (int) $cid ];
			} else {
				$skipped++;
			}
		}

		$msg = sprintf(
			/* translators: 1: created, 2: updated, 3: skipped, 4: blank */
			__( 'Import complete. Created: %1$d - Updated: %2$d - Skipped: %3$d - Blank rows: %4$d', 'tradeschool-manager' ),
			$created, $updated, $skipped, $blank
		);
		if ( $errors ) {
			$msg .= '<br><em>' . esc_html( implode( ' ', array_slice( $errors, 0, 5 ) ) ) . ( count( $errors ) > 5 ? ' ...' : '' ) . '</em>';
		}
		self::flash( $skipped ? 'warning' : 'success', $msg );
	}

	private static function import_assignments( $fh, $header, $update_existing ) {
		$idx     = array_flip( $header );
		$get     = function( $row, $key ) use ( $idx ) {
			return isset( $idx[ $key ] ) && isset( $row[ $idx[ $key ] ] ) ? trim( (string) $row[ $idx[ $key ] ] ) : '';
		};

		$classes = TSM_Classes::all();
		$by_id   = array();
		$by_name = array();
		foreach ( $classes as $c ) {
			$by_id[ (int) $c->id ]                         = $c;
			$by_name[ strtolower( trim( (string) $c->name ) ) ] = $c;
		}

		$created = 0; $updated = 0; $skipped = 0; $blank = 0; $errors = array(); $line_no = 1;
		while ( ( $row = fgetcsv( $fh ) ) !== false ) {
			$line_no++;
			$row_trim = array_map( function( $v ) { return trim( (string) $v ); }, $row );
			if ( '' === implode( '', $row_trim ) ) { $blank++; continue; }
			if ( count( $row_trim ) < count( $header ) ) { $row_trim = array_pad( $row_trim, count( $header ), '' ); }

			$title = $get( $row_trim, 'title' );
			if ( '' === $title ) {
				$skipped++;
				$errors[] = sprintf( /* translators: %d row */ __( 'Row %d: missing title.', 'tradeschool-manager' ), $line_no );
				continue;
			}

			$cls = null;
			$cid = (int) $get( $row_trim, 'class_id' );
			if ( $cid && isset( $by_id[ $cid ] ) ) {
				$cls = $by_id[ $cid ];
			} else {
				$cname = strtolower( $get( $row_trim, 'class_name' ) );
				if ( '' !== $cname && isset( $by_name[ $cname ] ) ) { $cls = $by_name[ $cname ]; }
			}
			if ( ! $cls ) {
				$skipped++;
				$errors[] = sprintf( /* translators: %d row */ __( 'Row %d: class not found.', 'tradeschool-manager' ), $line_no );
				continue;
			}

			$data = array(
				'class_id'        => (int) $cls->id,
				'school_year_id'  => (int) $cls->school_year_id,
				'title'           => $title,
				'category'        => $get( $row_trim, 'category' ),
				'assigned_date'   => $get( $row_trim, 'assigned_date' ),
				'due_date'        => $get( $row_trim, 'due_date' ),
				'points_possible' => floatval( $get( $row_trim, 'points_possible' ) ),
				'extra_credit'    => in_array( strtolower( $get( $row_trim, 'extra_credit' ) ), array( '1', 'yes', 'true', 'y' ), true ) ? 1 : 0,
				'required'        => in_array( strtolower( $get( $row_trim, 'required' ) ), array( '0', 'no', 'false', 'n' ), true ) ? 0 : 1,
				'description'     => $get( $row_trim, 'description' ),
			);

			// Pass/fail classes: a `pass_fail` column maps to points_possible = 1, and (on grade import) status.
			if ( $cls && 'pass_fail' === $cls->grading_method && $data['points_possible'] <= 0 ) {
				$data['points_possible'] = 1;
			}

			$existing = $update_existing ? TSM_Assignments::find_by_class_title( (int) $cls->id, $title ) : null;
			$res      = TSM_Assignments::save( $data, $existing ? (int) $existing->id : 0 );
			if ( is_wp_error( $res ) ) {
				$skipped++;
				$errors[] = sprintf( /* translators: 1 row, 2 message */ __( 'Row %1$d: %2$s', 'tradeschool-manager' ), $line_no, $res->get_error_message() );
				continue;
			}
			if ( $existing ) { $updated++; } else { $created++; }
		}

		$msg = sprintf(
			/* translators: 1 created, 2 updated, 3 skipped, 4 blank */
			__( 'Assignments import complete. Created: %1$d - Updated: %2$d - Skipped: %3$d - Blank rows: %4$d', 'tradeschool-manager' ),
			$created, $updated, $skipped, $blank
		);
		if ( $errors ) {
			$msg .= '<br><em>' . esc_html( implode( ' ', array_slice( $errors, 0, 5 ) ) ) . ( count( $errors ) > 5 ? ' ...' : '' ) . '</em>';
		}
		self::flash( $skipped ? 'warning' : 'success', $msg );
	}

	private static function import_grades( $fh, $header ) {
		$idx = array_flip( $header );
		$get = function( $row, $key ) use ( $idx ) {
			return isset( $idx[ $key ] ) && isset( $row[ $idx[ $key ] ] ) ? trim( (string) $row[ $idx[ $key ] ] ) : '';
		};

		$saved = 0; $skipped = 0; $blank = 0; $errors = array(); $line_no = 1;
		$valid_statuses = array_keys( TSM_Gradebook::statuses() );

		while ( ( $row = fgetcsv( $fh ) ) !== false ) {
			$line_no++;
			$row_trim = array_map( function( $v ) { return trim( (string) $v ); }, $row );
			if ( '' === implode( '', $row_trim ) ) { $blank++; continue; }
			if ( count( $row_trim ) < count( $header ) ) { $row_trim = array_pad( $row_trim, count( $header ), '' ); }

			$aid = (int) $get( $row_trim, 'assignment_id' );
			$sid = (int) $get( $row_trim, 'student_id' );
			if ( ! $aid || ! $sid ) {
				$skipped++;
				$errors[] = sprintf( /* translators: %d row */ __( 'Row %d: assignment_id and student_id are required.', 'tradeschool-manager' ), $line_no );
				continue;
			}
			$assignment = TSM_Assignments::get( $aid );
			if ( ! $assignment ) {
				$skipped++;
				$errors[] = sprintf( /* translators: 1 row, 2 id */ __( 'Row %1$d: assignment #%2$d not found.', 'tradeschool-manager' ), $line_no, $aid );
				continue;
			}

			$status_raw = strtolower( $get( $row_trim, 'status' ) );
			// Convenience aliases.
			$alias = array(
				'pass' => 'completed_ontime',
				'fail' => 'missing',
				'on-time' => 'completed_ontime',
				'ontime'  => 'completed_ontime',
				'late'    => 'completed_late',
			);
			if ( isset( $alias[ $status_raw ] ) ) { $status_raw = $alias[ $status_raw ]; }
			if ( '' !== $status_raw && ! in_array( $status_raw, $valid_statuses, true ) ) {
				$skipped++;
				$errors[] = sprintf( /* translators: 1 row, 2 status */ __( 'Row %1$d: unknown status "%2$s".', 'tradeschool-manager' ), $line_no, $status_raw );
				continue;
			}

			$data = array(
				'status'         => $status_raw,
				'points_earned'  => $get( $row_trim, 'points_earned' ),
				'bonus'          => $get( $row_trim, 'bonus' ),
				'late_penalty'   => $get( $row_trim, 'late_penalty' ),
				'submitted_date' => $get( $row_trim, 'submitted_date' ),
				'comments'       => $get( $row_trim, 'comments' ),
			);
			$data = self::sanitize_grade_payload( $data );
			$prev = TSM_Gradebook::get_grade( $aid, $sid );
			$res  = TSM_Gradebook::save_grade( $aid, $sid, $data );
			if ( is_wp_error( $res ) ) {
				$skipped++;
				$errors[] = sprintf( /* translators: 1 row, 2 message */ __( 'Row %1$d: %2$s', 'tradeschool-manager' ), $line_no, $res->get_error_message() );
				continue;
			}
			$saved++;
			// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Intentional public extension hook namespaced with the plugin slug.
			do_action( 'tradeschool_manager_after_save_grade', $aid, $sid, $data, $prev );
		}

		$msg = sprintf(
			/* translators: 1 saved, 2 skipped, 3 blank */
			__( 'Grades import complete. Saved: %1$d - Skipped: %2$d - Blank rows: %3$d', 'tradeschool-manager' ),
			$saved, $skipped, $blank
		);
		if ( $errors ) {
			$msg .= '<br><em>' . esc_html( implode( ' ', array_slice( $errors, 0, 5 ) ) ) . ( count( $errors ) > 5 ? ' ...' : '' ) . '</em>';
		}
		self::flash( $skipped ? 'warning' : 'success', $msg );
	}

	private static function flash( $type, $message ) {
		set_transient( 'tsmp_csv_flash', array( 'type' => $type, 'message' => $message ), 60 );
	}

	/* ---------------------------------------------------------------- *
	 * Template + export downloads (admin-post)
	 * ---------------------------------------------------------------- */

	public static function handle_template() {
		if ( ! current_user_can( 'manage_tradeschool' ) ) { wp_die( 'Forbidden' ); }
		check_admin_referer( 'tsmp_template' );
		$type  = sanitize_key( wp_unslash( $_GET['type'] ?? 'students' ) );
		$types = self::types();
		if ( ! isset( $types[ $type ] ) ) { wp_die( 'Unknown type' ); }
		// Clear any prior output so the CSV starts cleanly with no stray bytes (was producing a phantom "" glyph in A1 for some users).
		if ( ob_get_level() ) { @ob_end_clean(); }
		nocache_headers();
		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="tsm-' . $type . '-template.csv"' );
		$out = fopen( 'php://output', 'w' );
		// NOTE: intentionally no UTF-8 BOM here  -  templates are plain ASCII and
		// some spreadsheet apps render the BOM as a visible symbol in cell A1.
		switch ( $type ) {
			case 'students':
				$cf_defs = class_exists( 'TSMP_Custom_Fields' ) ? TSMP_Custom_Fields::all_defs() : array();
				$header  = self::student_csv_header( $cf_defs );
				self::putcsv( $out, $header );
				self::putcsv( $out, array_pad( array( '', 'Jane', 'Doe', 'jane@example.com', '555-0100', 'jane.doe', 'active', 'John Doe', '555-0101', 'Parent', '' ), count( $header ), '' ) );
				self::putcsv( $out, array_pad( array( '', 'John', 'Smith', 'john@example.com', '555-0200', 'john.smith', 'active', '', '', '', '' ), count( $header ), '' ) );
				break;
			case 'classes':
				self::putcsv( $out, array( 'id', 'name', 'school_year_id', 'teacher_id', 'grading_method' ) );
				self::putcsv( $out, array( '', 'Welding 101', '1', '0', 'points' ) );
				break;
			case 'assignments':
				self::putcsv( $out, array( 'class_id', 'class_name', 'title', 'category', 'assigned_date', 'due_date', 'points_possible', 'extra_credit', 'required', 'description' ) );
				// Points / Weighted class examples.
				self::putcsv( $out, array( '', 'Welding 101', 'Safety Quiz 1', 'Quizzes', '09/05/2025', '09/12/2025', '20', '0', '1', 'Open-book quiz on shop safety (Points class).' ) );
				self::putcsv( $out, array( '', 'Welding 101', 'Final Project', 'Projects', '11/01/2025', '12/15/2025', '100', '0', '1', 'Capstone weld assembly (Points class).' ) );
				// Pass/Fail class examples  -  points_possible = 1 per assignment.
				self::putcsv( $out, array( '', 'Shop Safety (Pass/Fail)', 'OSHA Walkthrough',  'Required', '09/08/2025', '09/08/2025', '1', '0', '1', 'Pass/Fail class: each row is one pass/fail mark.' ) );
				self::putcsv( $out, array( '', 'Shop Safety (Pass/Fail)', 'PPE Demonstration', 'Required', '09/15/2025', '09/15/2025', '1', '0', '1', 'Pass/Fail class: each row is one pass/fail mark.' ) );
				break;
			case 'grades':
				self::putcsv( $out, array( 'assignment_id', 'student_id', 'status', 'points_earned', 'bonus', 'late_penalty', 'submitted_date', 'comments' ) );
				// Points / Weighted class examples.
				self::putcsv( $out, array( '1', '101', 'completed_ontime', '18', '0', '0',  '09/12/2025', 'Great work.' ) );
				self::putcsv( $out, array( '1', '102', 'completed_late',   '15', '0', '10', '09/14/2025', '' ) );
				self::putcsv( $out, array( '1', '103', 'missing',          '',   '',  '',   '',           '' ) );
				// Pass/Fail class examples  -  just use pass / fail; points autofill.
				self::putcsv( $out, array( '2', '101', 'pass',    '', '', '', '09/15/2025', '' ) );
				self::putcsv( $out, array( '2', '102', 'fail',    '', '', '', '',           'Did not attend.' ) );
				self::putcsv( $out, array( '2', '103', 'excused', '', '', '', '',           'Doctor note on file.' ) );
				break;
		}
		fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		exit;
	}

	public static function handle_export() {
		if ( ! current_user_can( 'manage_tradeschool' ) ) { wp_die( 'Forbidden' ); }
		check_admin_referer( 'tsmp_export' );
		$type = sanitize_key( wp_unslash( $_GET['type'] ?? 'students' ) );

		if ( ob_get_level() ) { @ob_end_clean(); }
		nocache_headers();
		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename=tsm_' . $type . '_' . gmdate( 'Y-m-d' ) . '.csv' );
		$out = fopen( 'php://output', 'w' );
		// No BOM  -  keeps cell A1 clean in spreadsheet apps that don't auto-detect it.

		switch ( $type ) {
			case 'students':
				$cf_defs = class_exists( 'TSMP_Custom_Fields' ) ? TSMP_Custom_Fields::all_defs() : array();
				$header  = self::student_csv_header( $cf_defs );
				self::putcsv( $out, $header );

				foreach ( TSM_Students::all() as $s ) {
					$row = array(
						$s->id, $s->first_name, $s->last_name, $s->email, $s->phone, $s->username, $s->status,
						$s->emergency_name ?? '', $s->emergency_phone ?? '', $s->emergency_relation ?? '',
						$s->notes ?? '',
					);
					foreach ( $cf_defs as $def ) {
						$row[] = TSMP_Custom_Fields::get_value( $def->id, $s->id );
					}
					self::putcsv( $out, $row );
				}
				break;
			case 'classes':
				self::putcsv( $out, array( 'id', 'name', 'school_year_id', 'teacher_id', 'grading_method' ) );
				foreach ( TSM_Classes::all() as $c ) {
					self::putcsv( $out, array( $c->id, $c->name, $c->school_year_id, $c->teacher_id, $c->grading_method ) );
				}
				break;
			case 'assignments':
				$classes = TSM_Classes::all();
				$cmap    = array();
				foreach ( $classes as $c ) { $cmap[ (int) $c->id ] = $c->name; }
				self::putcsv( $out, array( 'class_id', 'class_name', 'title', 'category', 'assigned_date', 'due_date', 'points_possible', 'extra_credit', 'required', 'description' ) );
				foreach ( TSM_Assignments::all() as $r ) {
					self::putcsv( $out, array(
						(int) $r->class_id,
						$cmap[ (int) $r->class_id ] ?? '',
						$r->title,
						$r->category,
						self::date_for_csv( $r->assigned_date ),
						self::date_for_csv( $r->due_date ),
						$r->points_possible,
						$r->extra_credit ? 1 : 0,
						$r->required ? 1 : 0,
						wp_strip_all_tags( (string) $r->description ),
					) );
				}
				break;
			case 'grades':
				global $wpdb;
				$grades = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}tsm_grades" );
				self::putcsv( $out, array( 'assignment_id', 'student_id', 'status', 'points_earned', 'bonus', 'late_penalty', 'submitted_date', 'comments' ) );
				foreach ( $grades as $g ) {
					self::putcsv( $out, array(
						$g->assignment_id,
						$g->student_id,
						$g->status ?? '',
						$g->points_earned,
						$g->bonus ?? '',
						$g->late_penalty,
						self::date_for_csv( $g->submitted_date ?? '' ),
						$g->comments,
					) );
				}
				break;
		}
		fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		exit;
	}
}
