<?php
namespace Pay4All\Pro\License;

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

/**
 * Answers the question « can the merchant use Pro features on this request ? »
 * and orchestrates the SaaS lifecycle : registration, activation-token minting,
 * 48 h periodic re-check.
 *
 * Gating (`is_feature_allowed()`) NEVER calls the network — it only reads the
 * cached option state. The two network entry points are `register_installation()`
 * and `run_periodic_check()`, both driven by explicit admin actions or the
 * WP-Cron event registered in License\Cron.
 *
 * SECURITY NOTE — trial anchor
 * ----------------------------
 * The 30-day trial anchor is authoritative on the SaaS (see the
 * `domains.trial_started_at` column, populated on first /domains/check
 * without a subscription). The plugin caches the signed
 * `trial_started_at` / `trial_ends_at` echoed by /check but never mints
 * its own anchor. Deleting the local wp_options and re-registering the
 * same domain returns the SAME anchor → the trial cannot be re-triggered.
 *
 * Tri-state resolution used by `is_feature_allowed()`, in order :
 *   1. `status ∈ HARD_REJECT_STATUSES` (suspended / expired)   → FALSE (bypass grace)
 *   2. `status ∈ LIVE_STATUSES` (active / trialing / trial / …) → TRUE
 *   3. status empty (never checked) AND local trial anchor exists → TRUE
 *      only for the exact window echoed by the SaaS
 *   4. status unknown AND last check ≤ 72 h ago                → TRUE  (grace window)
 *   5. otherwise                                               → FALSE
 *
 * The old behavior "Store::is_trial_active() short-circuits everything"
 * has been removed : without a signed anchor from the SaaS the plugin
 * refuses Pro features.
 */
final class LicenseManager {

	private ApiClient $api;

	public function __construct( ?ApiClient $api = null ) {
		$this->api = $api ?? new ApiClient();
	}

	public static function instance() : self {
		static $i = null;
		if ( $i === null ) { $i = new self(); }
		return $i;
	}

	public static function is_feature_allowed() : bool {
		$status = Store::status();

		// Hard rejects short-circuit everything (even a cached trial window
		// stays honoured but the SaaS wins if it says "suspended" now).
		if ( in_array( $status, Constants::HARD_REJECT_STATUSES, true ) ) {
			return false;
		}

		// SaaS-confirmed live status (including 'trial' — server-signed).
		if ( in_array( $status, Constants::LIVE_STATUSES, true ) ) {
			// Extra tightening for 'trial': require the local mirror of the
			// server anchor to still be within its 30-day window. The SaaS
			// already denies expired trials but the local check bounds the
			// grace behavior below (a stale "trial" status kept for 72h
			// would otherwise outlive the actual anchor).
			if ( $status === 'trial' ) {
				return Store::is_trial_active();
			}
			return true;
		}

		// No SaaS status yet — brand-new install that hasn't reached
		// pay4all.net. We accept the SaaS-signed trial window if present,
		// nothing else. This is the anti-bypass check : wiping local
		// options resets Store::is_trial_active() to FALSE until the
		// next /check succeeds, which returns the SAME anchor as before.
		if ( Store::is_trial_active() ) {
			return true;
		}

		// Unknown status + last SUCCESSFUL check < 72 h → grace.
		$last_ok = Store::last_check_at();
		if ( $last_ok > 0 && ( time() - $last_ok ) < Constants::GRACE_SECONDS ) {
			return true;
		}
		return false;
	}

	/**
	 * Human-readable status tuple for the Licences admin page. Kept as a
	 * static wrapper so the AdminPage template stays the same.
	 *
	 * @return array{status:string,level:string,message:string,days_left:int}
	 */
	public static function summary() : array {
		$status = Store::status();

		// SaaS-confirmed 'trial' → server-signed 30-day window is still open.
		// Rendered before the generic "active" branch so we can display
		// a days-left countdown drawn from the local mirror of the anchor.
		if ( $status === 'trial' && Store::is_trial_active() ) {
			$days_left = Store::days_left_in_trial();
			return [
				'status'    => 'trial',
				'level'     => 'info',
				'message'   => sprintf(
					/* translators: %d = days remaining in the trial */
					_n( 'Essai gratuit, %d jour restant.', 'Essai gratuit, %d jours restants.', $days_left, 'pay4all-pro' ),
					$days_left
				),
				'days_left' => $days_left,
			];
		}
		// Any other live status (active, trialing, past_due, cancel_at_period_end)
		// means the merchant has passed the trial and is on a paid plan.
		if ( in_array( $status, Constants::LIVE_STATUSES, true ) ) {
			return [
				'status'    => $status,
				'level'     => 'success',
				'message'   => __( 'Licence active.', 'pay4all-pro' ),
				'days_left' => 0,
			];
		}
		if ( in_array( $status, Constants::HARD_REJECT_STATUSES, true ) ) {
			return [
				'status'    => $status,
				'level'     => 'error',
				'message'   => __( 'Licence suspendue ou expirée — activez votre abonnement pour réactiver les fonctions Pro.', 'pay4all-pro' ),
				'days_left' => 0,
			];
		}
		// Empty status : brand-new install that never reached the SaaS.
		// The Pro features stay locked until the first successful /check.
		if ( $status === '' ) {
			return [
				'status'    => 'pending_installation',
				'level'     => 'warning',
				'message'   => __( 'Enregistrement en attente — Pay4All n\'a pas encore été contacté. Ouvrez la page Licences puis « Vérifier maintenant » pour initier l\'essai gratuit.', 'pay4all-pro' ),
				'days_left' => 0,
			];
		}
		// Trial finished (SaaS returned pending_subscription) — surface the
		// actual end date instead of falling back to the grace-window
		// message which is confusing (the SaaS is reachable, the trial
		// just ran out). Pulled from the local mirror of the SaaS-signed
		// trial_ends_at.
		if ( $status === 'pending_subscription' ) {
			$ends = Store::trial_ends_at();
			if ( $ends > 0 ) {
				$formatted = function_exists( 'wp_date' )
					? wp_date( get_option( 'date_format', 'Y-m-d' ), $ends )
					: gmdate( 'Y-m-d', $ends );
				return [
					'status'    => 'pending_subscription',
					'level'     => 'warning',
					'message'   => sprintf(
						/* translators: %s = formatted trial end date */
						__( 'Votre période d\'essai s\'est terminée le %s. Souscrivez à un abonnement pour réactiver les fonctions Pro.', 'pay4all-pro' ),
						$formatted
					),
					'days_left' => 0,
				];
			}
			return [
				'status'    => 'pending_subscription',
				'level'     => 'warning',
				'message'   => __( 'Votre période d\'essai est terminée. Souscrivez à un abonnement pour réactiver les fonctions Pro.', 'pay4all-pro' ),
				'days_left' => 0,
			];
		}
		// Unknown status : advertise the grace window if we're still inside it.
		$last_ok = Store::last_check_at();
		if ( $last_ok > 0 && ( time() - $last_ok ) < Constants::GRACE_SECONDS ) {
			return [
				'status'    => 'grace',
				'level'     => 'warning',
				'message'   => __( 'Pay4All est temporairement injoignable — les fonctions Pro restent actives pendant 72 h.', 'pay4all-pro' ),
				'days_left' => 0,
			];
		}
		return [
			'status'    => 'inactive',
			'level'     => 'error',
			'message'   => __( 'Essai terminé — activez votre licence pour continuer à utiliser les fonctions Pro.', 'pay4all-pro' ),
			'days_left' => 0,
		];
	}

	/**
	 * Localised label for a raw SaaS or local status code.
	 *
	 * The status field is the field the SaaS returns in `/domains/check`
	 * (« active », « trialing », « past_due », …). We want the admin UI to
	 * show these in the merchant's language rather than the raw slug.
	 */
	public static function status_label( string $status ) : string {
		$labels = [
			'active'               => __( 'Active', 'pay4all-pro' ),
			'trialing'             => __( 'Essai en cours', 'pay4all-pro' ),
			'past_due'             => __( 'Paiement en retard', 'pay4all-pro' ),
			'cancel_at_period_end' => __( 'Annulation en fin de période', 'pay4all-pro' ),
			'suspended'            => __( 'Suspendue', 'pay4all-pro' ),
			'expired'              => __( 'Expirée', 'pay4all-pro' ),
			'disabled'             => __( 'Désactivée', 'pay4all-pro' ),
			'trial'                => __( 'Essai gratuit', 'pay4all-pro' ),
			'grace'                => __( 'Grâce (72 h)', 'pay4all-pro' ),
			'inactive'             => __( 'Inactive', 'pay4all-pro' ),
			'unknown'              => __( 'Inconnu', 'pay4all-pro' ),
			'pending_installation' => __( 'En attente d\'activation', 'pay4all-pro' ),
		];
		return $labels[ $status ] ?? $status;
	}

	public function current_domain() : ?string {
		$d = DomainNormalizer::current_domain();
		return $d === '' ? null : $d;
	}

	public function register_installation() : array {
		$domain = $this->current_domain();
		if ( $domain === null ) {
			return [ 'ok' => false, 'error' => __( 'Domaine non détectable.', 'pay4all-pro' ) ];
		}
		$res = $this->api->register( $domain );
		if ( empty( $res['ok'] ) ) {
			Store::set_last_error( '[register] ' . ( $res['error'] ?: 'http ' . $res['status'] ) );
			return $res;
		}
		$body = $res['body'];
		if ( ! empty( $body['installation_secret'] ) ) {
			Store::set_installation_secret( (string) $body['installation_secret'] );
		}
		Store::set_registered_at( (string) ( $body['registered_at'] ?? gmdate( 'c' ) ) );
		Store::set_normalized_domain( $domain );
		Store::del_last_error();
		Store::del_previous_domain();
		return $res;
	}

	public function request_activation_token() : ?string {
		if ( Store::installation_secret() === '' ) {
			$reg = $this->register_installation();
			if ( empty( $reg['ok'] ) ) { return null; }
		}
		$domain = $this->current_domain();
		if ( $domain === null ) { return null; }

		$res = $this->api->activation_token( $domain );
		if ( empty( $res['ok'] ) ) {
			Store::set_last_error( '[token] ' . ( $res['error'] ?: 'http ' . $res['status'] ) );
			return null;
		}
		$url = (string) ( $res['body']['activation_url'] ?? '' );
		return $url === '' ? null : $url;
	}

	/**
	 * Self-service cancellation initiated from the plugin's admin UI.
	 * Wraps the SaaS `/domains/cancel-subscription` call, then refreshes
	 * the local status mirror so the AdminPage reflects the new state
	 * immediately (without waiting for the next 48 h cron tick).
	 *
	 * @return array{ok:bool,error?:string,status?:string,valid_until?:string}
	 */
	public function cancel_subscription() : array {
		$domain = $this->current_domain();
		if ( $domain === null ) {
			return [ 'ok' => false, 'error' => 'no-domain' ];
		}
		if ( Store::installation_secret() === '' ) {
			return [ 'ok' => false, 'error' => 'not-registered' ];
		}

		$res = $this->api->cancel_subscription( $domain );
		if ( empty( $res['ok'] ) ) {
			Store::set_last_error( '[cancel] ' . ( $res['error'] ?: 'http ' . ( $res['status'] ?? '?' ) ) );
			return $res;
		}
		$body   = $res['body']['data'] ?? $res['body'];
		$status = (string) ( $body['status'] ?? 'cancel_at_period_end' );
		Store::set_status( $status );
		Store::set_valid_until( (string) ( $body['valid_until'] ?? '' ) );
		Store::set_last_check_at( time() );
		Store::del_last_error();

		return [
			'ok'          => true,
			'status'      => $status,
			'valid_until' => (string) ( $body['valid_until'] ?? '' ),
		];
	}

	/**
	 * Reverses a self-service cancellation initiated by the merchant.
	 * Same shape as cancel_subscription() — refreshes the local status
	 * mirror so the AdminPage buttons swap back immediately.
	 *
	 * @return array{ok:bool,error?:string,status?:string}
	 */
	public function reactivate_subscription() : array {
		$domain = $this->current_domain();
		if ( $domain === null ) {
			return [ 'ok' => false, 'error' => 'no-domain' ];
		}
		if ( Store::installation_secret() === '' ) {
			return [ 'ok' => false, 'error' => 'not-registered' ];
		}

		$res = $this->api->reactivate_subscription( $domain );

		// Debug trace — only meaningful when WP_DEBUG_LOG is on. Captures
		// the raw SaaS response so a 500 landing here can be diagnosed
		// without shell access to pay4all.net.
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- gated on WP_DEBUG + WP_DEBUG_LOG, dev-only diagnostic for licence reactivation payloads.
			error_log( sprintf(
				'[pay4all-pro] reactivate response: status=%s ok=%s error=%s body=%s',
				(string) ( $res['status'] ?? '?' ),
				! empty( $res['ok'] ) ? 'yes' : 'no',
				(string) ( $res['error'] ?? '' ),
				wp_json_encode( $res['body'] ?? [] )
			) );
		}

		if ( empty( $res['ok'] ) ) {
			Store::set_last_error( '[reactivate] ' . ( $res['error'] ?: 'http ' . ( $res['status'] ?? '?' ) ) );
			return $res;
		}
		$body   = $res['body']['data'] ?? $res['body'];
		$status = (string) ( $body['status'] ?? 'active' );
		Store::set_status( $status );
		Store::set_last_check_at( time() );
		Store::del_last_error();

		return [
			'ok'     => true,
			'status' => $status,
		];
	}

	public function run_periodic_check( bool $manual = false ) : array {
		$domain = $this->current_domain();
		if ( $domain === null ) {
			return [ 'ok' => false, 'error' => 'no-domain' ];
		}
		if ( Store::installation_secret() === '' ) {
			return [ 'ok' => false, 'error' => 'not-registered' ];
		}

		// Detect a domain change since the last successful check so the
		// admin UI can surface the mismatch (and so we don't accidentally
		// claim the old status still applies).
		$previous = Store::normalized_domain();
		if ( $previous !== '' && $previous !== $domain ) {
			Store::set_previous_domain( $previous );
			Store::set_status( 'unknown' );
		}
		Store::set_normalized_domain( $domain );

		$res = $this->api->check( $domain );
		if ( empty( $res['ok'] ) ) {
			Store::set_last_error( '[check] ' . ( $res['error'] ?: 'http ' . $res['status'] ) );
			return $res;
		}
		$body   = $res['body']['data'] ?? $res['body'];
		$status = (string) ( $body['status'] ?? 'unknown' );
		Store::set_status( $status );
		Store::set_valid_until( (string) ( $body['valid_until'] ?? '' ) );
		Store::set_grace_until( (string) ( $body['grace_until'] ?? '' ) );

		// Server-signed trial anchor : persist ONLY when the SaaS returned
		// values, do NOT invent a local anchor. When the SaaS clears them
		// (subscription became active), wipe the local cache so a stale
		// « trial » value can't outlive the paid transition.
		$trial_started = (string) ( $body['trial_started_at'] ?? '' );
		$trial_ends    = (string) ( $body['trial_ends_at']    ?? '' );
		if ( $trial_started !== '' && $trial_ends !== '' ) {
			Store::set_trial_started_at( $trial_started );
			Store::set_trial_ends_at( $trial_ends );
		} else {
			Store::del_trial();
		}

		// Subscription-type mirror : display-only fields the AdminPage
		// uses to show « Mensuel / Annuel / Gratuit à vie » next to the
		// status. Cleared when no paid/lifetime sub is attached so a
		// stale value can't outlive a cancellation → trial fallback.
		$plan_interval    = (string) ( $body['plan_interval']     ?? '' );
		$plan_amount      = isset( $body['plan_amount_cents'] ) ? (int) $body['plan_amount_cents'] : 0;
		$plan_currency    = (string) ( $body['plan_currency']     ?? '' );
		$is_lifetime      = ! empty( $body['is_lifetime'] );
		if ( $plan_interval !== '' || $is_lifetime ) {
			Store::set_plan_interval( $plan_interval );
			Store::set_plan_amount_cents( $plan_amount );
			Store::set_plan_currency( $plan_currency );
			Store::set_is_lifetime( $is_lifetime );
		} else {
			Store::del_plan();
		}

		Store::set_last_check_at( time() );
		Store::del_last_error();

		return [ 'ok' => true, 'status' => $status, 'body' => $body, 'manual' => $manual ];
	}
}
