<?php
/**
 * REST API used by the live count screen.
 *
 * Namespace: stwc/v1
 *
 * @package Stocktake_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class STWC_REST {

	const NS = 'stwc/v1';

	/**
	 * Sanity ceiling for a counted quantity.
	 *
	 * Its real job is to stop a barcode that slipped past the burst detector
	 * from being saved as a count: every EAN/UPC is at least 7 digits, far
	 * above any real shelf count. count.js has enforced this since the scanner
	 * work, but only in the browser — a direct call to this route accepted a
	 * 13-digit EAN as a quantity, which is exactly the accident the ceiling was
	 * written to prevent. The value is handed to the script so the two ends can
	 * never drift apart.
	 */
	const MAX_QTY = 1000000;

	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	/**
	 * Capability + nonce gate for every route.
	 *
	 * @return bool
	 */
	public function permission() {
		// Managers always; the "stwc_count" capability lets Pro shops grant
		// count-only access to staff roles without full store management.
		return current_user_can( 'manage_woocommerce' ) || current_user_can( 'stwc_count' );
	}

	/**
	 * Live-count responses must never be cached by ANY layer: a stale lookup
	 * turns a re-scan into an overwrite of the real total. WordPress already
	 * sends no-store for authenticated REST, but LiteSpeed's private cache has
	 * been observed serving hits regardless (x-litespeed-cache: hit,private on
	 * a Hostinger stack) — tell it explicitly, too.
	 */
	private function no_cache() {
		nocache_headers();
		if ( ! headers_sent() ) {
			header( 'X-LiteSpeed-Cache-Control: no-cache' );
		}
	}


	public function register_routes() {

		register_rest_route(
			self::NS,
			'/lookup',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'permission_callback' => array( $this, 'permission' ),
				'callback'            => array( $this, 'lookup' ),
				'args'                => array(
					'session_id' => array( 'required' => true ),
					'code'       => array( 'required' => true ),
				),
			)
		);

		register_rest_route(
			self::NS,
			'/count',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'permission_callback' => array( $this, 'permission' ),
				'callback'            => array( $this, 'count' ),
			)
		);

		register_rest_route(
			self::NS,
			'/uncount',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'permission_callback' => array( $this, 'permission' ),
				'callback'            => array( $this, 'uncount' ),
			)
		);

		register_rest_route(
			self::NS,
			'/search',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'permission_callback' => array( $this, 'permission' ),
				'callback'            => array( $this, 'search' ),
				'args'                => array(
					'session_id' => array( 'required' => true ),
					'q'          => array( 'required' => true ),
				),
			)
		);
	}

	/**
	 * Type-ahead product search by SKU, barcode, or name.
	 *
	 * Returns countable "units" (simple products and variations) with a small
	 * thumbnail, price, and their status within the session.
	 *
	 * @param WP_REST_Request $req
	 * @return WP_REST_Response
	 */
	public function search( $req ) {
		$this->no_cache();
		$session_id = absint( $req->get_param( 'session_id' ) );
		$q          = trim( sanitize_text_field( (string) $req->get_param( 'q' ) ) );

		$session = STWC_Sessions::get_session( $session_id );
		if ( ! $session ) {
			return new WP_REST_Response( array( 'message' => __( 'Session not found.', 'stocktake-for-woocommerce' ) ), 404 );
		}
		if ( mb_strlen( $q ) < 2 ) {
			return new WP_REST_Response( array( 'results' => array() ), 200 );
		}

		$blind = (bool) $session['blind'];
		$limit = 8;

		// Gather candidates: exact barcode (GTIN/UPC/EAN/ISBN) first, then
		// partial SKU match + title search.
		$candidates = array();
		$gtin_id    = STWC_Sessions::product_id_by_barcode( $q );
		if ( $gtin_id ) {
			$gp = wc_get_product( $gtin_id );
			// product_id_by_barcode() has no post-status filter: enforce the same
			// publish-only rule as the SKU/title queries below (a variation
			// follows its variable parent's status).
			if ( $gp ) {
				$stwc_status_id = $gp->is_type( 'variation' ) ? $gp->get_parent_id() : $gp->get_id();
				if ( 'publish' === get_post_status( $stwc_status_id ) ) {
					$candidates[] = $gp;
				}
			}
		}
		foreach ( wc_get_products( array( 'sku' => $q, 'limit' => 12, 'status' => 'publish', 'return' => 'objects' ) ) as $p ) {
			$candidates[] = $p;
		}
		// A variation's OWN sku is what staff read off the shelf label in a shop
		// that sells sizes or colours, and the box invites them to type a SKU —
		// but wc_get_products() searches the `product` post type unless the
		// variation type is asked for explicitly, so typing it found nothing
		// while the parent's sku worked. Status is filtered through the parent,
		// the same rule the barcode branch above applies: a variation has no
		// meaningful status of its own.
		foreach ( wc_get_products( array( 'sku' => $q, 'limit' => 12, 'type' => 'variation', 'return' => 'objects' ) ) as $p ) {
			if ( 'publish' === get_post_status( $p->get_parent_id() ) ) {
				$candidates[] = $p;
			}
		}
		foreach ( wc_get_products( array( 's' => $q, 'limit' => 12, 'status' => 'publish', 'type' => array( 'simple', 'variable' ), 'return' => 'objects' ) ) as $p ) {
			$candidates[] = $p;
		}

		$results = array();
		$seen    = array();

		foreach ( $candidates as $p ) {
			if ( $p->is_type( 'grouped' ) || $p->is_type( 'external' ) ) {
				continue;
			}

			// Hydrate variations one at a time — a large variation matrix would
			// otherwise load every child product object just to fill 8 slots.
			$unit_ids = $p->is_type( 'variable' ) ? $p->get_children() : array( $p->get_id() );

			foreach ( $unit_ids as $tid ) {
				$tid = (int) $tid;
				if ( isset( $seen[ $tid ] ) ) {
					continue;
				}
				$seen[ $tid ] = true;

				$u = ( $tid === $p->get_id() ) ? $p : wc_get_product( $tid );
				if ( ! $u ) {
					continue;
				}
				$results[] = $this->present_suggestion( $u, $session_id, $blind );

				if ( count( $results ) >= $limit ) {
					break 2;
				}
			}
		}

		return new WP_REST_Response( array( 'results' => $results ), 200 );
	}

	/**
	 * Shape a product/variation as a search suggestion.
	 *
	 * @param WC_Product $product
	 * @param int        $session_id
	 * @param bool       $blind
	 * @return array
	 */
	protected function present_suggestion( $product, $session_id, $blind ) {
		$is_var = $product->is_type( 'variation' );
		$pid    = $is_var ? $product->get_parent_id() : $product->get_id();
		$vid    = $is_var ? $product->get_id() : 0;

		// Variations: ship the parent's name and the attribute values apart, so
		// the UI can render "Red, S" as always-visible chips — a long product
		// name would otherwise ellipsize away exactly the part that tells
		// variants apart.
		$name    = $product->get_name();
		$variant = '';
		if ( $is_var ) {
			$variant = wc_get_formatted_variation( $product, true, false, false );
			$parent  = wc_get_product( $pid );
			if ( $parent && '' !== $variant ) {
				$name = $parent->get_name();
			}
		}

		// A variation pooling its stock at the parent lives in the session as
		// ONE parent line (variation_id = 0) — resolve membership against that.
		$lookup_vid = ( $is_var && 'parent' === $product->managing_stock() ) ? 0 : $vid;
		$item       = STWC_Sessions::find_item_by_target( $session_id, $pid, $lookup_vid );
		$img_id     = $product->get_image_id();
		$image  = $img_id ? wp_get_attachment_image_url( $img_id, 'thumbnail' ) : wc_placeholder_img_src( 'thumbnail' );

		// Use the raw numeric price (formatted) rather than get_price_html(), so
		// price-altering plugins (e.g. quote/catalog modes) don't hide the figure
		// the person counting actually needs.
		$raw   = $product->get_price();
		$price = ( '' === $raw || null === $raw ) ? '' : html_entity_decode( wp_strip_all_tags( wc_price( $raw ) ), ENT_QUOTES, 'UTF-8' );

		return array(
			'add_id'      => $is_var ? $vid : $pid,
			'product_id'  => $pid,
			'variation_id' => $vid,
			'name'        => $name,
			'variant'     => $variant,
			'sku'         => $product->get_sku(),
			'image'       => $image,
			'price'       => $price,
			'in_session'  => (bool) $item,
			'item_id'     => $item ? (int) $item['id'] : null,
			'expected'    => ( $item && ! $blind ) ? (float) $item['expected_qty'] : null,
			'counted'     => ( $item && ! is_null( $item['counted_qty'] ) ) ? (float) $item['counted_qty'] : null,
		);
	}

	/**
	 * Look up a scanned code within a session.
	 *
	 * @param WP_REST_Request $req
	 * @return WP_REST_Response
	 */
	public function lookup( $req ) {
		$this->no_cache();
		$session_id = absint( $req->get_param( 'session_id' ) );
		$code       = sanitize_text_field( (string) $req->get_param( 'code' ) );

		$session = STWC_Sessions::get_session( $session_id );
		if ( ! $session ) {
			return new WP_REST_Response( array( 'message' => __( 'Session not found.', 'stocktake-for-woocommerce' ) ), 404 );
		}

		if ( '' === trim( $code ) ) {
			return new WP_REST_Response(
				array( 'found' => false, 'out_of_scope' => false, 'message' => __( 'No product matches this code.', 'stocktake-for-woocommerce' ) ),
				200
			);
		}

		$item = STWC_Sessions::find_item_by_code( $session_id, $code );
		if ( $item ) {
			return new WP_REST_Response(
				array(
					'found' => true,
					'item'  => $this->present_item( $item, (bool) $session['blind'] ),
				),
				200
			);
		}

		// Code resolves to a real product that simply was not in scope -> offer to
		// add. Only PUBLISHED products (a variation follows its variable parent);
		// never surface a draft/trashed product for stock changes.
		$product_id = wc_get_product_id_by_sku( $code );
		if ( ! $product_id ) {
			$product_id = STWC_Sessions::product_id_by_barcode( $code );
		}
		if ( $product_id ) {
			$product     = wc_get_product( $product_id );
			$stwc_status = ( $product && $product->is_type( 'variation' ) ) ? $product->get_parent_id() : $product_id;
			if ( ! $product || 'publish' !== get_post_status( $stwc_status ) ) {
				$product_id = 0;
			}
		}
		if ( $product_id ) {
			$product = wc_get_product( $product_id );
			// A scanned code can resolve to a VARIABLE parent (e.g. the shared
			// SKU printed on the label of SKU-less variations). When the
			// variations manage their own stock the parent itself is not a
			// countable line — offering "add it?" would only be rejected later.
			// Return the stock-managed variations as pickable suggestions.
			if ( $product && $product->is_type( 'variable' ) && ! $product->managing_stock() ) {
				$blind       = (bool) $session['blind'];
				$suggestions = array();
				foreach ( $product->get_children() as $stwc_child_id ) {
					$stwc_variation = wc_get_product( $stwc_child_id );
					if ( ! $stwc_variation || true !== $stwc_variation->managing_stock() ) {
						continue;
					}
					$suggestions[] = $this->present_suggestion( $stwc_variation, $session_id, $blind );
					if ( count( $suggestions ) >= 8 ) {
						break;
					}
				}
				if ( $suggestions ) {
					return new WP_REST_Response(
						array(
							'found'        => false,
							'out_of_scope' => false,
							'suggestions'  => $suggestions,
							'message'      => __( 'No product matches this code.', 'stocktake-for-woocommerce' ),
						),
						200
					);
				}
			}
			return new WP_REST_Response(
				array(
					'found'        => false,
					'out_of_scope' => true,
					'product_id'   => $product_id,
					'name'         => $product ? $product->get_name() : '',
				),
				200
			);
		}

		return new WP_REST_Response(
			array(
				'found'        => false,
				'out_of_scope' => false,
				'message'      => __( 'No product matches this code.', 'stocktake-for-woocommerce' ),
			),
			200
		);
	}

	/**
	 * Record a counted quantity.
	 *
	 * Accepts either an `item_id` (preferred, from a prior lookup) or a raw
	 * `code`. If `add_product_id` is supplied the product is first added to the
	 * session as an out-of-scope line, then counted.
	 *
	 * @param WP_REST_Request $req
	 * @return WP_REST_Response
	 */
	public function count( $req ) {
		$this->no_cache();
		$session_id = absint( $req->get_param( 'session_id' ) );

		$qty_raw = $req->get_param( 'qty' );
		if ( null === $qty_raw || '' === $qty_raw || ! is_numeric( $qty_raw ) || (float) $qty_raw < 0 || (float) $qty_raw > self::MAX_QTY ) {
			return new WP_REST_Response( array( 'message' => __( 'Enter a valid quantity of zero or more.', 'stocktake-for-woocommerce' ) ), 400 );
		}
		$qty = wc_stock_amount( $qty_raw );

		$session = STWC_Sessions::get_session( $session_id );
		if ( ! $session ) {
			return new WP_REST_Response( array( 'message' => __( 'Session not found.', 'stocktake-for-woocommerce' ) ), 404 );
		}
		if ( 'open' !== $session['status'] ) {
			return new WP_REST_Response( array( 'message' => __( 'This stocktake is closed and can no longer be counted.', 'stocktake-for-woocommerce' ) ), 409 );
		}

		$item = null;

		$add_product_id = absint( $req->get_param( 'add_product_id' ) );
		$item_id        = absint( $req->get_param( 'item_id' ) );
		$code           = sanitize_text_field( (string) $req->get_param( 'code' ) );

		if ( $add_product_id ) {
			$item = STWC_Sessions::add_extra_item( $session_id, $add_product_id );
			if ( is_wp_error( $item ) ) {
				return new WP_REST_Response( array( 'message' => $item->get_error_message() ), 400 );
			}
		} elseif ( $item_id ) {
			$item = STWC_Sessions::get_item( $item_id );
		} elseif ( '' !== $code ) {
			$item = STWC_Sessions::find_item_by_code( $session_id, $code );
		}

		if ( ! $item || (int) $item['session_id'] !== $session_id ) {
			return new WP_REST_Response( array( 'message' => __( 'Count target not found in this session.', 'stocktake-for-woocommerce' ) ), 404 );
		}

		{
			$stwc_wrote = STWC_Sessions::record_count( (int) $item['id'], $qty );
		}
		if ( ! $stwc_wrote ) {
			return new WP_REST_Response(
				array(
					'ok'      => false,
					'message' => __( 'Could not save. Try again.', 'stocktake-for-woocommerce' ),
					'item'    => $this->present_item( STWC_Sessions::get_item( (int) $item['id'] ), (bool) $session['blind'] ),
				),
				500
			);
		}
		$item = STWC_Sessions::get_item( (int) $item['id'] );

		return new WP_REST_Response(
			array(
				'ok'      => true,
				'item'    => $this->present_item( $item, (bool) $session['blind'] ),
				'summary' => $this->summary_for( $session_id, (bool) $session['blind'] ),
			),
			200
		);
	}

	/**
	 * Remove a line's count so it goes back to "not counted yet" (the delete
	 * button on the recent-scans list).
	 *
	 * @param WP_REST_Request $req
	 * @return WP_REST_Response
	 */
	public function uncount( $req ) {
		$this->no_cache();
		$session_id = absint( $req->get_param( 'session_id' ) );
		$item_id    = absint( $req->get_param( 'item_id' ) );

		$session = STWC_Sessions::get_session( $session_id );
		if ( ! $session ) {
			return new WP_REST_Response( array( 'message' => __( 'Session not found.', 'stocktake-for-woocommerce' ) ), 404 );
		}
		if ( 'open' !== $session['status'] ) {
			return new WP_REST_Response( array( 'message' => __( 'This stocktake is closed and can no longer be counted.', 'stocktake-for-woocommerce' ) ), 409 );
		}

		$item = $item_id ? STWC_Sessions::get_item( $item_id ) : null;
		if ( ! $item || (int) $item['session_id'] !== $session_id ) {
			return new WP_REST_Response( array( 'message' => __( 'Count target not found in this session.', 'stocktake-for-woocommerce' ) ), 404 );
		}

		$stwc_ok = null;

		if ( null === $stwc_ok ) {
			$stwc_ok = STWC_Sessions::uncount( $item_id );
		}
		if ( ! $stwc_ok ) {
			return new WP_REST_Response( array( 'message' => __( 'Could not save. Try again.', 'stocktake-for-woocommerce' ) ), 500 );
		}

		return new WP_REST_Response(
			array(
				'ok'      => true,
				'item'    => $this->present_item( STWC_Sessions::get_item( $item_id ), (bool) $session['blind'] ),
				'summary' => $this->summary_for( $session_id, (bool) $session['blind'] ),
			),
			200
		);
	}

	/**
	 * Session summary for a client response. In a blind session the variance
	 * figures are derived from the hidden expected quantities, so they are
	 * redacted here — otherwise a counter can reverse-engineer book stock from
	 * the running totals their own /count responses return.
	 *
	 * @param int  $session_id
	 * @param bool $blind
	 * @return array
	 */
	private function summary_for( $session_id, $blind ) {
		$summary = STWC_Sessions::get_summary( $session_id );
		if ( $blind && ! current_user_can( STWC_Admin::CAP ) ) {
			foreach ( array( 'variance_items', 'variance_units', 'variance_value', 'expected_units', 'expected_value' ) as $stwc_key ) {
				if ( array_key_exists( $stwc_key, $summary ) ) {
					$summary[ $stwc_key ] = null;
				}
			}
		}
		return $summary;
	}




	/**
	 * Shape an item row for the client, hiding expected qty when blind counting.
	 *
	 * @param array $item
	 * @param bool  $blind
	 * @return array
	 */
	protected function present_item( $item, $blind ) {
		$counted  = is_null( $item['counted_qty'] ) ? null : (float) $item['counted_qty'];
		$expected = (float) $item['expected_qty'];

		$target  = $item['variation_id'] ? (int) $item['variation_id'] : (int) $item['product_id'];
		$product = wc_get_product( $target );
		$img_id  = $product ? $product->get_image_id() : 0;
		$image   = $img_id ? wp_get_attachment_image_url( $img_id, 'thumbnail' ) : wc_placeholder_img_src( 'thumbnail' );

		return array(
			'id'           => (int) $item['id'],
			'name'         => $item['name'],
			'sku'          => $item['sku'],
			'image'        => $image,
			'product_id'   => (int) $item['product_id'],
			'variation_id' => (int) $item['variation_id'],
			'expected_qty' => $blind ? null : $expected,
			'counted_qty'  => $counted,
			'variance'     => ( $blind || is_null( $counted ) ) ? null : ( $counted - $expected ),
		);
	}
}
