<?php
/**
 * Omnibus Price History module.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Products;

// Prevent direct access.
defined( 'ABSPATH' ) || exit;

/**
 * Omnibus Price History module.
 *
 * Tracks 30-day price history for EU Omnibus Directive compliance. Warns when
 * sales exceed 30 days and optionally auto-expires them.
 *
 * @since 1.0.0
 */
final class Omnibus extends \Kramar\Module {

	/**
	 * {@inheritDoc}
	 */
	public function get_slug(): string {
		return 'omnibus';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_category(): string {
		return 'products';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_name(): string {
		return __( 'Omnibus Price History', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_description(): string {
		return __( 'Track 30-day price history for EU Omnibus Directive compliance. Warns when sales exceed 30 days and optionally auto-expires them.', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_default_settings(): array {
		return [
			'enabled'            => false,
			'auto_expire'        => false,
			'show_lowest_price'  => false,
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function sanitize_settings( array $input ): array {
		return [
			'enabled'            => ! empty( $input['enabled'] ),
			'auto_expire'        => ! empty( $input['auto_expire'] ),
			'show_lowest_price'  => ! empty( $input['show_lowest_price'] ),
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function render_extra_settings( string $option_name ): void {
		$slug              = $this->get_slug();
		$cat               = $this->get_category();
		$auto_expire       = $this->get_setting( 'auto_expire' );
		$show_lowest_price = $this->get_setting( 'show_lowest_price' );
		$omnibus_url       = admin_url( 'admin.php?page=kramar-omnibus' );
		?>
		<div class="kramar-module__extra">
			<div class="kramar-settings-stack">

				<p>
					<label>
						<input
							type="checkbox"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][auto_expire]"
							value="1"
							<?php checked( $auto_expire ); ?>
						/>
						<?php esc_html_e( 'Auto-expire sales after 30 days', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Automatically removes the sale price when a sale has been active for 30 days (Mode B — most stores should use this). Without this, overdue sales are flagged in the Omnibus page for manual action.', 'kramar' ); ?></span>
				</p>

				<p>
					<label>
						<input
							type="checkbox"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][show_lowest_price]"
							value="1"
							<?php checked( $show_lowest_price ); ?>
						/>
						<?php esc_html_e( 'Show the 30-day lowest price on product pages', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Displays the lowest price of the previous 30 days next to a reduced (sale) price, as required by the EU Omnibus Directive. Only shown for products that are currently on sale.', 'kramar' ); ?></span>
				</p>

				<?php if ( $this->is_enabled() ) : ?>
					<p>
						<a href="<?php echo esc_url( $omnibus_url ); ?>"><?php esc_html_e( 'View Omnibus overview →', 'kramar' ); ?></a>
					</p>
				<?php endif; ?>

			</div>
		</div>
		<?php
	}

	/**
	 * Create the omnibus log database table.
	 *
	 * @since 1.0.0
	 */
	public static function install(): void {
		global $wpdb;

		$table   = $wpdb->prefix . 'kramar_omnibus_log';
		$charset = $wpdb->get_charset_collate();

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		dbDelta(
			"CREATE TABLE {$table} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				product_id bigint(20) unsigned NOT NULL,
				action varchar(50) NOT NULL,
				price decimal(19,4) DEFAULT NULL,
				sale_price decimal(19,4) DEFAULT NULL,
				user_id bigint(20) unsigned DEFAULT 0,
				note text NOT NULL,
				created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
				PRIMARY KEY (id),
				KEY product_id (product_id),
				KEY created_at (created_at)
			) {$charset};"
		);
	}

	/**
	 * {@inheritDoc}
	 */
	protected function run(): void {
		$this->register_price_tracking();
		$this->schedule_daily_check();

		if ( $this->get_setting( 'show_lowest_price' ) ) {
			add_filter( 'woocommerce_get_price_html', [ $this, 'render_lowest_price_html' ], 20, 2 );
		}

		if ( is_admin() ) {
			add_action( 'admin_menu', [ $this, 'add_admin_page' ] );
			add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] );
			add_action( 'load-kramar_page_kramar-omnibus', [ $this, 'handle_actions' ] );
		}
	}

	/**
	 * Register hooks that track price changes on products and variations.
	 *
	 * @since 1.0.0
	 */
	private function register_price_tracking(): void {
		add_action( 'woocommerce_product_object_updated_props', [ $this, 'track_price_changes' ], 10, 2 );
		add_action( 'woocommerce_product_variation_object_updated_props', [ $this, 'track_price_changes' ], 10, 2 );
	}

	/**
	 * Schedule the daily check cron event.
	 *
	 * @since 1.0.0
	 */
	private function schedule_daily_check(): void {
		if ( ! wp_next_scheduled( 'kramar_omnibus_check' ) ) {
			wp_schedule_event( time(), 'daily', 'kramar_omnibus_check' );
		}

		add_action( 'kramar_omnibus_check', [ $this, 'daily_check' ] );
	}

	/**
	 * Add the Omnibus sub-page under the Kramar menu.
	 *
	 * @since 1.0.0
	 */
	public function add_admin_page(): void {
		add_submenu_page(
			'kramar',
			__( 'Omnibus', 'kramar' ),
			$this->get_menu_title(),
			'manage_options',
			'kramar-omnibus',
			[ $this, 'render_page' ]
		);
	}

	/**
	 * Build the menu title, appending a badge when products need attention.
	 *
	 * @since 1.0.0
	 */
	private function get_menu_title(): string {
		$count = $this->get_attention_count();

		if ( $count > 0 ) {
			return sprintf(
				/* translators: %s: badge HTML with overdue count */
				__( 'Omnibus %s', 'kramar' ),
				'<span class="awaiting-mod">' . $count . '</span>'
			);
		}

		return __( 'Omnibus', 'kramar' );
	}

	/**
	 * Enqueue the admin stylesheet on the Omnibus page.
	 *
	 * @since 1.0.0
	 *
	 * @param string $hook_suffix Current admin page hook suffix.
	 */
	public function enqueue_assets( string $hook_suffix ): void {
		if ( ! str_contains( $hook_suffix, 'kramar-omnibus' ) ) {
			return;
		}

		wp_enqueue_style( 'kramar-admin', KRAMAR_URL . 'assets/css/admin.css', [], KRAMAR_VERSION );
	}

	/**
	 * Handle PRG actions on the Omnibus page before any output.
	 *
	 * @since 1.0.0
	 */
	public function handle_actions(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		// Single end sale.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_GET['action'] ) && 'end_sale' === sanitize_key( wp_unslash( $_GET['action'] ) ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$product_id = absint( $_GET['product_id'] ?? 0 );
			check_admin_referer( 'kramar_end_sale_' . $product_id );
			$this->end_sale( $product_id );
			wp_safe_redirect(
				add_query_arg(
					[ 'page' => 'kramar-omnibus', 'ended' => 1 ],
					admin_url( 'admin.php' )
				)
			);
			exit;
		}

		// Bulk end sale.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_POST['action'] ) && 'end_sale_bulk' === sanitize_key( wp_unslash( $_POST['action'] ) ) ) {
			check_admin_referer( 'kramar_omnibus_bulk' );
			// phpcs:ignore WordPress.Security.NonceVerification.Missing
			$ids = array_map( 'absint', (array) ( $_POST['product_ids'] ?? [] ) );

			foreach ( $ids as $id ) {
				if ( $id > 0 ) {
					$this->end_sale( $id );
				}
			}

			wp_safe_redirect(
				add_query_arg(
					[ 'page' => 'kramar-omnibus', 'ended' => count( $ids ) ],
					admin_url( 'admin.php' )
				)
			);
			exit;
		}
	}

	/**
	 * Render the Omnibus admin page with Active Sales and Log tabs.
	 *
	 * @since 1.0.0
	 */
	public function render_page(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$tab = sanitize_key( $_GET['tab'] ?? 'sales' );
		?>
		<div class="wrap">
			<h1><?php esc_html_e( 'Omnibus Price History', 'kramar' ); ?></h1>

			<?php if ( ! empty( $_GET['ended'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
				<div class="notice notice-success is-dismissible">
					<p>
						<?php
						$ended = absint( $_GET['ended'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
						if ( $ended > 1 ) {
							printf(
								/* translators: %d: number of products */
								esc_html__( '%d sales ended.', 'kramar' ),
								$ended // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- absint() integer, no escaping needed
							);
						} else {
							esc_html_e( 'Sale ended.', 'kramar' );
						}
						?>
					</p>
				</div>
			<?php endif; ?>

			<nav class="nav-tab-wrapper">
				<a
					href="<?php echo esc_url( admin_url( 'admin.php?page=kramar-omnibus&tab=sales' ) ); ?>"
					class="nav-tab <?php echo 'sales' === $tab ? 'nav-tab-active' : ''; ?>"
				>
					<?php esc_html_e( 'Active Sales', 'kramar' ); ?>
				</a>
				<a
					href="<?php echo esc_url( admin_url( 'admin.php?page=kramar-omnibus&tab=log' ) ); ?>"
					class="nav-tab <?php echo 'log' === $tab ? 'nav-tab-active' : ''; ?>"
				>
					<?php esc_html_e( 'Log', 'kramar' ); ?>
				</a>
			</nav>

			<?php if ( 'log' === $tab ) : ?>
				<?php $this->render_log_tab(); ?>
			<?php else : ?>
				<?php
				$table = new \Kramar\Omnibus_Table();
				$table->prepare_items();
				?>
				<form method="post">
					<input type="hidden" name="page" value="kramar-omnibus" />
					<?php wp_nonce_field( 'kramar_omnibus_bulk' ); ?>
					<?php $table->display(); ?>
				</form>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Render the log tab — a paginated table of recent log entries.
	 *
	 * @since 1.0.0
	 */
	private function render_log_tab(): void {
		global $wpdb;

		$table    = $wpdb->prefix . 'kramar_omnibus_log';
		$per_page = 50;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$paged  = max( 1, absint( $_GET['paged'] ?? 1 ) );
		$offset = ( $paged - 1 ) * $per_page;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
		$rows  = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d OFFSET %d",
				$per_page,
				$offset
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$rows         = is_array( $rows ) ? $rows : [];
		$total_pages  = (int) ceil( $total / $per_page );

		$action_labels = [
			'price_change'      => __( 'Price changed', 'kramar' ),
			'sale_started'      => __( 'Sale started', 'kramar' ),
			'sale_ended_manual' => __( 'Sale ended (manual)', 'kramar' ),
			'sale_ended_auto'   => __( 'Sale ended (auto)', 'kramar' ),
			'warning_25'        => __( 'Warning: 25 days', 'kramar' ),
			'warning_30'        => __( 'Warning: 30 days', 'kramar' ),
		];

		?>
		<table class="wp-list-table widefat fixed striped">
			<thead>
				<tr>
					<th><?php esc_html_e( 'Date', 'kramar' ); ?></th>
					<th><?php esc_html_e( 'Product', 'kramar' ); ?></th>
					<th><?php esc_html_e( 'Action', 'kramar' ); ?></th>
					<th><?php esc_html_e( 'Price', 'kramar' ); ?></th>
					<th><?php esc_html_e( 'Sale Price', 'kramar' ); ?></th>
					<th><?php esc_html_e( 'User', 'kramar' ); ?></th>
					<th><?php esc_html_e( 'Note', 'kramar' ); ?></th>
				</tr>
			</thead>
			<tbody>
				<?php if ( empty( $rows ) ) : ?>
					<tr>
						<td colspan="7"><?php esc_html_e( 'No log entries yet.', 'kramar' ); ?></td>
					</tr>
				<?php else : ?>
					<?php foreach ( $rows as $row ) : ?>
						<?php
						$product     = wc_get_product( (int) $row['product_id'] );
						$product_name = $product ? $product->get_name() : sprintf( '#%d', (int) $row['product_id'] );
						$edit_url     = $product ? get_edit_post_link( (int) $row['product_id'] ) : '';
						$user         = (int) $row['user_id'] > 0 ? get_userdata( (int) $row['user_id'] ) : false;
						$username     = $user ? $user->display_name : __( 'System', 'kramar' );
						$action_label = $action_labels[ $row['action'] ] ?? esc_html( $row['action'] );
						?>
						<tr>
							<td>
								<?php
								echo esc_html(
									mysql2date(
										get_option( 'date_format' ) . ' ' . get_option( 'time_format' ),
										(string) $row['created_at']
									)
								);
								?>
							</td>
							<td>
								<?php if ( $edit_url ) : ?>
									<a href="<?php echo esc_url( $edit_url ); ?>"><?php echo esc_html( $product_name ); ?></a>
								<?php else : ?>
									<?php echo esc_html( $product_name ); ?>
								<?php endif; ?>
							</td>
							<td><?php echo esc_html( $action_label ); ?></td>
							<td>
								<?php
								if ( null !== $row['price'] && '' !== $row['price'] ) {
									echo wp_kses_post( wc_price( (float) $row['price'] ) );
								} else {
									echo '—';
								}
								?>
							</td>
							<td>
								<?php
								if ( null !== $row['sale_price'] && '' !== $row['sale_price'] ) {
									echo wp_kses_post( wc_price( (float) $row['sale_price'] ) );
								} else {
									echo '—';
								}
								?>
							</td>
							<td><?php echo esc_html( $username ); ?></td>
							<td><?php echo esc_html( (string) $row['note'] ); ?></td>
						</tr>
					<?php endforeach; ?>
				<?php endif; ?>
			</tbody>
		</table>

		<?php if ( $total_pages > 1 ) : ?>
			<div class="tablenav bottom">
				<div class="tablenav-pages">
					<?php
					echo wp_kses_post(
						paginate_links(
							[
								'base'      => add_query_arg( 'paged', '%#%' ),
								'format'    => '',
								'current'   => $paged,
								'total'     => $total_pages,
								'prev_text' => '&laquo;',
								'next_text' => '&raquo;',
							]
						)
					);
					?>
				</div>
			</div>
		<?php endif; ?>
		<?php
	}

	/**
	 * Append the EU Omnibus "lowest price in the last 30 days" line to the
	 * storefront price HTML of on-sale products.
	 *
	 * Opt-in via the "Show the 30-day lowest price on product pages" setting.
	 *
	 * @since 1.0.0
	 *
	 * @param string $price_html Existing price HTML.
	 * @param mixed  $product    Product object (loosely typed — this is a WC filter arg).
	 * @return string
	 */
	public function render_lowest_price_html( string $price_html, $product ): string {
		if ( is_admin() || ! $product instanceof \WC_Product ) {
			return $price_html;
		}

		// Only simple products and single variations carry a single comparable
		// price. A variable parent shows a range, so a single "low" is meaningless.
		if ( ! $product->is_type( [ 'simple', 'variation' ] ) ) {
			return $price_html;
		}

		if ( ! $product->is_on_sale() ) {
			return $price_html;
		}

		$lowest = $this->get_lowest_price_30d( $product );
		if ( null === $lowest ) {
			return $price_html;
		}

		$line = '<span class="kramar-omnibus-lowest" style="display:block;font-size:0.85em;opacity:0.85;margin-top:2px;">'
			. esc_html__( 'Lowest price in the last 30 days:', 'kramar' ) . ' '
			. wp_kses_post( wc_price( $lowest ) )
			. '</span>';

		return $price_html . $line;
	}

	/**
	 * Compute the lowest tracked price of the previous 30 days for a product.
	 *
	 * Reads the recorded `_kramar_price_history` and always considers the current
	 * regular price as a baseline. Corrupt/non-positive entries are ignored.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product or variation.
	 * @return float|null Lowest price, or null when nothing usable is known.
	 */
	private function get_lowest_price_30d( \WC_Product $product ): ?float {
		$prices = [];

		$raw = $product->get_meta( '_kramar_price_history' );
		if ( is_string( $raw ) && '' !== $raw ) {
			$decoded = json_decode( $raw, true );
			if ( is_array( $decoded ) ) {
				$cutoff = gmdate( 'Y-m-d', time() - ( 30 * DAY_IN_SECONDS ) );
				foreach ( $decoded as $entry ) {
					if ( ! is_array( $entry ) || ! isset( $entry['price'], $entry['date'] ) ) {
						continue;
					}
					if ( (string) $entry['date'] < $cutoff ) {
						continue;
					}
					$val = is_numeric( $entry['price'] )
						? (float) $entry['price']
						: (float) wc_format_decimal( (string) $entry['price'] );
					if ( $val > 0 ) {
						$prices[] = $val;
					}
				}
			}
		}

		// The current regular price is the reference the reduction is measured
		// against — always include it so a low is available even without history.
		// WooCommerce stores this as a canonical dot-decimal string.
		$regular_raw = (string) $product->get_regular_price();
		$regular     = is_numeric( $regular_raw ) ? (float) $regular_raw : 0.0;
		if ( $regular > 0 ) {
			$prices[] = $regular;
		}

		// A price that is being charged today was also charged yesterday and every
		// day back to the day it was set, but history records only the day it
		// changed. A sale left running unchanged for more than thirty days — which
		// is a supported setup, since auto-expiry can be turned off — had its own
		// entry age out of the window, and the disclosure fell back to the regular
		// price: "lowest price in the last 30 days: €100,00" printed beside a
		// €60,00 the shopper was paying at that moment. Higher than what is being
		// charged is the one thing this line can never say.
		if ( $product->is_on_sale() ) {
			$sale_raw = (string) $product->get_sale_price();
			$sale     = is_numeric( $sale_raw ) ? (float) $sale_raw : 0.0;
			if ( $sale > 0 ) {
				$prices[] = $sale;
			}
		}

		if ( empty( $prices ) ) {
			return null;
		}

		return min( $prices );
	}

	/**
	 * Track price and sale changes when WooCommerce saves a product.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product  $product       The product being saved.
	 * @param array<string> $updated_props List of updated property names.
	 */
	public function track_price_changes( \WC_Product $product, array $updated_props ): void {
		$product_id = $product->get_id();

		if ( in_array( 'regular_price', $updated_props, true ) ) {
			$price = $product->get_regular_price();
			$this->append_price_history( $product_id, $price );
			$this->log_action( $product_id, 'price_change', $price, null, get_current_user_id() );
		}

		if ( in_array( 'sale_price', $updated_props, true ) ) {
			$sale_price = $product->get_sale_price();

			// A sale price that is not live yet has been charged to nobody. Saving a
			// sale scheduled to begin next week used to write it into the price
			// history dated today; cancel it before it ever runs and it still sat
			// there for thirty days, so the next genuine sale disclosed a "lowest
			// price" no customer was ever offered. The Directive asks for the lowest
			// price actually applied.
			$is_live = '' !== $sale_price && null !== $sale_price && $product->is_on_sale( 'edit' );

			if ( '' !== $sale_price && null !== $sale_price ) {
				// Sale started — record the start time if not already set.
				if ( $is_live && ! $product->get_meta( '_kramar_sale_started' ) ) {
					$product->update_meta_data( '_kramar_sale_started', time() );
					$product->save_meta_data();
				}

				// The sale price belongs in the price history, not just the audit
				// log. The Omnibus Directive requires showing the lowest price
				// actually applied in the preceding 30 days, and a sale price is
				// normally the lowest price there is — recording only regular-price
				// changes made the displayed "lowest price" too high, overstating
				// the discount on the next sale.
				if ( $is_live ) {
					$this->append_price_history( $product_id, $sale_price );
				}

				$this->log_action( $product_id, 'sale_started', $product->get_regular_price(), $sale_price, get_current_user_id() );
			} else {
				// Sale ended manually.
				$product->delete_meta_data( '_kramar_sale_started' );
				$product->save_meta_data();
				$this->log_action( $product_id, 'sale_ended_manual', $product->get_regular_price(), null, get_current_user_id() );
			}
		}
	}

	/**
	 * Append a price entry to the product's 90-day price history meta.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $product_id Product ID.
	 * @param string $price      Regular price value.
	 */
	/**
	 * Whether a price already appears in the product's recorded history.
	 *
	 * @since 1.0.2
	 *
	 * @param \WC_Product $product The product.
	 * @param string      $price   Price to look for.
	 */
	private function history_contains( \WC_Product $product, string $price ): bool {
		$raw = $product->get_meta( '_kramar_price_history' );
		if ( ! is_string( $raw ) || '' === $raw ) {
			return false;
		}

		$decoded = json_decode( $raw, true );
		if ( ! is_array( $decoded ) ) {
			return false;
		}

		foreach ( $decoded as $entry ) {
			if ( isset( $entry['price'] ) && abs( (float) $entry['price'] - (float) $price ) < 0.005 ) {
				return true;
			}
		}

		return false;
	}

	private function append_price_history( int $product_id, string $price ): void {
		$product = wc_get_product( $product_id );
		if ( ! $product ) {
			return;
		}

		// Guard/normalize the recorded price. WooCommerce hands us a canonical
		// dot-decimal string ("27.00"); keep those untouched. Only salvage a
		// non-canonical value (e.g. comma-decimal "27,00") via wc_format_decimal,
		// then reject anything empty or non-positive so junk never enters history.
		$normalized = is_numeric( $price )
			? (string) $price
			: wc_format_decimal( (string) $price );
		if ( '' === $normalized || ! is_numeric( $normalized ) || (float) $normalized <= 0 ) {
			return;
		}

		$raw     = $product->get_meta( '_kramar_price_history' );
		$history = [];

		if ( is_string( $raw ) && '' !== $raw ) {
			$decoded = json_decode( $raw, true );
			if ( is_array( $decoded ) ) {
				$history = $decoded;
			}
		}

		$history[] = [
			'price' => $normalized,
			'date'  => gmdate( 'Y-m-d' ),
		];

		// Prune entries older than 90 days.
		$cutoff  = gmdate( 'Y-m-d', time() - ( 90 * DAY_IN_SECONDS ) );
		$history = array_values(
			array_filter(
				$history,
				static fn( array $entry ): bool => $entry['date'] >= $cutoff
			)
		);

		$product->update_meta_data( '_kramar_price_history', wp_json_encode( $history ) );
		$product->save_meta_data();
	}

	/**
	 * Run the daily sale duration check for all on-sale products.
	 *
	 * @since 1.0.0
	 */
	public function daily_check(): void {
		$products = wc_get_products(
			[
				'status' => 'publish',
				'limit'  => -1,
				'type'   => [ 'simple', 'variable' ],
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				'meta_query' => [
					[
						'key'     => '_sale_price',
						'value'   => '',
						'compare' => '!=',
					],
				],
			]
		);

		foreach ( $products as $product ) {
			if ( $product->is_type( 'variable' ) ) {
				foreach ( $product->get_children() as $variation_id ) {
					$variation = wc_get_product( $variation_id );
					if ( $variation && '' !== $variation->get_sale_price() ) {
						$this->check_sale_duration( $variation );
					}
				}
			} else {
				$this->check_sale_duration( $product );
			}
		}
	}

	/**
	 * Check how long a single product's sale has been active and act accordingly.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product On-sale product or variation.
	 */
	private function check_sale_duration( \WC_Product $product ): void {
		// A sale scheduled for a future date is not written to the price history
		// when it is saved, because nobody has been charged it yet. Record it on
		// the first daily run after it goes live, so a genuinely running sale is
		// still the lowest price of the last thirty days once it ends.
		if ( $product->is_on_sale( 'edit' ) ) {
			$sale_price = (string) $product->get_sale_price();

			if ( '' !== $sale_price && ! $this->history_contains( $product, $sale_price ) ) {
				$this->append_price_history( $product->get_id(), $sale_price );
			}
		}

		$sale_from = $product->get_date_on_sale_from();
		$start     = $sale_from
			? $sale_from->getTimestamp()
			: (int) $product->get_meta( '_kramar_sale_started' );

		if ( ! $start ) {
			// No recorded start: the module was enabled after this sale began,
			// the price was written by an import or by SQL, or something set
			// _sale_price without going through WC_Product::save(). Stamping
			// "now" and returning restarted the clock, so a sale that had
			// already run for months never tripped the 30-day check — exactly
			// the situation the check exists for. Fall back to the product's
			// last modification date, which is the earliest defensible estimate,
			// and record it so the estimate is stable from here on.
			$modified = $product->get_date_modified();
			$start    = $modified instanceof \WC_DateTime ? $modified->getTimestamp() : time();

			$product->update_meta_data( '_kramar_sale_started', $start );
			$product->update_meta_data( '_kramar_sale_start_estimated', 'yes' );
			$product->save_meta_data();
		}

		$days = (int) ( ( time() - $start ) / DAY_IN_SECONDS );

		if ( $days >= 30 && $this->get_setting( 'auto_expire' ) ) {
			$old_sale = $product->get_sale_price();
			$product->set_sale_price( '' );
			$product->set_date_on_sale_from( null );
			$product->set_date_on_sale_to( null );
			$product->delete_meta_data( '_kramar_sale_started' );
			$product->save();
			$this->log_action(
				$product->get_id(),
				'sale_ended_auto',
				$product->get_regular_price(),
				$old_sale,
				0,
				'Auto-expired after 30 days'
			);
			return;
		}

		// Log threshold warnings once per threshold.
		if ( $days >= 30 ) {
			$this->maybe_log_warning( $product, 'warning_30', 30 );
		} elseif ( $days >= 25 ) {
			$this->maybe_log_warning( $product, 'warning_25', 25 );
		}
	}

	/**
	 * Log a warning if it has not already been logged for the current threshold.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product    Product to warn about.
	 * @param string      $action     Log action key ('warning_25' or 'warning_30').
	 * @param int         $threshold  Day threshold (25 or 30).
	 */
	private function maybe_log_warning( \WC_Product $product, string $action, int $threshold ): void {
		global $wpdb;

		$table      = $wpdb->prefix . 'kramar_omnibus_log';
		$product_id = $product->get_id();

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$already = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$table} WHERE product_id = %d AND action = %s AND created_at >= %s",
				$product_id,
				$action,
				gmdate( 'Y-m-d', time() - ( $threshold * DAY_IN_SECONDS ) )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( $already > 0 ) {
			return;
		}

		$this->log_action(
			$product_id,
			$action,
			$product->get_regular_price(),
			$product->get_sale_price(),
			0,
			sprintf(
				/* translators: %d: number of days the sale has been active */
				__( 'Sale has been active for %d+ days', 'kramar' ),
				$threshold
			)
		);
	}

	/**
	 * End the sale for a single product and log the action.
	 *
	 * @since 1.0.0
	 *
	 * @param int $product_id Product ID.
	 */
	private function end_sale( int $product_id ): void {
		$product = wc_get_product( $product_id );
		if ( ! $product ) {
			return;
		}

		$old_sale = $product->get_sale_price();
		$product->set_sale_price( '' );
		$product->set_date_on_sale_from( null );
		$product->set_date_on_sale_to( null );
		$product->delete_meta_data( '_kramar_sale_started' );
		$product->save();

		$this->log_action(
			$product_id,
			'sale_ended_manual',
			$product->get_regular_price(),
			$old_sale,
			get_current_user_id(),
			'Manually ended from Omnibus page'
		);
	}

	/**
	 * Count products with sales active for 25 or more days (shown as admin badge).
	 *
	 * @since 1.0.0
	 *
	 * @return int Number of products needing attention.
	 */
	private function get_attention_count(): int {
		global $wpdb;

		$table  = $wpdb->prefix . 'kramar_omnibus_log';
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 25 * DAY_IN_SECONDS ) );

		// Count distinct products that have warning_25 or warning_30 but no sale_ended_* after the last warning.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$count = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT product_id) FROM {$table}
				WHERE action IN ('warning_25','warning_30')
				AND created_at >= %s",
				$cutoff
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return $count;
	}

	/**
	 * Insert an entry into the omnibus log table.
	 *
	 * @since 1.0.0
	 *
	 * @param int         $product_id Product ID.
	 * @param string      $action     Action key.
	 * @param string|null $price      Regular price.
	 * @param string|null $sale_price Sale price.
	 * @param int         $user_id    User ID (0 = system/cron).
	 * @param string      $note       Optional note.
	 */
	private function log_action(
		int $product_id,
		string $action,
		?string $price,
		?string $sale_price,
		int $user_id,
		string $note = ''
	): void {
		global $wpdb;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to the plugin's own omnibus price-history table.
		$wpdb->insert(
			$wpdb->prefix . 'kramar_omnibus_log',
			[
				'product_id' => $product_id,
				'action'     => $action,
				'price'      => $price,
				'sale_price' => $sale_price,
				'user_id'    => $user_id,
				'note'       => $note,
				'created_at' => current_time( 'mysql', true ),
			]
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Read a setting for this module.
	 *
	 * @since 1.0.0
	 *
	 * @param string $key Setting key.
	 * @return mixed Setting value.
	 */
	private function get_setting( string $key ): mixed {
		return $this->plugin->get_setting( $this->get_category(), $this->get_slug(), $key );
	}
}
