<?php

declare(strict_types=1);

namespace SiteFort\Modules\Cloud\Service;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use SiteFort\Includes\SettingsStore;

/**
 * Locally cached entitlements, as last reported by Console.
 *
 * Console is the only authority on what this site is entitled to. This class caches its
 * answer and converges local state to it; it never derives expiry or plan itself. A second
 * opinion about when access ends is what let plugin, Console and IntelEngine disagree.
 *
 * Cloud-enforced capabilities (deep-analysis credits, guided repair) are gated server-side as
 * well, so the cache going stale cannot grant real paid capacity. The gates here exist to keep
 * the UI honest and to stop scheduling work the cloud would reject.
 */
class Entitlements {
	private const DEFAULT_FLAGS = [
		'scan.enabled'           => true,
		'scan.on_demand.enabled' => true,
		'scan.scheduled.enabled' => false,
		'repair.enabled'         => false,
	];

	/**
	 * How long a cached entitlement set is trusted without a successful Console check.
	 *
	 * Longer than any plausible WP-Cron outage, comfortably shorter than a billing cycle.
	 * Past this the free defaults apply, which still permit scanning and on-demand scans, so
	 * staleness can only reduce tier and can never produce a hard error for a paying customer.
	 */
	private const STALE_AFTER = 21 * DAY_IN_SECONDS;

	/**
	 * Opportunistic refresh interval. Well inside STALE_AFTER so a site with broken cron is
	 * repaired long before the stale fallback could ever be reached.
	 */
	private const REFRESH_AFTER = DAY_IN_SECONDS;

	private const REFRESH_LOCK = 'sitefort_entitlement_refresh_lock';

	public static function all(): array {
		$settings = CloudSettings::get_all();
		$entitlements = $settings['entitlements'] ?? [];

		if ( ! is_array( $entitlements ) || $entitlements === [] ) {
			return [];
		}

		if ( self::is_stale( $settings ) ) {
			return [];
		}

		return $entitlements;
	}

	/**
	 * Whether the cached entitlements are too old to trust.
	 *
	 * A missing timestamp is treated as fresh and backfilled, never as stale. Existing
	 * installs upgrading to this version have no stamp yet, and failing them closed would
	 * downgrade every live site the moment it updated.
	 */
	private static function is_stale( array $settings ): bool {
		$checked_at = isset( $settings['entitlements_checked_at'] )
			? absint( $settings['entitlements_checked_at'] )
			: 0;

		if ( $checked_at <= 0 ) {
			self::backfill_checked_at();
			return false;
		}

		return ( time() - $checked_at ) > self::STALE_AFTER;
	}

	/**
	 * Stamp existing installs on first read so an upgrade never looks stale.
	 */
	private static function backfill_checked_at(): void {
		if ( ! class_exists( '\\SiteFort\\Modules\\Cloud\\Service\\CloudSettings' ) ) {
			return;
		}
		CloudSettings::update_stored_fields( [ 'entitlements_checked_at' => time() ] );
	}

	/**
	 * Queue a background license check when the cached set is getting old.
	 *
	 * Runs off-request via WP-Cron so an admin page load is never blocked on a network call,
	 * and is rate limited by a transient so a burst of admin requests queues one job.
	 */
	public static function maybe_schedule_refresh(): void {
		$settings = CloudSettings::get_all();
		if ( empty( $settings['site_token'] ) || empty( $settings['site_secret'] ) ) {
			return;
		}

		$checked_at = isset( $settings['entitlements_checked_at'] )
			? absint( $settings['entitlements_checked_at'] )
			: 0;
		if ( $checked_at > 0 && ( time() - $checked_at ) < self::REFRESH_AFTER ) {
			return;
		}

		if ( get_transient( self::REFRESH_LOCK ) ) {
			return;
		}
		set_transient( self::REFRESH_LOCK, 1, HOUR_IN_SECONDS );

		if ( ! wp_next_scheduled( 'sitefort_entitlement_refresh' ) ) {
			wp_schedule_single_event( time(), 'sitefort_entitlement_refresh' );
		}
	}

	public static function isEnabled( string $key ): bool {
		$entitlements = self::all();
		if ( array_key_exists( $key, $entitlements ) ) {
			return self::normalize_bool( $entitlements[ $key ] );
		}

		return self::DEFAULT_FLAGS[ $key ] ?? false;
	}

	public static function allowsScanning(): bool {
		return self::isEnabled( 'scan.enabled' );
	}

	public static function allowsOnDemandScans(): bool {
		return self::allowsScanning() && self::isEnabled( 'scan.on_demand.enabled' );
	}

	public static function allowsScheduledScans(): bool {
		return self::allowsScanning() && self::isEnabled( 'scan.scheduled.enabled' );
	}

	public static function allowsRepair(): bool {
		return self::isEnabled( 'repair.enabled' );
	}

	/**
	 * Converge local state to the current entitlements.
	 *
	 * Idempotent and bidirectional: it suspends what is no longer entitled and restores what
	 * is, so a renewal brings a site back without the customer reconfiguring anything.
	 *
	 * Suspending means stopping the running artifact only. Configuration the customer chose is
	 * never rewritten here, which is what makes restore possible.
	 */
	/**
	 * @param bool $announce Whether a paid -> free move should raise an admin notice. Pass false
	 *                       for a deliberate disconnect: the admin caused it, so telling them
	 *                       paid features were paused and to renew would be misleading.
	 */
	public static function reconcile( bool $announce = true ): void {
		$plan = strtolower( (string) ( CloudSettings::get_all()['license_plan'] ?? 'free' ) );
		$previous_plan = strtolower( (string) SettingsStore::get_field(
			SettingsStore::COL_STATE,
			'reconciled_license_plan',
			''
		) );

		if ( class_exists( '\\SiteFort\\Modules\\Scanner\\Queue\\ScanCron' ) ) {
			// Reconcile the cron event only. The stored schedule is the customer's intent and
			// stays put, so re-entitlement restores their cadence automatically.
			\SiteFort\Modules\Scanner\Queue\ScanCron::reconcile_scheduled_scan(
				null,
				self::allowsScheduledScans(),
				true
			);
		}

		if ( class_exists( '\\SiteFort\\Modules\\WhiteLabel\\WhiteLabel' ) ) {
			\SiteFort\Modules\WhiteLabel\WhiteLabel::sync_for_license_state( CloudSettings::get_all() );
		}

		if ( $previous_plan !== $plan ) {
			self::record_plan_transition( $previous_plan, $plan, $announce );
		}
	}

	/**
	 * Note a plan change so the admin can surface a one-time notice.
	 */
	private static function record_plan_transition( string $previous_plan, string $plan, bool $announce = true ): void {
		$paid = [ 'pro', 'managed' ];
		$was_paid = in_array( $previous_plan, $paid, true );
		$is_paid = in_array( $plan, $paid, true );

		SettingsStore::set_field( SettingsStore::COL_STATE, 'reconciled_license_plan', $plan );

		// Only a real paid -> free move is worth telling the admin about. First-time
		// reconciliation (no previous plan recorded) is not a transition.
		if ( $previous_plan === '' || ! $was_paid || $is_paid || ! $announce ) {
			if ( $is_paid || ! $announce ) {
				SettingsStore::set_field( SettingsStore::COL_STATE, 'plan_suspended_notice_at', 0 );
			}
			return;
		}

		SettingsStore::set_field( SettingsStore::COL_STATE, 'plan_suspended_notice_at', time() );
	}

	private static function normalize_bool( $value ): bool {
		if ( is_bool( $value ) ) {
			return $value;
		}

		if ( is_numeric( $value ) ) {
			return (int) $value === 1;
		}

		if ( is_string( $value ) ) {
			$normalized = strtolower( trim( $value ) );
			if ( in_array( $normalized, [ '1', 'true', 'yes', 'on' ], true ) ) {
				return true;
			}
			if ( in_array( $normalized, [ '0', 'false', 'no', 'off', '' ], true ) ) {
				return false;
			}
		}

		return ! empty( $value );
	}
}
