<?php
/**
 * Postomatic Plugin - Code Manager Class.
 *
 *   PHP Version 5.6
 *
 * @package  Postomatic
 * @category Plugin
 * @license  GPL-2.0-or-later https://www.gnu.org/licenses/gpl-2.0.html
 * @link     https://buildomatics.com/postomatics
 * @since    1.0.0
 * @requires PHP >= 8.0
 */

namespace Postomatic;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Codemanager Class.
 *
 * Manages the storage, validation, and removal of one-time authorization codes.
 *
 * @category Plugin
 * @license  GPL-2.0-or-later https://www.gnu.org/licenses/gpl-2.0.html
 * @link     https://buildomatics.com/postomatics
 * @since    1.0.0
 */
class Codemanager {

	/**
	 * The option name for storing one-time codes.
	 *
	 * @var string
	 */
	private $option_name = Constants::OPTION_API_PER_POST_ONE_TIME_CODE;

	/**
	 * How long a code stays valid, in seconds.
	 *
	 * AutoPilot tasks are created once and then keep calling back for as long as
	 * the schedule runs, so this has to be generous. The point of the expiry is
	 * housekeeping, not short-lived authorisation — the callback is separately
	 * authenticated by the WP API secret in the Authorization-Code header.
	 */
	private const CODE_TTL = 365 * DAY_IN_SECONDS;

	/**
	 * Maximum number of codes kept at once.
	 *
	 * Oldest codes are dropped first once this is exceeded.
	 */
	private const MAX_CODES = 200;

	/**
	 * Add a new code to the storage.
	 *
	 * Codes accumulate rather than replace each other. The previous
	 * implementation stored a single code in the option, so whichever request
	 * generated a code last silently invalidated every code before it. In
	 * practice that meant creating an AutoPilot task and then generating a post
	 * by hand (or two overlapping generations) left the earlier job holding a
	 * code this class no longer recognised, and its callback was rejected with
	 * "The provided code is invalid or has expired." — the post was generated
	 * and paid for remotely but never created in WordPress.
	 *
	 * @param string $new_code The code to store.
	 *
	 * @return void
	 */
	public function add_codes( $new_code ) {
		$code = $this->sanitize_code( $new_code );

		if ( '' === $code ) {
			return;
		}

		$codes = $this->get_codes();

		// Refresh the timestamp when the same code is added again.
		$codes[ $code ] = time();

		$this->save_codes( $codes );
	}

	/**
	 * Check if a code exists and has not expired.
	 *
	 * @param string $code The code to check.
	 *
	 * @return bool
	 */
	public function code_exists( $code ) {
		$candidate = $this->sanitize_code( $code );

		if ( '' === $candidate ) {
			return false;
		}

		foreach ( array_keys( $this->get_codes() ) as $stored_code ) {
			if ( hash_equals( (string) $stored_code, $candidate ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Delete a specific code.
	 *
	 * @param string $code The code to delete.
	 *
	 * @return bool True if deleted, false if not found.
	 */
	public function delete_code( $code ) {
		$candidate = $this->sanitize_code( $code );

		if ( '' === $candidate ) {
			return false;
		}

		$codes = $this->get_codes();

		if ( ! array_key_exists( $candidate, $codes ) ) {
			return false;
		}

		unset( $codes[ $candidate ] );

		if ( empty( $codes ) ) {
			delete_option( $this->option_name );
		} else {
			$this->save_codes( $codes );
		}

		return true;
	}

	/**
	 * Get all stored codes as a map of code => creation timestamp.
	 *
	 * Handles both storage formats that have existed for this option: a bare
	 * string holding one code, and the code => timestamp map written by
	 * save_codes(). Expired entries are filtered out on read so a stale option
	 * can never authorise a callback even if it is never written to again.
	 *
	 * @return array<string, int> Valid codes keyed by code, value is the created timestamp.
	 */
	private function get_codes(): array {
		$stored = get_option( $this->option_name, array() );

		if ( is_string( $stored ) ) {
			$stored = '' === $stored ? array() : array( $stored => time() );
		}

		if ( ! is_array( $stored ) ) {
			return array();
		}

		$now     = time();
		$cutoff  = $now - self::CODE_TTL;
		$cleaned = array();

		foreach ( $stored as $code => $created ) {
			$code = $this->sanitize_code( (string) $code );

			if ( '' === $code ) {
				continue;
			}

			/*
			 * A legacy entry may hold a non-numeric value (the old array format
			 * used the code as both key and value). Treat those as "just
			 * created" so an upgrade never invalidates a code that is still in
			 * flight.
			 */
			$created = is_numeric( $created ) ? (int) $created : $now;

			if ( $created < $cutoff ) {
				continue;
			}

			$cleaned[ $code ] = $created;
		}

		return $cleaned;
	}

	/**
	 * Persist the code map, trimming it to the newest MAX_CODES entries.
	 *
	 * @param array<string, int> $codes Codes keyed by code, value is the created timestamp.
	 *
	 * @return void
	 */
	private function save_codes( array $codes ): void {
		if ( count( $codes ) > self::MAX_CODES ) {
			// Newest first, then keep the head of the list.
			arsort( $codes );
			$codes = array_slice( $codes, 0, self::MAX_CODES, true );
		}

		// Not autoloaded: this can hold a few hundred entries and is only read
		// on the REST callback.
		update_option( $this->option_name, $codes, false );
	}

	/**
	 * Update the codes in database.
	 *
	 * Kept for backwards compatibility with callers that passed either a single
	 * code or a full code map.
	 *
	 * @param mixed $code The code, or a map/list of codes.
	 *
	 * @return void
	 */
	public function update_codes( $code ) {
		if ( is_array( $code ) ) {
			$normalized = array();

			foreach ( $code as $key => $value ) {
				$candidate = $this->sanitize_code( (string) ( is_numeric( $key ) ? $value : $key ) );

				if ( '' !== $candidate ) {
					$normalized[ $candidate ] = is_numeric( $value ) ? (int) $value : time();
				}
			}

			$this->save_codes( $normalized );

			return;
		}

		$this->add_codes( $code );
	}

	/**
	 * Sanitize code input.
	 *
	 * @param string $code The code to sanitize.
	 *
	 * @return string
	 */
	private function sanitize_code( $code ) {
		// Sanitize the code to prevent security issues.
		return trim( sanitize_text_field( (string) $code ) );
	}

	/**
	 * Generate a one-time code using API key and current time.
	 *
	 * @return string Generated one-time code.
	 */
	public function generate_one_time_code(): string {
		// Get the API key.
		$api_key = Utils::get_api_service_key();

		// If API key is not available, generate a secure random key.
		if ( empty( $api_key ) ) {
			$api_key = wp_generate_password( 32, true, true );
		}

		// Get current time as a salt.
		$time_salt = time();

		// Combine API key and time salt.
		$base_string = $api_key . '|' . $time_salt . '|' . wp_rand();

		// Generate a hash.
		$hash = md5( $base_string );

		// Format the hash into a readable code (similar to the JS implementation).
		$parts = array(
			substr( $hash, 0, 8 ),
			substr( $hash, 8, 8 ),
			substr( $hash, 16, 8 ),
			substr( $hash, 24, 8 ),
		);

		return implode( '-', $parts );
	}
}
