<?php
/**
 * /jobs routes (see docs/SPEC.md §8).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Rest;

use EmberPrice\Apply\RuleResolver;
use EmberPrice\Apply\Snapshotter;
use EmberPrice\Core\Crypto;
use EmberPrice\Core\Jobs;
use EmberPrice\Core\LicenseManager;
use EmberPrice\Core\Rows;
use EmberPrice\Core\Settings;
use EmberPrice\Core\Snapshots;
use EmberPrice\Core\Suppliers;
use EmberPrice\Ingestion\ColumnDetector;
use EmberPrice\Ingestion\CsvParser;
use EmberPrice\Ingestion\FileValidator;
use EmberPrice\Ingestion\LlmColumnMapper;
use EmberPrice\Ingestion\LlmPdfRowExtractor;
use EmberPrice\Ingestion\PdfParser;
use EmberPrice\Ingestion\PdfTextLayerMissingException;
use EmberPrice\Ingestion\PriceParser;
use EmberPrice\Ingestion\UploadStorage;
use EmberPrice\Ingestion\XlsxParser;
use EmberPrice\Llm\LlmClientFactory;
use EmberPrice\Llm\LlmClientInterface;
use EmberPrice\Llm\LlmException;
use EmberPrice\Pricing\DiffCsvExporter;
use EmberPrice\Pricing\Guardrails;
use EmberPrice\Pricing\RuleEngine;
use EmberPrice\Pricing\RuleValidator;

defined( 'ABSPATH' ) || exit;

/**
 * Registers the job lifecycle routes. POST /jobs and POST /jobs/{id}/map
 * are implemented (T-105); the rest are still stubs, implemented
 * incrementally in later tasks.
 */
final class JobsController extends Controller {

	/**
	 * Rows per page for GET /jobs/{id}/rows.
	 */
	private const ROWS_PER_PAGE      = 50;
	private const JOBS_PER_PAGE      = 20;
	private const MAX_IMPORT_ROWS    = 50000;
	private const MAX_IMPORT_COLUMNS = 100;

	/**
	 * Rough per-row token estimate for GET /jobs/{id}/llm-estimate (row
	 * name/code + its top-5 candidates' sku/title) -- a heuristic for the
	 * UI's cost estimate (SPEC/T-402), not a billing-accurate count.
	 */
	private const LLM_ESTIMATED_TOKENS_PER_ROW = 120;

	/**
	 * Rough fixed overhead per adjudication batch (system prompt + schema
	 * hint), added once per ~30-row batch in the same estimate.
	 */
	private const LLM_ESTIMATED_TOKENS_PER_BATCH = 150;
	private const LLM_ESTIMATE_BATCH_SIZE        = 30;

	/**
	 * Below this, Ingestion\ColumnDetector's confidence for a required role
	 * (price/description) is considered too low to trust (SPEC §4's LLM
	 * mapping fallback, T-403).
	 */
	private const LLM_MAPPING_CONFIDENCE_THRESHOLD = 0.6;

	/**
	 * SPEC §4: "enviar primeras 15 filas" to the LLM mapping fallback.
	 */
	private const LLM_MAPPING_SAMPLE_ROWS = 15;

	/**
	 * REST base for this resource.
	 *
	 * @var string
	 */
	protected $rest_base = 'jobs';

	/**
	 * Used to read the configured max upload size.
	 *
	 * @var Settings
	 */
	private Settings $settings;

	/**
	 * Supplier data access.
	 *
	 * @var Suppliers
	 */
	private Suppliers $suppliers;

	/**
	 * Job data access.
	 *
	 * @var Jobs
	 */
	private Jobs $jobs;

	/**
	 * Row data access.
	 *
	 * @var Rows
	 */
	private Rows $rows;

	/**
	 * Async matching orchestration (POST /jobs/{id}/match, GET /jobs/{id}/progress).
	 *
	 * @var JobMatchScheduler
	 */
	private JobMatchScheduler $scheduler;

	/**
	 * Async apply orchestration (POST /jobs/{id}/apply).
	 *
	 * @var JobApplyScheduler
	 */
	private JobApplyScheduler $apply_scheduler;

	/**
	 * Async rollback orchestration (POST /jobs/{id}/rollback).
	 *
	 * @var JobRollbackScheduler
	 */
	private JobRollbackScheduler $rollback_scheduler;

	/**
	 * Async LLM adjudication orchestration (POST /jobs/{id}/adjudicate).
	 * Null when the AI/LLM module isn't part of this build (SPEC §9, L-7) --
	 * see llm_unavailable_reason().
	 *
	 * @var JobLlmAdjudicationScheduler|null
	 */
	private ?JobLlmAdjudicationScheduler $llm_scheduler;

	/**
	 * Snapshots affected products before POST /jobs/{id}/apply overwrites them.
	 *
	 * @var Snapshotter
	 */
	private Snapshotter $snapshotter;

	/**
	 * Read directly (not just via Snapshotter) to check whether a job's
	 * snapshots are still available before allowing a rollback.
	 *
	 * @var Snapshots
	 */
	private Snapshots $snapshots;

	/**
	 * Resolves which rule (default or category override) applies to a row's product.
	 *
	 * @var RuleResolver
	 */
	private RuleResolver $rule_resolver;

	/**
	 * Overrides build_llm_column_mapper()'s real Settings-built mapper;
	 * only ever set by tests.
	 *
	 * @var LlmColumnMapper|null
	 */
	private ?LlmColumnMapper $llm_column_mapper_override;

	/**
	 * Overrides build_llm_pdf_extractor()'s real Settings-built extractor;
	 * only ever set by tests.
	 *
	 * @var LlmPdfRowExtractor|null
	 */
	private ?LlmPdfRowExtractor $llm_pdf_extractor_override;

	/**
	 * Constructor.
	 *
	 * @param Settings|null                    $settings                   Defaults to a new instance.
	 * @param Suppliers|null                   $suppliers                  Defaults to a new instance.
	 * @param Jobs|null                        $jobs                       Defaults to a new instance.
	 * @param Rows|null                        $rows                       Defaults to a new instance.
	 * @param JobMatchScheduler|null           $scheduler                  Defaults to a new instance.
	 * @param JobApplyScheduler|null           $apply_scheduler            Defaults to a new instance.
	 * @param JobRollbackScheduler|null        $rollback_scheduler         Defaults to a new instance.
	 * @param JobLlmAdjudicationScheduler|null $llm_scheduler              Defaults to a new instance, or null if this build doesn't ship the AI/LLM module.
	 * @param Snapshotter|null                 $snapshotter                Defaults to a new instance.
	 * @param Snapshots|null                   $snapshots                  Defaults to a new instance.
	 * @param RuleResolver|null                $rule_resolver              Defaults to a new instance.
	 * @param LlmColumnMapper|null             $llm_column_mapper_override Overrides the real Settings-built mapper; only ever set by tests.
	 * @param LlmPdfRowExtractor|null          $llm_pdf_extractor_override Overrides the real Settings-built extractor; only ever set by tests.
	 */
	public function __construct(
		?Settings $settings = null,
		?Suppliers $suppliers = null,
		?Jobs $jobs = null,
		?Rows $rows = null,
		?JobMatchScheduler $scheduler = null,
		?JobApplyScheduler $apply_scheduler = null,
		?JobRollbackScheduler $rollback_scheduler = null,
		?JobLlmAdjudicationScheduler $llm_scheduler = null,
		?Snapshotter $snapshotter = null,
		?Snapshots $snapshots = null,
		?RuleResolver $rule_resolver = null,
		?LlmColumnMapper $llm_column_mapper_override = null,
		?LlmPdfRowExtractor $llm_pdf_extractor_override = null
	) {
		$this->settings                   = $settings ?? new Settings( Crypto::for_site() );
		$this->suppliers                  = $suppliers ?? new Suppliers();
		$this->jobs                       = $jobs ?? new Jobs();
		$this->rows                       = $rows ?? new Rows();
		$this->scheduler                  = $scheduler ?? new JobMatchScheduler();
		$this->apply_scheduler            = $apply_scheduler ?? new JobApplyScheduler();
		$this->rollback_scheduler         = $rollback_scheduler ?? new JobRollbackScheduler();
		$this->llm_scheduler              = $llm_scheduler ?? ( class_exists( JobLlmAdjudicationScheduler::class ) ? new JobLlmAdjudicationScheduler() : null );
		$this->snapshots                  = $snapshots ?? new Snapshots();
		$this->snapshotter                = $snapshotter ?? new Snapshotter( $this->rows, $this->snapshots );
		$this->rule_resolver              = $rule_resolver ?? new RuleResolver();
		$this->llm_column_mapper_override = $llm_column_mapper_override;
		$this->llm_pdf_extractor_override = $llm_pdf_extractor_override;
	}

	/**
	 * Registers all /jobs routes.
	 */
	public function register_routes(): void {
			register_rest_route(
				$this->namespace,
				'/' . $this->rest_base,
				array(
					array(
						'methods'             => \WP_REST_Server::READABLE,
						'callback'            => array( $this, 'list_jobs' ),
						'permission_callback' => array( $this, 'permissions_check' ),
						'args'                => array(
							'page'     => array(
								'type'    => 'integer',
								'default' => 1,
							),
							'per_page' => array(
								'type'    => 'integer',
								'default' => self::JOBS_PER_PAGE,
							),
						),
					),
					array(
						'methods'             => \WP_REST_Server::CREATABLE,
						'callback'            => array( $this, 'create_job' ),
						'permission_callback' => array( $this, 'permissions_check' ),
						'args'                => array(
							'supplier_id'   => array(
								'type' => 'integer',
							),
							'supplier_name' => array(
								'type' => 'string',
							),
						),
					),
				)
			);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/progress',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'job_progress' ),
				'permission_callback' => array( $this, 'permissions_check' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/rows',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'job_rows' ),
				'permission_callback' => array( $this, 'permissions_check' ),
				'args'                => array(
					'status' => array(
						'type' => 'string',
					),
					'page'   => array(
						'type'    => 'integer',
						'default' => 1,
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/map',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'map_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
				'args'                => array(
					'column_map' => array(
						'type'     => 'object',
						'required' => true,
					),
					'has_iva'    => array(
						'type' => 'boolean',
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/match',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'match_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
				'args'                => array(
					'retry' => array(
						'type'    => 'boolean',
						'default' => false,
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/preview',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'preview_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/export',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'export_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/apply',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'apply_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
				'args'                => array(
					'retry' => array(
						'type'    => 'boolean',
						'default' => false,
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/rollback',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'rollback_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
				'args'                => array(
					'retry' => array(
						'type'    => 'boolean',
						'default' => false,
					),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/llm-estimate',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'llm_estimate' ),
				'permission_callback' => array( $this, 'permissions_check' ),
			)
		);

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/adjudicate',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'adjudicate_job' ),
				'permission_callback' => array( $this, 'permissions_check' ),
			)
		);
	}

	/**
	 * `GET /jobs`: every job, most recent first -- the "Historial" screen's
	 * listing (SPEC §8).
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response
	 */
	public function list_jobs( \WP_REST_Request $request ) {
		$page     = max( 1, (int) $request->get_param( 'page' ) );
		$per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
		$result   = $this->jobs->list_paginated( $page, $per_page );

		return new \WP_REST_Response(
			array(
				'jobs'     => $result['jobs'],
				'total'    => $result['total'],
				'page'     => $page,
				'per_page' => $per_page,
			),
			200
		);
	}

	/**
	 * `GET /jobs/{id}`: a single job's detail, with its `totals`.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function get_job( \WP_REST_Request $request ) {
		$job = $this->jobs->find( (int) $request->get_param( 'id' ) );

		if ( null === $job ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$supplier     = $this->suppliers->find( (int) $job['supplier_id'] );
		$mapping      = (array) ( $job['totals']['mapping'] ?? array() );
		$preview_rows = $this->rows->get_paginated( (int) $job['id'], null, 1, 20 )['rows'];

		return new \WP_REST_Response(
			array(
				'job'            => $job,
				'supplier'       => $supplier,
				'mapping'        => (array) ( $mapping['proposed'] ?? ( $supplier['column_map'] ?? array() ) ),
				'mapping_source' => (string) ( $mapping['source'] ?? 'unknown' ),
				'mapping_stale'  => (bool) ( $mapping['stale_saved_map'] ?? false ),
				'headers'        => (array) ( $mapping['headers'] ?? array() ),
				'preview'        => array_map( static fn( array $row ): array => (array) $row['raw'], $preview_rows ),
			),
			200
		);
	}

	/**
	 * `POST /jobs`: uploads a file, resolves/creates the supplier, parses
	 * the file into `embp_rows.raw`, and proposes a column mapping.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 *
	 * @throws \RuntimeException Internal transaction sentinels are caught and converted to a REST error.
	 */
	public function create_job( \WP_REST_Request $request ) {
		$files = $request->get_file_params();

		if ( empty( $files['file']['tmp_name'] ) || UPLOAD_ERR_OK !== ( $files['file']['error'] ?? UPLOAD_ERR_NO_FILE ) ) {
			return $this->error( 'embp_missing_file', __( 'No file was uploaded.', 'emberprice' ), 400 );
		}

		$supplier_id   = $request->get_param( 'supplier_id' );
		$supplier_name = $request->get_param( 'supplier_name' );

		if ( empty( $supplier_id ) === empty( $supplier_name ) ) {
			return $this->error(
				'embp_invalid_supplier',
				__( 'Provide exactly one of supplier_id or supplier_name.', 'emberprice' ),
				400
			);
		}

		// Validate the file before touching the database, so a rejected
		// upload never leaves behind an orphaned new supplier record.
		$file_errors = FileValidator::with_max_size_from_settings( $this->settings )
			->validate( $files['file']['tmp_name'], $files['file']['name'] );

		if ( ! empty( $file_errors ) ) {
			return $this->error( 'embp_invalid_file', implode( ' ', $file_errors ), 400 );
		}

		$clean_supplier_name = sanitize_text_field( (string) $supplier_name );
		$supplier            = null;

		if ( ! empty( $supplier_id ) ) {
			$supplier = $this->suppliers->find( (int) $supplier_id );

			if ( null === $supplier ) {
				return $this->error( 'embp_supplier_not_found', __( 'Supplier not found.', 'emberprice' ), 404 );
			}
		} elseif ( '' === $clean_supplier_name ) {
			return $this->error( 'embp_invalid_supplier', __( 'Supplier name cannot be empty.', 'emberprice' ), 400 );
		}

		$extension = strtolower( pathinfo( $files['file']['name'], PATHINFO_EXTENSION ) );
		$headers   = array();

		if ( 'pdf' === $extension ) {
			$unavailable = $this->llm_unavailable_reason();

			if ( 'unavailable' === $unavailable ) {
				return $this->error( 'embp_pdf_not_supported', __( 'PDF price lists are not supported in this version.', 'emberprice' ), 400 );
			}

			if ( 'not_pro' === $unavailable ) {
				return $this->error( 'embp_pro_required', __( 'PDF price lists require the Pro plan.', 'emberprice' ), 403 );
			}

			if ( 'disabled' === $unavailable ) {
				return $this->error( 'embp_ai_disabled', __( 'AI features are currently disabled in settings.', 'emberprice' ), 400 );
			}

			if ( 'no_key' === $unavailable ) {
				return $this->error( 'embp_llm_no_key', __( 'PDF price lists require an LLM API key to be configured.', 'emberprice' ), 400 );
			}

			try {
				$data_rows = $this->extract_pdf_rows( $files['file']['tmp_name'] );
			} catch ( PdfTextLayerMissingException $exception ) {
				return $this->error( 'embp_pdf_no_text_layer', $exception->getMessage(), 400 );
			} catch ( \Throwable $exception ) {
				return $this->error( 'embp_parse_failed', __( 'The uploaded file could not be parsed safely.', 'emberprice' ), 400 );
			}

			if ( empty( $data_rows ) ) {
				return $this->error(
					'embp_pdf_no_rows_extracted',
					__( 'Could not extract any price rows from this PDF.', 'emberprice' ),
					400
				);
			}

			$headers  = array( 'Code', 'Description', 'Price' );
			$proposed = array(
				0 => 'code',
				1 => 'description',
				2 => 'price',
			);
		} else {
			try {
				$parsed_file = $this->parse_file( $files['file']['tmp_name'], $extension );
			} catch ( \Throwable $exception ) {
				return $this->error( 'embp_parse_failed', __( 'The uploaded spreadsheet is damaged or is not a supported workbook.', 'emberprice' ), 400 );
			}

			$header_row = $parsed_file['header_row'];
			$headers    = null !== $header_row ? (array) ( $parsed_file['rows'][ $header_row ] ?? array() ) : array();
			$data_rows  = array_slice( $parsed_file['rows'], ( $header_row ?? -1 ) + 1, null, true );

			// Reject oversized matrices before column detection scans every
			// value. File-size validation alone does not cap CSV row count.
			$matrix_error = $this->validate_import_matrix( $data_rows );

			if ( null !== $matrix_error ) {
				return $this->error( 'embp_invalid_file_shape', $matrix_error, 400 );
			}

			$detected = ( new ColumnDetector() )->detect( $parsed_file['rows'], $header_row );
			$proposed = $this->propose_column_map( $detected, $data_rows, $headers );
		}

		// PDF extraction bypasses the spreadsheet branch above.
		$matrix_error = 'pdf' === $extension ? $this->validate_import_matrix( $data_rows ) : null;

		if ( null !== $matrix_error ) {
			return $this->error( 'embp_invalid_file_shape', $matrix_error, 400 );
		}

		$column_count   = max( array_map( 'count', $data_rows ) );
		$signature      = $this->column_signature( $headers, $column_count );
		$mapping_source = 'detected';
		$had_saved_map  = null !== $supplier && ! empty( $supplier['column_map'] );

		if (
			null !== $supplier
			&& ! empty( $supplier['column_map'] )
			&& hash_equals( (string) ( $supplier['settings']['column_map_signature'] ?? '' ), $signature )
			&& $this->column_map_fits( (array) $supplier['column_map'], $column_count )
		) {
			$proposed       = (array) $supplier['column_map'];
			$mapping_source = 'saved';
		}

		$uploads = new UploadStorage();

		try {
			$stored = $uploads->store( $files['file']['tmp_name'] );
		} catch ( \Throwable $exception ) {
			return $this->error( 'embp_upload_storage_failed', __( 'The validated file could not be stored securely.', 'emberprice' ), 500 );
		}

		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query( 'START TRANSACTION' );

		try {
			if ( null === $supplier ) {
				$supplier_id = $this->suppliers->create( $clean_supplier_name );
				$supplier    = $this->suppliers->find( (int) $supplier_id );
			}

			if ( null === $supplier ) {
				throw new \RuntimeException( 'Supplier insert failed.' );
			}

			$job_id = $this->jobs->create(
				array(
					'supplier_id' => (int) $supplier['id'],
					'file_name'   => sanitize_file_name( $files['file']['name'] ),
					'file_hash'   => $stored['hash'],
					'status'      => 'parsing',
					'created_by'  => get_current_user_id(),
				)
			);

			if ( $job_id <= 0 ) {
				throw new \RuntimeException( 'Job insert failed.' );
			}

			$this->jobs->merge_totals(
				$job_id,
				array(
					'mapping' => array(
						'signature'       => $signature,
						'headers'         => $headers,
						'column_count'    => $column_count,
						'proposed'        => $proposed,
						'source'          => $mapping_source,
						'stale_saved_map' => $had_saved_map && 'saved' !== $mapping_source,
					),
				)
			);
			$this->rows->insert_raw( $job_id, $data_rows );

			if ( count( $data_rows ) !== $this->rows->count_by_job( $job_id ) ) {
				throw new \RuntimeException( 'Row insert failed.' );
			}

			$this->jobs->update_status( $job_id, 'mapping' );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query( 'COMMIT' );
		} catch ( \Throwable $exception ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query( 'ROLLBACK' );
			$uploads->delete_by_hash( (string) $stored['hash'] );

			return $this->error( 'embp_job_create_failed', __( 'The import could not be created. No data was saved.', 'emberprice' ), 500 );
		}

		return new \WP_REST_Response(
			array(
				'job'            => $this->jobs->find( $job_id ),
				'supplier'       => $this->suppliers->find( (int) $supplier['id'] ),
				'mapping'        => $proposed,
				'mapping_source' => $mapping_source,
				'mapping_stale'  => $had_saved_map && 'saved' !== $mapping_source,
				'headers'        => $headers,
				'preview'        => array_slice( array_values( $data_rows ), 0, 20 ),
			),
			201
		);
	}

	/**
	 * `POST /jobs/{id}/map`: persists the confirmed column mapping onto the
	 * supplier and fills `embp_rows.parsed` (name/code/price) for every row.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function map_job( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );
		$job    = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		if ( 'mapping' !== $job['status'] ) {
			return $this->error( 'embp_invalid_job_status', __( 'This job is not waiting for column mapping.', 'emberprice' ), 409 );
		}

		$column_count = (int) ( $job['totals']['mapping']['column_count'] ?? 0 );
		$column_map   = $this->normalize_column_map( (array) $request->get_param( 'column_map' ), $column_count );

		if ( is_wp_error( $column_map ) ) {
			return $this->error(
				'embp_invalid_mapping',
				$column_map->get_error_message(),
				400
			);
		}

		$price_column       = array_search( 'price', $column_map, true );
		$description_column = array_search( 'description', $column_map, true );
		$code_column        = array_search( 'code', $column_map, true );

		$this->suppliers->save_column_map( (int) $job['supplier_id'], $column_map );

		$supplier_settings = array(
			'column_map_signature' => (string) ( $job['totals']['mapping']['signature'] ?? '' ),
		);

		if ( null !== $request->get_param( 'has_iva' ) ) {
			$supplier_settings['has_iva'] = (bool) $request->get_param( 'has_iva' );
		}

		$this->suppliers->merge_settings( (int) $job['supplier_id'], $supplier_settings );

		$price_parser = new PriceParser();

		foreach ( $this->rows->get_by_job( $job_id ) as $row ) {
			$raw = $row['raw'];

			$parsed_price = $price_parser->parse( $raw[ $price_column ] ?? null );
			$parsed       = array(
				'name'  => $raw[ $description_column ] ?? '',
				'code'  => false !== $code_column ? ( $raw[ $code_column ] ?? null ) : null,
				'price' => null !== $parsed_price && $parsed_price > 0.0 ? $parsed_price : null,
			);

			$this->rows->update_parsed( (int) $row['id'], $parsed );

			if ( null === $parsed['price'] ) {
				$this->rows->update_status( (int) $row['id'], 'skipped' );
				$this->rows->add_flag( (int) $row['id'], 'invalid_list_price' );
			}
		}

		$this->jobs->update_status( $job_id, 'matching' );

		return new \WP_REST_Response( array( 'job' => $this->jobs->find( $job_id ) ), 200 );
	}

	/**
	 * `POST /jobs/{id}/match`: schedules the match cascade (SPEC §3) over
	 * every row via Action Scheduler, in batches of 500. Returns
	 * immediately; poll `GET /jobs/{id}/progress` for status.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function match_job( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );
		$job    = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$retrying_failed_match = 'failed' === $job['status']
			&& 'matching' === ( $job['totals']['error']['stage'] ?? '' );

		if ( 'matching' !== $job['status'] && ! $retrying_failed_match ) {
			return $this->error(
				'embp_invalid_job_status',
				__( 'This job is not ready to be matched.', 'emberprice' ),
				409
			);
		}

		if ( $retrying_failed_match ) {
			$this->jobs->update_status( $job_id, 'matching' );
			$this->jobs->merge_totals( $job_id, array( 'error' => null ) );
		}

		if ( (bool) $request->get_param( 'retry' ) ) {
			$this->scheduler->retry( $job_id );
		} else {
			$this->scheduler->schedule( $job_id );
		}

		return new \WP_REST_Response( array( 'job' => $this->jobs->find( $job_id ) ), 202 );
	}

	/**
	 * `POST /jobs/{id}/apply`: snapshots every affected product/variation's
	 * current prices (synchronously -- SPEC §6 step 1), then schedules the
	 * batched write of each row's previewed price via Action Scheduler.
	 * Returns immediately; poll `GET /jobs/{id}/progress` for status.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function apply_job( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );
		$job    = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		if ( 'applying' === $job['status'] && (bool) $request->get_param( 'retry' ) ) {
			$this->apply_scheduler->retry( $job_id );

			return new \WP_REST_Response( array( 'job' => $this->jobs->find( $job_id ) ), 202 );
		}

		if ( 'ready' !== $job['status'] ) {
			return $this->error(
				'embp_invalid_job_status',
				__( 'This job is not ready to be applied.', 'emberprice' ),
				409
			);
		}

		$this->snapshotter->snapshot_job( $job_id );
		$this->jobs->update_status( $job_id, 'applying' );
		$this->apply_scheduler->schedule( $job_id );

		return new \WP_REST_Response( array( 'job' => $this->jobs->find( $job_id ) ), 202 );
	}

	/**
	 * `POST /jobs/{id}/rollback`: restores every snapshot taken for the job
	 * back onto its product/variation (SPEC §6 step 4), batched via Action
	 * Scheduler the same way apply is. The job stays `applied` until the
	 * last batch flips it to `rolled_back`. Returns immediately; poll
	 * `GET /jobs/{id}/progress` for status.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function rollback_job( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );
		$job    = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$failed_stage           = (string) ( $job['totals']['error']['stage'] ?? '' );
		$can_recover_failed_job = 'failed' === $job['status']
			&& in_array( $failed_stage, array( 'apply', 'rollback' ), true );

		if ( 'applied' !== $job['status'] && ! $can_recover_failed_job ) {
			return $this->error(
				'embp_invalid_job_status',
				__( 'This job is not ready to be rolled back.', 'emberprice' ),
				409
			);
		}

		if ( 0 === $this->snapshots->count_by_job( $job_id ) ) {
			return $this->error(
				'embp_snapshots_not_found',
				__( 'Snapshots for this job are no longer available (retention period expired).', 'emberprice' ),
				404
			);
		}

		// `status` has no rolling_back value. Recoverable failed jobs move
		// back to applied while the rollback runner owns them, so progress
		// does not immediately report the old terminal failure.
		if ( $can_recover_failed_job ) {
			$this->jobs->update_status( $job_id, 'applied' );
		}

		$this->jobs->merge_totals( $job_id, array( 'error' => null ) );

		if ( (bool) $request->get_param( 'retry' ) ) {
			$this->rollback_scheduler->retry( $job_id );
		} else {
			$this->rollback_scheduler->schedule( $job_id );
		}

		return new \WP_REST_Response( array( 'job' => $this->jobs->find( $job_id ) ), 202 );
	}

	/**
	 * `GET /jobs/{id}/llm-estimate`: whether stage-4 LLM adjudication (SPEC
	 * §3.3) is available for this job (Pro + a configured key) and, if so,
	 * a rough cost estimate -- shown in the UI before the user commits to
	 * spending API credits, per T-402's acceptance.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function llm_estimate( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );

		if ( null === $this->jobs->find( $job_id ) ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$unavailable = $this->llm_unavailable_reason();

		if ( null !== $unavailable ) {
			return new \WP_REST_Response(
				array(
					'available' => false,
					'reason'    => $unavailable,
				),
				200
			);
		}

		$rows      = $this->rows->count_by_job_and_statuses( $job_id, array( 'review' ) );
		$batches   = (int) ceil( $rows / self::LLM_ESTIMATE_BATCH_SIZE );
		$estimated = $rows * self::LLM_ESTIMATED_TOKENS_PER_ROW + $batches * self::LLM_ESTIMATED_TOKENS_PER_BATCH;

		return new \WP_REST_Response(
			array(
				'available'        => true,
				'rows'             => $rows,
				'estimated_tokens' => $estimated,
			),
			200
		);
	}

	/**
	 * `POST /jobs/{id}/adjudicate`: schedules stage-4 LLM adjudication (SPEC
	 * §3.3) over the job's `review` rows, batched via Action Scheduler.
	 * Returns immediately; poll `GET /jobs/{id}/progress` for status.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function adjudicate_job( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );

		if ( null === $this->jobs->find( $job_id ) ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$unavailable = $this->llm_unavailable_reason();

		if ( 'unavailable' === $unavailable ) {
			return $this->error(
				'embp_llm_unavailable',
				__( 'AI-assisted matching is not available in this version.', 'emberprice' ),
				400
			);
		}

		if ( 'not_pro' === $unavailable ) {
			return $this->error(
				'embp_pro_required',
				__( 'LLM adjudication requires the Pro plan.', 'emberprice' ),
				403
			);
		}

		if ( 'disabled' === $unavailable ) {
			return $this->error(
				'embp_ai_disabled',
				__( 'AI features are currently disabled in settings.', 'emberprice' ),
				400
			);
		}

		if ( 'no_key' === $unavailable ) {
			return $this->error(
				'embp_llm_no_key',
				__( 'No LLM API key is configured yet.', 'emberprice' ),
				400
			);
		}

		if ( 0 === $this->rows->count_by_job_and_statuses( $job_id, array( 'review' ) ) ) {
			return $this->error(
				'embp_no_rows_to_adjudicate',
				__( 'There are no rows left to adjudicate.', 'emberprice' ),
				400
			);
		}

		$this->llm_scheduler->schedule( $job_id );

		return new \WP_REST_Response( array( 'job' => $this->jobs->find( $job_id ) ), 202 );
	}

	/**
	 * `GET /jobs/{id}/progress`: `{ stage, done, total }` for whichever
	 * batch runner the job is currently in -- matching, apply, rollback, or
	 * LLM adjudication.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function job_progress( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );
		$job    = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$error_stage = (string) ( $job['totals']['error']['stage'] ?? '' );

		if ( 'rolled_back' === $job['status'] || 'rollback' === $error_stage || isset( $job['totals']['rollback'] ) ) {
			$progress = $this->rollback_scheduler->progress( $job_id );
		} elseif ( 'apply' === $error_stage || in_array( $job['status'], array( 'applying', 'applied' ), true ) ) {
			$progress = $this->apply_scheduler->progress( $job_id );
		} elseif ( null !== $this->llm_scheduler && isset( $job['totals']['llm'] ) ) {
			$progress = $this->llm_scheduler->progress( $job_id );
		} elseif ( 'preview' === $error_stage ) {
			$progress = array(
				'stage'   => 'failed',
				'done'    => 0,
				'total'   => $this->rows->count_by_job( $job_id ),
				'failed'  => 0,
				'stalled' => false,
				'message' => (string) ( $job['totals']['error']['message'] ?? '' ),
				'error'   => $job['totals']['error'] ?? null,
			);
		} else {
			$progress = $this->scheduler->progress( $job_id );
		}

		return new \WP_REST_Response( $progress, 200 );
	}

	/**
	 * Why LLM adjudication isn't available for the current user, or null if
	 * it is: 'unavailable' if this build doesn't ship the AI/LLM module at
	 * all (SPEC §9, L-7 -- the free 1.0.0 build), 'not_pro' if Free (a build
	 * that does ship the module), 'disabled' if the AI features toggle
	 * (SPEC §10/T-405) is off, 'no_key' if Pro + enabled but no key
	 * configured yet.
	 */
	private function llm_unavailable_reason(): ?string {
		if ( ! class_exists( LlmClientFactory::class ) ) {
			return 'unavailable';
		}

		if ( ! LicenseManager::is_pro() ) {
			return 'not_pro';
		}

		if ( ! $this->settings->ai_enabled() ) {
			return 'disabled';
		}

		if ( ! $this->settings->has_llm_api_key() ) {
			return 'no_key';
		}

		return null;
	}

	/**
	 * `GET /jobs/{id}/rows?status=&page=`: paginated rows for the review
	 * step's table, plus counts by status for its filter badges.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function job_rows( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );

		if ( null === $this->jobs->find( $job_id ) ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$status = (string) $request->get_param( 'status' );
		$page   = max( 1, (int) $request->get_param( 'page' ) );

		$page_data = $this->rows->get_paginated( $job_id, '' !== $status ? $status : null, $page, self::ROWS_PER_PAGE );

		return new \WP_REST_Response(
			array(
				'rows'     => array_map( array( $this, 'with_matched_product' ), $page_data['rows'] ),
				'total'    => $page_data['total'],
				'page'     => $page,
				'per_page' => self::ROWS_PER_PAGE,
				'counts'   => $this->rows->counts_by_status( $job_id ),
			),
			200
		);
	}

	/**
	 * `POST /jobs/{id}/preview`: applies RuleEngine + Guardrails (SPEC §5)
	 * to every `auto`/`confirmed` row, persisting `old_price`/`new_price`/
	 * `flags`. A row Guardrails flags as too risky gets demoted back to
	 * `review` -- a perfect match doesn't guarantee a safe price. Re-callable:
	 * tweaking the supplier's rule (paso 4) and previewing again just
	 * recomputes every eligible row from scratch.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function preview_job( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );
		$job    = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		if ( ! in_array( $job['status'], array( 'review', 'ready' ), true ) ) {
			return $this->error(
				'embp_invalid_job_status',
				__( 'This job is not ready to be previewed.', 'emberprice' ),
				409
			);
		}

		$duplicate_targets = $this->rows->duplicate_targets( $job_id, array( 'auto', 'confirmed' ) );

		if ( ! empty( $duplicate_targets ) ) {
			foreach ( $duplicate_targets as $row_ids ) {
				foreach ( $row_ids as $row_id ) {
					$this->rows->update_status( $row_id, 'review' );
					$this->rows->add_flag( $row_id, 'duplicate_product_target' );
				}
			}

			$this->jobs->update_status( $job_id, 'review' );
			$this->jobs->update_totals( $job_id, $this->rows->counts_by_status( $job_id ) );

			return $this->error(
				'embp_duplicate_product_targets',
				__( 'Two or more supplier rows target the same WooCommerce product. Review the flagged rows and keep only one.', 'emberprice' ),
				409
			);
		}

		$supplier           = $this->suppliers->find( (int) $job['supplier_id'] );
		$default_rule       = null !== $supplier ? (array) $supplier['default_rule'] : array();
		$list_includes_vat  = null !== $supplier && (bool) ( $supplier['settings']['has_iva'] ?? false );
		$store_includes_vat = function_exists( 'wc_prices_include_tax' ) && wc_prices_include_tax();

		try {
			$default_rule = ( new RuleValidator() )->validate_default_rule( $default_rule );
		} catch ( \InvalidArgumentException $exception ) {
			return $this->error( 'embp_invalid_pricing_rule', __( 'The supplier pricing rule is invalid. Save a valid rule before previewing.', 'emberprice' ), 400 );
		}

		$rule_engine = new RuleEngine();
		$guardrails  = new Guardrails();

		try {
			foreach ( $this->rows->get_by_job_and_statuses( $job_id, array( 'auto', 'confirmed' ) ) as $row ) {
				$this->preview_row( $row, $default_rule, $list_includes_vat, $store_includes_vat, $rule_engine, $guardrails );
			}
		} catch ( \Throwable $exception ) {
			$this->jobs->mark_failed( $job_id, 'preview', __( 'Price preview failed because a stored rule or product value is invalid.', 'emberprice' ) );

			return $this->error( 'embp_preview_failed', __( 'The price preview could not be completed safely.', 'emberprice' ), 400 );
		}

		$this->jobs->update_status( $job_id, 'ready' );
		$this->jobs->update_totals( $job_id, $this->rows->counts_by_status( $job_id ) );

		return new \WP_REST_Response( array( 'job' => $this->jobs->find( $job_id ) ), 200 );
	}

	/**
	 * `GET /jobs/{id}/export`: the same `auto`/`confirmed` rows as the
	 * preview, as a CSV (returned as a JSON string field rather than a raw
	 * file response, so the admin SPA can trigger the actual browser
	 * download itself via a Blob -- no REST response-serialization
	 * overrides needed).
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function export_job( \WP_REST_Request $request ) {
		$job_id = (int) $request->get_param( 'id' );

		if ( null === $this->jobs->find( $job_id ) ) {
			return $this->error( 'embp_job_not_found', __( 'Job not found.', 'emberprice' ), 404 );
		}

		$export_rows = array();

		foreach ( $this->rows->get_by_job_and_statuses( $job_id, array( 'auto', 'confirmed' ) ) as $row ) {
			$product = $this->find_matched_product( $row );

			$export_rows[] = array(
				'code'      => (string) ( $row['parsed']['code'] ?? '' ),
				'name'      => $product instanceof \WC_Product ? $product->get_name() : (string) ( $row['parsed']['name'] ?? '' ),
				'old_price' => $row['old_price'],
				'new_price' => $row['new_price'],
				'flags'     => $row['flags'],
			);
		}

		return new \WP_REST_Response(
			array(
				'csv'      => ( new DiffCsvExporter() )->export( $export_rows ),
				'filename' => "emberprice-job-{$job_id}.csv",
			),
			200
		);
	}

	/**
	 * Prices a single row: resolves its rule (category override or the
	 * supplier default), reads the matched product's current prices, runs
	 * RuleEngine then Guardrails, and persists the result.
	 *
	 * @param array      $row                 The row (status auto/confirmed).
	 * @param array      $default_rule        Supplier's `{ rule, category_overrides }`.
	 * @param bool       $list_includes_vat   Whether this supplier's list prices include VAT.
	 * @param bool       $store_includes_vat  Whether the store displays VAT-inclusive prices.
	 * @param RuleEngine $rule_engine         Shared engine instance.
	 * @param Guardrails $guardrails          Shared guardrails instance.
	 */
	private function preview_row( array $row, array $default_rule, bool $list_includes_vat, bool $store_includes_vat, RuleEngine $rule_engine, Guardrails $guardrails ): void {
		$list_price = $row['parsed']['price'] ?? null;

		if ( null === $list_price ) {
			// Rows without a price never reach 'auto'/'confirmed' (MatchPipeline
			// marks them 'skipped'), but stay defensive against bad data.
			return;
		}

		$product = $this->find_matched_product( $row );

		if ( ! $product instanceof \WC_Product ) {
			$this->rows->update_preview( (int) $row['id'], 0.0, 0.0, array( 'product_not_found' ), 'review' );
			return;
		}

		$rule                       = $this->rule_resolver->resolve_for_product( $default_rule, (int) $row['match_product_id'] );
		$rule['list_includes_vat']  = $list_includes_vat;
		$rule['store_includes_vat'] = $store_includes_vat;

		$old_regular_price = '' !== $product->get_regular_price() ? (float) $product->get_regular_price() : null;
		$old_sale_price    = '' !== $product->get_sale_price() ? (float) $product->get_sale_price() : null;

		$computed = $rule_engine->apply(
			(float) $list_price,
			$rule,
			array(
				'regular_price' => $old_regular_price,
				'sale_price'    => $old_sale_price,
			)
		);

		$guarded = $guardrails->apply(
			$computed['regular_price'],
			(float) $list_price,
			$old_regular_price,
			(array) ( $rule['guardrails'] ?? array() )
		);

		$this->rows->update_preview(
			(int) $row['id'],
			$old_regular_price ?? 0.0,
			$guarded['regular_price'],
			$guarded['flags'],
			$guarded['force_review'] ? 'review' : null
		);
	}

	/**
	 * The product a row actually prices: its variation if it has one,
	 * otherwise the matched product itself.
	 *
	 * @param array $row A row with match_product_id/match_variation_id.
	 */
	private function find_matched_product( array $row ): ?\WC_Product {
		if ( null === $row['match_product_id'] || ! function_exists( 'wc_get_product' ) ) {
			return null;
		}

		$product = wc_get_product( $row['match_variation_id'] ?? $row['match_product_id'] );

		return $product instanceof \WC_Product ? $product : null;
	}

	/**
	 * Validates file-level row/column limits after parsing.
	 *
	 * @param array<int, array> $rows Parsed data rows.
	 */
	private function validate_import_matrix( array $rows ): ?string {
		if ( empty( $rows ) ) {
			return __( 'The uploaded file contains no data rows.', 'emberprice' );
		}

		if ( count( $rows ) > self::MAX_IMPORT_ROWS ) {
			return sprintf(
				/* translators: %d: maximum rows. */
				__( 'The file exceeds the maximum of %d data rows.', 'emberprice' ),
				self::MAX_IMPORT_ROWS
			);
		}

		$column_count = max( array_map( 'count', $rows ) );

		if ( $column_count <= 0 || $column_count > self::MAX_IMPORT_COLUMNS ) {
			return sprintf(
				/* translators: %d: maximum columns. */
				__( 'The file must contain between 1 and %d columns.', 'emberprice' ),
				self::MAX_IMPORT_COLUMNS
			);
		}

		return null;
	}

	/**
	 * Stable signature used to reuse a supplier map only when the current
	 * template has the same normalized headers and column count.
	 *
	 * @param string[] $headers      Header cells.
	 * @param int      $column_count Number of data columns.
	 */
	private function column_signature( array $headers, int $column_count ): string {
		$normalized = array_map(
			static fn( $header ): string => strtolower( trim( (string) $header ) ),
			array_pad( array_slice( $headers, 0, $column_count ), $column_count, '' )
		);

		return hash( 'sha256', wp_json_encode( array( $column_count, $normalized ) ) );
	}

	/**
	 * Whether every saved mapping index still exists in this file.
	 *
	 * @param array $column_map   Saved map.
	 * @param int   $column_count Current column count.
	 */
	private function column_map_fits( array $column_map, int $column_count ): bool {
		if ( empty( $column_map ) ) {
			return false;
		}

		foreach ( array_keys( $column_map ) as $index ) {
			if ( ! ctype_digit( (string) $index ) || (int) $index < 0 || (int) $index >= $column_count ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Sanitizes a REST mapping and enforces one-to-one roles.
	 *
	 * @param array $column_map   Raw map.
	 * @param int   $column_count Current file column count.
	 *
	 * @return array|\WP_Error
	 */
	private function normalize_column_map( array $column_map, int $column_count ) {
		$normalized = array();
		$allowed    = array( 'code', 'description', 'price' );

		foreach ( $column_map as $index => $role ) {
			$role = sanitize_key( (string) $role );

			if (
				! ctype_digit( (string) $index )
				|| (int) $index < 0
				|| (int) $index >= $column_count
				|| ! in_array( $role, $allowed, true )
			) {
				return new \WP_Error( 'embp_invalid_mapping', __( 'The column mapping contains an invalid column or role.', 'emberprice' ) );
			}

			$normalized[ (int) $index ] = $role;
		}

		$counts = array_count_values( array_values( $normalized ) );

		if ( 1 !== ( $counts['price'] ?? 0 ) || 1 !== ( $counts['description'] ?? 0 ) || ( $counts['code'] ?? 0 ) > 1 ) {
			return new \WP_Error(
				'embp_invalid_mapping',
				__( 'Choose exactly one price column, exactly one description column, and at most one code column.', 'emberprice' )
			);
		}

		ksort( $normalized );

		return $normalized;
	}

	/**
	 * Parses a stored file with the parser matching its extension.
	 *
	 * @param string $path      Path to the stored file.
	 * @param string $extension Lowercased file extension (csv/xlsx/xls).
	 */
	private function parse_file( string $path, string $extension ): array {
		if ( 'csv' === $extension ) {
			return ( new CsvParser() )->parse( $path );
		}

		return ( new XlsxParser() )->parse( $path );
	}

	/**
	 * Flattens ColumnDetector's `{ index: {role, confidence} }` output to
	 * a plain `{ index: role }` map.
	 *
	 * @param array $detected ColumnDetector::detect() output.
	 */
	private function roles_only( array $detected ): array {
		$map = array();

		foreach ( $detected as $column_index => $info ) {
			$map[ $column_index ] = $info['role'];
		}

		return $map;
	}

	/**
	 * The column map proposed on upload (SPEC §4): the heuristic map, unless
	 * confidence is too low for a required role and Pro + an LLM key are
	 * configured, in which case Ingestion\LlmColumnMapper's proposal is used
	 * -- but only if it actually covers both required roles; otherwise the
	 * heuristic stays the (always available) fallback.
	 *
	 * @param array         $detected   ColumnDetector::detect() output.
	 * @param array         $data_rows  Parsed data rows (header already excluded), keyed by original row index.
	 * @param string[]|null $header_row The file's own header row text, if one was detected.
	 */
	private function propose_column_map( array $detected, array $data_rows, ?array $header_row ): array {
		$heuristic_map = $this->roles_only( $detected );

		if ( ! $this->detection_confidence_is_low( $detected ) ) {
			return $heuristic_map;
		}

		$mapper = $this->build_llm_column_mapper();

		if ( null === $mapper ) {
			return $heuristic_map;
		}

		$sample    = array_slice( array_values( $data_rows ), 0, self::LLM_MAPPING_SAMPLE_ROWS );
		$llm_map   = $mapper->propose_mapping( $sample, $header_row );
		$llm_roles = array_values( $llm_map );

		$covers_required_roles = in_array( 'price', $llm_roles, true ) && in_array( 'description', $llm_roles, true );

		return $covers_required_roles ? $llm_map : $heuristic_map;
	}

	/**
	 * Whether ColumnDetector's confidence for either required role
	 * (price/description) is below the threshold, or the role wasn't
	 * detected at all. `code` is deliberately not checked here: it's an
	 * optional role (see map_job()'s own validation), so a supplier list
	 * that simply has no code column isn't a detection failure worth an
	 * LLM call over.
	 *
	 * @param array $detected ColumnDetector::detect() output.
	 */
	private function detection_confidence_is_low( array $detected ): bool {
		$confidence_by_role = array();

		foreach ( $detected as $info ) {
			$confidence_by_role[ $info['role'] ] = $info['confidence'];
		}

		foreach ( array( 'price', 'description' ) as $role ) {
			if ( ( $confidence_by_role[ $role ] ?? 0.0 ) < self::LLM_MAPPING_CONFIDENCE_THRESHOLD ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Builds an LLM column mapper from the currently configured
	 * provider/key, or null if the fallback isn't available (Free, no key
	 * configured, or an unsupported provider).
	 */
	private function build_llm_column_mapper(): ?LlmColumnMapper {
		// Checked first, even before the test override: the AI toggle
		// (SPEC §10/T-405) is meant to be an absolute kill switch, so
		// nothing calls out when it's off, regardless of what else is wired up.
		if ( ! $this->settings->ai_enabled() ) {
			return null;
		}

		if ( null !== $this->llm_column_mapper_override ) {
			return $this->llm_column_mapper_override;
		}

		$client = $this->build_llm_client();

		return null !== $client ? new LlmColumnMapper( $client ) : null;
	}

	/**
	 * Extracts every page of a PDF's text (Ingestion\PdfParser) and
	 * structures each page into {code, description, price} rows
	 * (Ingestion\LlmPdfRowExtractor, SPEC §11/T-404), assembled into the
	 * same 3-column row-matrix shape CsvParser/XlsxParser produce.
	 *
	 * @param string $path Path to the uploaded PDF (the PHP upload tmp_name).
	 *
	 * @return array<int, string[]>
	 *
	 * @throws PdfTextLayerMissingException If the PDF has no extractable text on any page.
	 */
	private function extract_pdf_rows( string $path ): array {
		$pages     = ( new PdfParser() )->extract_pages( $path );
		$extractor = $this->build_llm_pdf_extractor();

		if ( null === $extractor ) {
			return array();
		}

		$data_rows = array();

		foreach ( $pages as $page_text ) {
			foreach ( $extractor->extract_rows( $page_text ) as $row ) {
				$data_rows[] = array(
					(string) ( $row['code'] ?? '' ),
					$row['description'],
					(string) ( $row['price'] ?? '' ),
				);
			}
		}

		return $data_rows;
	}

	/**
	 * Builds an LLM PDF row extractor from the currently configured
	 * provider/key, or null if PDF ingestion isn't available (Free, no key
	 * configured, or an unsupported provider).
	 */
	private function build_llm_pdf_extractor(): ?LlmPdfRowExtractor {
		// Checked first, even before the test override: the AI toggle
		// (SPEC §10/T-405) is meant to be an absolute kill switch, so
		// nothing calls out when it's off, regardless of what else is wired up.
		if ( ! $this->settings->ai_enabled() ) {
			return null;
		}

		if ( null !== $this->llm_pdf_extractor_override ) {
			return $this->llm_pdf_extractor_override;
		}

		$client = $this->build_llm_client();

		return null !== $client ? new LlmPdfRowExtractor( $client ) : null;
	}

	/**
	 * Builds an LLM client from the currently configured provider/key, or
	 * null if none is usable (Free, no key configured, or an unsupported provider).
	 */
	private function build_llm_client(): ?LlmClientInterface {
		if ( ! class_exists( LlmClientFactory::class ) || ! LicenseManager::is_pro() || ! $this->settings->ai_enabled() || ! $this->settings->has_llm_api_key() ) {
			return null;
		}

		$provider = $this->settings->get_string( 'llm_provider', '' );
		$api_key  = $this->settings->get_llm_api_key();

		if ( '' === $provider || null === $api_key ) {
			return null;
		}

		try {
			return LlmClientFactory::create( $provider, $api_key );
		} catch ( LlmException $exception ) {
			return null;
		}
	}
}
