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

namespace EmberPrice\Rest;

use EmberPrice\Core\Jobs;
use EmberPrice\Core\Rows;
use EmberPrice\Matching\AliasStore;
use EmberPrice\Matching\Normalizer;

defined( 'ABSPATH' ) || exit;

/**
 * Registers the row review action routes: confirm the pipeline's
 * suggestion, reject it, or manually assign a different product. All three
 * feed AliasStore (SPEC §3.4) so the same row resolves instantly on the
 * supplier's next import.
 */
final class RowsController extends Controller {

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

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

	/**
	 * Job data access (to resolve a row's supplier for AliasStore).
	 *
	 * @var Jobs
	 */
	private Jobs $jobs;

	/**
	 * Learned alias storage.
	 *
	 * @var AliasStore
	 */
	private AliasStore $aliases;

	/**
	 * Used to normalize a row's description before writing it as an alias.
	 *
	 * @var Normalizer
	 */
	private Normalizer $normalizer;

	/**
	 * Validates WooCommerce product/variation relationships.
	 *
	 * @var ProductAssignmentValidator
	 */
	private ProductAssignmentValidator $product_validator;

	/**
	 * Constructor.
	 *
	 * @param Rows|null                       $rows       Defaults to a new instance.
	 * @param Jobs|null                       $jobs       Defaults to a new instance.
	 * @param AliasStore|null                 $aliases    Defaults to a new instance.
	 * @param Normalizer|null                 $normalizer        Defaults to a new instance.
	 * @param ProductAssignmentValidator|null $product_validator Defaults to a new instance.
	 */
	public function __construct(
		?Rows $rows = null,
		?Jobs $jobs = null,
		?AliasStore $aliases = null,
		?Normalizer $normalizer = null,
		?ProductAssignmentValidator $product_validator = null
	) {
		$this->rows              = $rows ?? new Rows();
		$this->jobs              = $jobs ?? new Jobs();
		$this->aliases           = $aliases ?? new AliasStore();
		$this->normalizer        = $normalizer ?? new Normalizer();
		$this->product_validator = $product_validator ?? new ProductAssignmentValidator();
	}

	/**
	 * Registers all /rows routes.
	 */
	public function register_routes(): void {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/confirm',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'confirm' ),
				'permission_callback' => array( $this, 'permissions_check' ),
			)
		);

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

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>\d+)/assign',
			array(
				'methods'             => \WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'assign' ),
				'permission_callback' => array( $this, 'permissions_check' ),
				'args'                => array(
					'product_id'   => array(
						'type'     => 'integer',
						'required' => true,
					),
					'variation_id' => array(
						'type' => 'integer',
					),
				),
			)
		);
	}

	/**
	 * `POST /rows/{id}/confirm`: accepts the cascade's suggested match as
	 * correct.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function confirm( \WP_REST_Request $request ) {
		$row = $this->rows->find( (int) $request->get_param( 'id' ) );

		if ( null === $row ) {
			return $this->error( 'embp_row_not_found', __( 'Row not found.', 'emberprice' ), 404 );
		}

		$job_error = $this->reviewable_job_error( $row );

		if ( null !== $job_error ) {
			return $job_error;
		}

		if ( null === $row['match_product_id'] ) {
			return $this->error(
				'embp_nothing_to_confirm',
				__( 'This row has no suggested match to confirm.', 'emberprice' ),
				400
			);
		}

		$product_error = $this->product_validator->validate( (int) $row['match_product_id'], $row['match_variation_id'] );

		if ( null !== $product_error ) {
			return $this->error( $product_error->get_error_code(), $product_error->get_error_message(), 400 );
		}

		$this->rows->update_status( (int) $row['id'], 'confirmed' );
		$this->learn_alias( $row, (int) $row['match_product_id'], $row['match_variation_id'] );

		return new \WP_REST_Response( array( 'row' => $this->with_matched_product( $this->rows->find( (int) $row['id'] ) ) ), 200 );
	}

	/**
	 * `POST /rows/{id}/reject`: marks the cascade's suggestion as wrong and
	 * clears it -- a rejected row must never look matched.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function reject( \WP_REST_Request $request ) {
		$row = $this->rows->find( (int) $request->get_param( 'id' ) );

		if ( null === $row ) {
			return $this->error( 'embp_row_not_found', __( 'Row not found.', 'emberprice' ), 404 );
		}

		$job_error = $this->reviewable_job_error( $row );

		if ( null !== $job_error ) {
			return $job_error;
		}

		$this->rows->update_match(
			(int) $row['id'],
			array(
				'product_id'   => null,
				'variation_id' => null,
				'match_stage'  => null,
				'confidence'   => null,
				'status'       => 'rejected',
			)
		);

		return new \WP_REST_Response( array( 'row' => $this->with_matched_product( $this->rows->find( (int) $row['id'] ) ) ), 200 );
	}

	/**
	 * `POST /rows/{id}/assign`: manually points a row at a specific
	 * product/variation (e.g. picked from the inline product search),
	 * regardless of what the cascade suggested.
	 *
	 * @param \WP_REST_Request $request The current request.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function assign( \WP_REST_Request $request ) {
		$row = $this->rows->find( (int) $request->get_param( 'id' ) );

		if ( null === $row ) {
			return $this->error( 'embp_row_not_found', __( 'Row not found.', 'emberprice' ), 404 );
		}

		$job_error = $this->reviewable_job_error( $row );

		if ( null !== $job_error ) {
			return $job_error;
		}

		$product_id   = (int) $request->get_param( 'product_id' );
		$variation_id = $request->get_param( 'variation_id' );
		$variation_id = null !== $variation_id ? (int) $variation_id : null;

		$product_error = $this->product_validator->validate( $product_id, $variation_id );

		if ( null !== $product_error ) {
			return $this->error( $product_error->get_error_code(), $product_error->get_error_message(), 400 );
		}

		$this->rows->update_match(
			(int) $row['id'],
			array(
				'product_id'   => $product_id,
				'variation_id' => $variation_id,
				'match_stage'  => 'manual',
				'confidence'   => 1.0,
				'status'       => 'confirmed',
			)
		);
		$this->learn_alias( $row, $product_id, $variation_id );

		return new \WP_REST_Response( array( 'row' => $this->with_matched_product( $this->rows->find( (int) $row['id'] ) ) ), 200 );
	}

	/**
	 * Ensures row mutations only happen during review, and invalidates a
	 * previously computed preview when a ready job is edited.
	 *
	 * @param array $row Row being changed.
	 */
	private function reviewable_job_error( array $row ): ?\WP_Error {
		$job = $this->jobs->find( (int) $row['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', __( 'Rows can only be changed while the job is under review.', 'emberprice' ), 409 );
		}

		if ( 'ready' === $job['status'] ) {
			$this->jobs->update_status( (int) $job['id'], 'review' );
		}

		return null;
	}

	/**
	 * Writes/updates the learned alias for a row's supplier + normalized
	 * description, per SPEC §3.4 -- run from both confirm() and assign().
	 *
	 * @param array    $row          The row being confirmed/assigned.
	 * @param int      $product_id   Confirmed product id.
	 * @param int|null $variation_id Confirmed variation id, if any.
	 */
	private function learn_alias( array $row, int $product_id, ?int $variation_id ): void {
		$job = $this->jobs->find( (int) $row['job_id'] );

		if ( null === $job ) {
			return;
		}

		$name       = (string) ( $row['parsed']['name'] ?? '' );
		$normalized = $this->normalizer->normalize( $name );

		if ( '' === $normalized ) {
			return;
		}

		$this->aliases->remember( (int) $job['supplier_id'], $normalized, $product_id, $variation_id );
	}
}
