<?php
/**
 * Applies counted quantities back to WooCommerce stock, and reverts them.
 *
 * @package Stocktake_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class STWC_Stock {

	/**
	 * Why this stocktake cannot be posted to live stock right now, or '' when it
	 * can. One gate, so the engine and the screen can never disagree about
	 * whether the Apply button will actually work.
	 *
	 * The `approval` and `count_mode` COLUMNS are written by every build, so a
	 * session row can name a workflow this build does not implement — a store
	 * that moved between builds, a restored database, a copied site. Refusing is
	 * the only safe answer, so every guard here lives OUTSIDE the premium region
	 * and the regions below only RELAX it.
	 *
	 * Deliberately NOT tied to stwc_is_pro(): a lapsed licence must freeze an
	 * existing approval or goods-in session, not silently start applying it with
	 * the wrong arithmetic.
	 *
	 * @param array $session Session row.
	 * @return string Neutral reason, or '' when the stocktake can be applied.
	 */
	/**
	 * Take the session's write lock, or fail.
	 *
	 * One lock, one implementation, for every path that writes this session's
	 * live stock: apply, undo, and the Pro restore-point action. They must
	 * exclude EACH OTHER, not merely themselves -- an undo that runs while an
	 * apply is still writing puts back stock the apply is still moving.
	 *
	 * The acquire is a RAW INSERT that relies on the wp_options
	 * UNIQUE(option_name) key -- NOT add_option, whose "INSERT ... ON DUPLICATE
	 * KEY UPDATE" lets a racing insert succeed. With a plain INSERT exactly one
	 * racing request lands the row and the loser's INSERT fails at the DB layer
	 * (error suppressed). Stale locks auto-expire after 120s so a crashed
	 * request cannot wedge the session.
	 *
	 * @param int $session_id
	 * @return bool Whether this caller now holds the lock.
	 */
	public static function lock_session( $session_id ) {
		global $wpdb;
		$stwc_lock = self::lock_name( $session_id );
		$stwc_prev = $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, $stwc_lock ) );
		if ( null !== $stwc_prev && ( time() - (int) $stwc_prev ) < 120 ) {
			return false;
		}
		if ( null !== $stwc_prev ) {
			$wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name = %s', $wpdb->options, $stwc_lock ) );
		}
		$stwc_suppress = $wpdb->suppress_errors( true );
		$stwc_got      = $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name, option_value, autoload) VALUES (%s, %s, 'no')", $wpdb->options, $stwc_lock, (string) time() ) );
		$wpdb->suppress_errors( $stwc_suppress );
		return (bool) $stwc_got;
	}

	/**
	 * Release the session's write lock.
	 *
	 * @param int $session_id
	 * @return void
	 */
	public static function unlock_session( $session_id ) {
		global $wpdb;
		$wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name = %s', $wpdb->options, self::lock_name( $session_id ) ) );
	}

	/**
	 * @param int $session_id
	 * @return string
	 */
	public static function lock_name( $session_id ) {
		return 'stwc_apply_lock_' . (int) $session_id;
	}

	/**
	 * The message every locked-out caller shows. Reused rather than reworded so
	 * the seven shipped locales already carry it.
	 *
	 * @return string
	 */
	public static function busy_message() {
		return __( 'This stocktake is already being applied — please wait a moment and refresh.', 'stocktake-for-woocommerce' );
	}

	public static function apply_blocker( $session ) {
		// Only an open stocktake can be applied to live stock.
		$stwc_apply_ok = ( 'open' === $session['status'] );
		$stwc_gate_msg = __( 'This stocktake is not open, so it cannot be applied to stock.', 'stocktake-for-woocommerce' );

		// A stocktake flagged for approval never posts straight to live stock.
		if ( ! empty( $session['approval'] ) ) {
			$stwc_apply_ok = false;
			$stwc_gate_msg = __( 'This stocktake is marked as requiring approval, so it cannot be applied to stock.', 'stocktake-for-woocommerce' );
		}

		if ( ! $stwc_apply_ok ) {
			return $stwc_gate_msg;
		}

		// For a catalogue larger than one page the product list is assembled in the
		// background. Applying before it finishes would zero every line that has not
		// been listed yet, so this is a gate rather than a warning — and it belongs
		// HERE, with the others, because the session screen asks this function
		// whether to draw the Apply button. While it lived only in apply_session()
		// the button stayed live on a stocktake that was still being built and the
		// click came back as an error.
		if ( ! empty( $session['building'] ) ) {
			return __( 'The product list is still being prepared — try again in a moment.', 'stocktake-for-woocommerce' );
		}

		// A counting method this build does not implement must never reach the
		// stock write. The fallback arithmetic below is "stock = counted", which on
		// a goods-in session would REPLACE live stock with the quantity just
		// received instead of adding to it. The list mirrors create_session()'s
		// whitelist: this build applies only what it can create.
		$stwc_modes = array( 'partial', 'full' );
		if ( ! in_array( $session['count_mode'], $stwc_modes, true ) ) {
			return __( 'This stocktake uses an unrecognized counting method, so it cannot be applied to stock.', 'stocktake-for-woocommerce' );
		}

		return '';
	}

	/**
	 * Post a session's counts to WooCommerce stock.
	 *
	 * - partial : counted lines that differ from live stock -> stock = counted.
	 * - full (zero-based) : counted -> counted, and uncounted -> 0.
	 *
	 * Each changed line stores its pre-change stock in `applied_from` so the
	 * whole session can be reverted (undo).
	 *
	 * @param int $session_id
	 * @return array { applied:int, skipped:int, errors:string[] }
	 */
	public static function apply_session( $session_id ) {
		$session = STWC_Sessions::get_session( $session_id );
		if ( ! $session ) {
			return array( 'applied' => 0, 'skipped' => 0, 'errors' => array( __( 'Session not found.', 'stocktake-for-woocommerce' ) ) );
		}
		// One gate for every reason a stocktake may not post to stock, shared with
		// the session screen so the button and the engine cannot disagree.
		$stwc_block = self::apply_blocker( $session );
		if ( '' !== $stwc_block ) {
			return array( 'applied' => 0, 'skipped' => 0, 'errors' => array( $stwc_block ) );
		}

		// Concurrency mutex: a double-click, a double-submit, or two admins must
		// not run two overlapping applies — the second wave would duplicate the
		// undo backup and could double-write stock. The acquire is a RAW INSERT
		// that relies on the wp_options UNIQUE(option_name) key — NOT add_option,
		// whose "INSERT ... ON DUPLICATE KEY UPDATE" lets a racing insert succeed.
		// With a plain INSERT exactly one racing request lands the row and the
		// loser's INSERT fails at the DB layer (error suppressed). Stale locks
		// auto-expire after 120s so a crashed request can't wedge the session.
		global $wpdb;
		$stwc_lock = self::lock_name( $session_id );
		if ( ! self::lock_session( $session_id ) ) {
			return array( 'applied' => 0, 'skipped' => 0, 'errors' => array( self::busy_message() ) );
		}

		// A stocktake apply is a long, user-initiated write: measured at roughly
		// 100ms per row on a normal host, so a few HUNDRED lines already pass the
		// common 30s limit and the request dies half way. Nothing is corrupted
		// when that happens - every row is marked applied as it goes and pass 1
		// skips what is already done - but the admin gets a 500 with no idea how
		// much of the count went in, and has to keep pressing Apply. Ask for more
		// time the way WooCommerce's own importers do; hosts that forbid it simply
		// ignore the request.
		if ( function_exists( 'wc_set_time_limit' ) ) {
			/**
			 * Seconds to allow the apply request. 0 = no limit where the host permits it.
			 *
			 * @param int $limit Default 0.
			 */
			wc_set_time_limit( (int) apply_filters( 'stwc_apply_time_limit', 0 ) );
		}

		$mode = $session['count_mode'];

		if ( 'full' === $mode ) {
			$items = STWC_Sessions::get_items( $session_id );
		} else {
			// 'counted', not 'variance': after a wave apply (or a manual stock
			// edit mid-count) a recount that lands back on the stale snapshot
			// expected can still differ from live stock — pass 1 decides the
			// no-ops against live stock instead of the snapshot.
			$items = STWC_Sessions::get_items( $session_id, array( 'only' => 'counted' ) );
		}

		$applied     = 0;
		$skipped     = 0;
		$errors      = array();
		$backup_rows = array();
		$plan        = array();

		// Pass 1 — read only: decide every change and snapshot the pre-change
		// state. Nothing is written yet.
		$stwc_legacy_parents = array();
		foreach ( $items as $item ) {
			if ( (int) $item['applied'] === 1 ) {
				$skipped++;
				continue;
			}

				$effective = STWC_Sessions::effective_counted( $item, $mode );
				if ( is_null( $effective ) ) {
					continue;
				}
				// A line whose count MATCHES the snapshot needs no correction —
				// live stock may legitimately differ because the store kept
				// selling after the count, and pushing the count back would
				// silently re-add sold units. The one exception: a line THIS
				// session already pushed once (applied_from survives a recount),
				// where live diverged because of our own earlier wave — a recount
				// landing back on the snapshot expected must still be re-pushed.
				$stwc_was_pushed = isset( $item['applied_from'] ) && null !== $item['applied_from'];
				if ( abs( (float) $effective - (float) $item['expected_qty'] ) < 0.000001 && ! $stwc_was_pushed ) {
					continue;
				}

			$target_id = $item['variation_id'] ? (int) $item['variation_id'] : (int) $item['product_id'];
			$product   = wc_get_product( $target_id );

			if ( ! $product ) {
				$errors[] = sprintf(
					/* translators: %s: product name */
					__( 'Product no longer exists: %s', 'stocktake-for-woocommerce' ),
					$item['name']
				);
				continue;
			}

			// Legacy rows from before parent-managed variations were collapsed
			// into one parent line: a stock write to such a variation is inert
			// (WC managing_stock() returns the string 'parent'), and redirecting
			// sibling writes at the pooled parent would clobber each other and
			// break undo. Skip them explicitly — a fresh session snapshots the
			// pool correctly as a single parent line.
			if ( $item['variation_id'] && $product->is_type( 'variation' ) && 'parent' === $product->managing_stock() ) {
				$skipped++;
				if ( ! isset( $stwc_legacy_parents[ (int) $item['product_id'] ] ) ) {
					$stwc_legacy_parents[ (int) $item['product_id'] ] = true;
					$errors[] = sprintf(
						/* translators: %s: product name */
						__( 'Stock for %s is managed on the parent product — start a new stocktake to count it (this one predates parent-level support).', 'stocktake-for-woocommerce' ),
						$item['name']
					);
				}
				continue;
			}

			$before      = wc_stock_amount( $product->managing_stock() ? $product->get_stock_quantity() : 0 );
			$applied_pre = isset( $item['applied_qty'] ) ? (float) $item['applied_qty'] : 0.0;

			{
				$new_qty = wc_stock_amount( $effective );
				// Nothing to write when live stock already holds the target
				// (epsilon absorbs DOUBLE accumulation drift from 0.1-step scans).
				if ( abs( (float) $new_qty - (float) $before ) < 0.000001 ) {
					continue;
				}
			}

			// Record the pre-change stock, the pre-change applied portion (for
			// an exact undo of partially-applied waves) and whether the product
			// was even stock-managed, so undo can restore that flag too.
			$backup_rows[] = array(
				'product_id'   => (int) $item['product_id'],
				'variation_id' => (int) $item['variation_id'],
				'name'         => $item['name'],
				'qty'          => $before,
				'applied_qty'  => $applied_pre,
				'managed'      => $product->managing_stock() ? 1 : 0,
			);

			$plan[] = array(
				'item'      => $item,
				'product'   => $product,
				'target_id' => $target_id,
				'before'    => $before,
				'new_qty'   => $new_qty,
			);
		}

		// The restore point is written BEFORE any stock changes: if the
		// request dies mid-apply (timeout, fatal), the pre-change quantities
		// are already safe. No restore point, no apply.
		if ( ! empty( $backup_rows ) ) {
			$backup_id = STWC_Backups::create(
				$session_id,
				sprintf(
					/* translators: 1: session name, 2: date/time */
					__( 'Auto backup before applying · %1$s · %2$s', 'stocktake-for-woocommerce' ),
					$session['name'],
					wp_date( 'Y-m-d H:i' )
				),
				$backup_rows
			);
			if ( ! $backup_id ) {
				$errors[] = __( 'Could not save the restore point — stock was NOT changed. Try again.', 'stocktake-for-woocommerce' );
				$wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name = %s', $wpdb->options, $stwc_lock ) );
				return array(
					'applied' => 0,
					'skipped' => $skipped,
					'errors'  => $errors,
				);
			}
		}

		// Pass 2 — apply the planned changes.
		foreach ( $plan as $p ) {
			$item    = $p['item'];
			$product = $p['product'];
			$before  = $p['before'];
			$new_qty = $p['new_qty'];

			if ( ! $product->managing_stock() ) {
				$product->set_manage_stock( true );
			}
			$product->set_stock_quantity( $new_qty );
			$product->save();

			// What has now been pushed to live stock: the counted total (0 for a
			// full-mode uncounted line that was zeroed).
			$now_applied = is_null( $item['counted_qty'] ) ? 0.0 : (float) $item['counted_qty'];
			self::mark_applied( (int) $item['id'], $before, $now_applied );
			$applied++;

			// Keep the concurrency lock warm. A lock older than 120s is treated as
			// stale so a crashed request cannot wedge the session forever - but now
			// that the apply is allowed to run long, a big session would cross that
			// line while still working, and a second click would delete the lock
			// and start a SECOND apply over the same rows. Refreshing the stamp as
			// we go keeps "stale" meaning dead rather than merely slow.
			$stwc_beat = (int) apply_filters( 'stwc_apply_heartbeat_rows', 25 );
			if ( $stwc_beat > 0 && 0 === $applied % $stwc_beat ) {
				$wpdb->update( $wpdb->options, array( 'option_value' => (string) time() ), array( 'option_name' => $stwc_lock ), array( '%s' ), array( '%s' ) );
			}

			STWC_Log::add(
				array(
					'session_id'   => $session_id,
					'item_id'      => (int) $item['id'],
					'product_id'   => (int) $item['product_id'],
					'variation_id' => (int) $item['variation_id'],
					'name'         => $item['name'],
					'event'        => 'applied',
					'qty_before'   => $before,
					'qty_after'    => $new_qty,
					'note'         => $mode,
				)
			);

			/**
			 * Fires after a single item's stock is adjusted from a stocktake.
			 *
			 * @param int   $target_id
			 * @param float $new_qty
			 * @param array $item
			 */
			do_action( 'stwc_item_applied', $p['target_id'], $new_qty, $item );
		}

		$wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name = %s', $wpdb->options, $stwc_lock ) );

		return array(
			'applied' => $applied,
			'skipped' => $skipped,
			'errors'  => $errors,
		);
	}

	/**
	 * Undo a session's last apply by restoring its most recent restore point.
	 *
	 * @param int $session_id
	 * @return array { reverted:int, errors:string[] }
	 */
	public static function revert_session( $session_id ) {
		// The SAME lock apply_session() takes, deliberately under the same name.
		// An undo and an apply write the same live stock, and so does the Pro
		// restore-point action; each of the three has to exclude the other two,
		// not merely itself. Two overlapping undos both read the same restore
		// point, both restore it, and the second deletes it while the first is
		// still writing.
		if ( ! self::lock_session( $session_id ) ) {
			return array( 'reverted' => 0, 'errors' => array( self::busy_message() ) );
		}
		$backup = STWC_Backups::latest_for_session( $session_id );
		if ( ! $backup ) {
			// Nothing to undo is not an error -- it is an accurate zero, and the
			// screen is what has to stop reading it as a success.
			self::unlock_session( $session_id );
			return array( 'reverted' => 0, 'errors' => array() );
		}
		$res = STWC_Backups::restore( (int) $backup['id'] );
		// Consume the restore point once it has been fully applied, so pressing
		// Undo again rolls back the PREVIOUS apply wave instead of re-restoring
		// the same (now-current) quantities and falsely reporting success.
		if ( empty( $res['errors'] ) ) {
			STWC_Backups::delete( (int) $backup['id'] );
		}
		self::unlock_session( $session_id );
		return array( 'reverted' => $res['restored'], 'errors' => $res['errors'] );
	}

	/**
	 * @param int   $item_id
	 * @param float $before      Pre-change stock.
	 * @param float $applied_qty Counted total now reflected in live stock.
	 */
	protected static function mark_applied( $item_id, $before, $applied_qty ) {
		global $wpdb;
		$wpdb->update(
			STWC_Sessions::items_table(),
			array( 'applied' => 1, 'applied_from' => (float) $before, 'applied_qty' => (float) $applied_qty ),
			array( 'id' => $item_id ),
			array( '%d', '%f', '%f' ),
			array( '%d' )
		);
	}
}
