<?php
/**
 * Settings storage, defaults and sanitizing.
 *
 * Each destination type holds a LIST of slots rather than a single config,
 * so a shop can run several sheets or webhooks side by side. Slot 0 is the
 * free one; every slot above it is premium.
 *
 * @package Ordsync
 */

defined( 'ABSPATH' ) || exit;

/**
 * All plugin settings live in one option.
 */
class Ordsync_Settings {

	const OPTION = 'ordsync_settings';

	/**
	 * Two secrets, deliberately.
	 *
	 * SECRET travels inside the Sheets payload, in clear, to every Apps
	 * Script deployment the shop owner creates. It is therefore readable by
	 * anyone they share that sheet or its execution logs with -- a bookkeeper,
	 * an agency, a colleague.
	 *
	 * WEBHOOK_SECRET is the HMAC key that proves a webhook really came from
	 * this store. Receivers act on that proof: an ERP, a fulfilment service,
	 * an accounting system.
	 *
	 * They were once the same value, which let anyone holding the low-trust
	 * one forge the high-trust one. Rotating also collided: fixing a leaked
	 * sheet invalidated every webhook signature at the same time. Keep them
	 * separate, and keep them separately rotatable.
	 */
	const SECRET         = 'ordsync_secret';
	const WEBHOOK_SECRET = 'ordsync_webhook_secret';

	/**
	 * Last outcome per destination, so the settings screen can answer
	 * "is this working?" without querying orders.
	 *
	 * Deriving it from order meta would mean a meta query on every page
	 * load, which is exactly the kind of thing that makes an admin screen
	 * slow on a shop with a lot of orders.
	 */
	const STATUS = 'ordsync_status';

	/**
	 * Trigger value meaning "whatever status it reaches".
	 *
	 * Needed because a shop that pays by bank transfer or invoice never
	 * passes through "processing": those orders go pending → on-hold →
	 * completed, so a plugin that only listens for one status drops them
	 * silently. Orders quietly not arriving is the single worst thing this
	 * plugin can do, so the escape hatch is free, not paid.
	 */
	const TRIGGER_ANY = 'any';

	/**
	 * How much of an error message is worth keeping for the chip.
	 */
	const STATUS_MSG_MAX = 200;

	/**
	 * Hard ceiling on slots per type, so a broken form cannot create
	 * thousands of destinations and stall the queue.
	 */
	const MAX_SLOTS = 20;

	/**
	 * The destination types this plugin knows about.
	 *
	 * @return string[]
	 */
	public static function types() {
		return array( 'sheets', 'webhook', 'email', 'pdf' );
	}

	/**
	 * A single empty slot for one type.
	 *
	 * @param string $type Destination type.
	 * @return array
	 */
	public static function slot_defaults( $type ) {

		$common = array(
			'enabled'         => 'no',
			'label'           => '',
			'trigger'         => 'processing',
			// PREMIUM: empty means no restriction, which is what free does.
			'conditions'      => array(),
			'condition_match' => 'all',
		);

		switch ( $type ) {
			case 'sheets':
			case 'webhook':
				return $common + array( 'url' => '' );

			case 'email':
				return $common + array(
					'to'      => '',
					'subject' => '',
					'body'    => '',
				);

			case 'pdf':
				return array_merge(
					$common,
					array(
						'to'      => 'customer',
						'title'   => 'INVOICE',
						'footer'  => '',
						'trigger' => 'completed',
					)
				);
		}

		return $common;
	}

	/**
	 * Default settings: one empty slot per type.
	 *
	 * @return array
	 */
	public static function defaults() {
		$defaults = array();

		foreach ( self::types() as $type ) {
			$defaults[ $type ] = array( self::slot_defaults( $type ) );
		}

		return $defaults;
	}

	/**
	 * All settings, normalised to the list shape.
	 *
	 * @return array type => list of slots
	 */
	public static function all() {

		$saved = get_option( self::OPTION, array() );

		if ( ! is_array( $saved ) ) {
			$saved = array();
		}

		$settings = array();

		foreach ( self::types() as $type ) {

			$raw   = isset( $saved[ $type ] ) ? $saved[ $type ] : array();
			$slots = self::normalise_slots( $type, $raw );

			$settings[ $type ] = $slots;
		}

		return $settings;
	}

	/**
	 * Turn whatever is stored into a clean list of slots.
	 *
	 * Handles the pre-multi-destination shape, where a type held one flat
	 * config keyed by field name. Those installs must keep working and keep
	 * their settings, so that config becomes slot 0.
	 *
	 * @param string $type Destination type.
	 * @param mixed  $raw  Stored value.
	 * @return array
	 */
	private static function normalise_slots( $type, $raw ) {

		$template = self::slot_defaults( $type );

		if ( ! is_array( $raw ) || empty( $raw ) ) {
			return array( $template );
		}

		// Old shape: a single config, recognisable by its field keys.
		if ( isset( $raw['enabled'] ) || isset( $raw['url'] ) || isset( $raw['to'] ) ) {
			$raw = array( $raw );
		}

		$slots = array();

		foreach ( array_values( $raw ) as $slot ) {

			if ( ! is_array( $slot ) ) {
				continue;
			}

			$merged = array_merge( $template, array_intersect_key( $slot, $template ) );

			if ( ! is_array( $merged['conditions'] ) ) {
				$merged['conditions'] = array();
			}

			$slots[] = $merged;

			if ( count( $slots ) >= self::MAX_SLOTS ) {
				break;
			}
		}

		return empty( $slots ) ? array( $template ) : $slots;
	}

	/**
	 * Every configured slot for one type.
	 *
	 * @param string $type Destination type.
	 * @return array
	 */
	public static function slots( $type ) {
		$all = self::all();
		return isset( $all[ $type ] ) ? $all[ $type ] : array( self::slot_defaults( $type ) );
	}

	/**
	 * One slot.
	 *
	 * @param string $type  Destination type.
	 * @param int    $index Slot index.
	 * @return array
	 */
	public static function get( $type, $index = 0 ) {
		$slots = self::slots( $type );
		return isset( $slots[ $index ] ) ? $slots[ $index ] : self::slot_defaults( $type );
	}

	/**
	 * Save settings.
	 *
	 * @param array $settings Already sanitized.
	 * @return void
	 */
	public static function save( array $settings ) {
		update_option( self::OPTION, $settings, false );
	}

	/**
	 * Clean every submitted slot for one type.
	 *
	 * @param string $type Destination type.
	 * @param array  $raw  Submitted slots for that type.
	 * @return array
	 */
	public static function sanitize_type( $type, array $raw ) {

		if ( ! in_array( $type, self::types(), true ) ) {
			return array();
		}

		// Old single-config shape, in case something posts it.
		if ( isset( $raw['enabled'] ) || isset( $raw['url'] ) || isset( $raw['to'] ) ) {
			$raw = array( $raw );
		}

		$slots = array();

		foreach ( array_values( $raw ) as $slot ) {
			if ( ! is_array( $slot ) ) {
				continue;
			}

			$slots[] = self::sanitize_slot( $type, $slot );

			if ( count( $slots ) >= self::MAX_SLOTS ) {
				break;
			}
		}

		if ( empty( $slots ) ) {
			$slots = array( self::slot_defaults( $type ) );
		}

		return $slots;
	}

	/**
	 * Clean one slot.
	 *
	 * @param string $type Destination type.
	 * @param array  $raw  Submitted values.
	 * @return array
	 */
	public static function sanitize_slot( $type, array $raw ) {

		if ( ! in_array( $type, self::types(), true ) ) {
			return array();
		}

		$clean    = self::slot_defaults( $type );
		// "any" is a legal trigger, so validate against the trigger list.
		$statuses = array_keys( self::trigger_choices() );

		// An unchecked checkbox is simply absent from $_POST.
		$clean['enabled'] = ( isset( $raw['enabled'] ) && 'yes' === $raw['enabled'] ) ? 'yes' : 'no';

		$clean['label'] = isset( $raw['label'] ) ? sanitize_text_field( $raw['label'] ) : '';

		$clean['trigger'] = ( isset( $raw['trigger'] ) && in_array( $raw['trigger'], $statuses, true ) )
			? $raw['trigger']
			: $clean['trigger'];

		$clean['condition_match'] = ( isset( $raw['condition_match'] ) && 'any' === $raw['condition_match'] )
			? 'any'
			: 'all';

		$clean['conditions'] = self::sanitize_conditions(
			isset( $raw['conditions'] ) ? (array) $raw['conditions'] : array()
		);

		switch ( $type ) {

			case 'sheets':
			case 'webhook':
				$clean['url'] = isset( $raw['url'] ) ? esc_url_raw( trim( $raw['url'] ) ) : '';
				break;

			case 'email':
				$clean['to']      = isset( $raw['to'] ) ? sanitize_text_field( $raw['to'] ) : '';
				$clean['subject'] = isset( $raw['subject'] ) ? sanitize_text_field( $raw['subject'] ) : '';
				$clean['body']    = isset( $raw['body'] ) ? wp_kses_post( $raw['body'] ) : '';
				break;

			case 'pdf':
				$clean['to']     = isset( $raw['to'] ) ? sanitize_text_field( $raw['to'] ) : '';
				$clean['title']  = isset( $raw['title'] ) ? sanitize_text_field( $raw['title'] ) : '';
				$clean['footer'] = isset( $raw['footer'] ) ? sanitize_text_field( $raw['footer'] ) : '';
				break;
		}

		return $clean;
	}

	/**
	 * Clean a submitted list of conditional rules.
	 *
	 * Rules whose field or operator is unknown are dropped rather than
	 * stored, so a tampered form can never widen what an automation matches.
	 *
	 * @param array $raw Submitted rules.
	 * @return array
	 */
	public static function sanitize_conditions( array $raw ) {

		if ( ! class_exists( 'Ordsync_Conditions' ) ) {
			// Free build: nothing understands rules, so store none.
			return array();
		}

		$fields    = array_keys( Ordsync_Conditions::fields() );
		$operators = array_keys( Ordsync_Conditions::operators() );
		$clean     = array();

		foreach ( $raw as $rule ) {

			if ( ! is_array( $rule ) ) {
				continue;
			}

			$field    = isset( $rule['field'] ) ? sanitize_key( $rule['field'] ) : '';
			$operator = isset( $rule['operator'] ) ? sanitize_key( $rule['operator'] ) : '';

			if ( ! in_array( $field, $fields, true ) || ! in_array( $operator, $operators, true ) ) {
				continue;
			}

			$clean[] = array(
				'field'    => $field,
				'operator' => $operator,
				'value'    => isset( $rule['value'] ) ? sanitize_text_field( $rule['value'] ) : '',
			);
		}

		return $clean;
	}

	/**
	 * Order statuses a user may trigger on, without the "wc-" prefix.
	 *
	 * @return array
	 */
	public static function order_statuses() {
		$statuses = array();
		foreach ( wc_get_order_statuses() as $key => $label ) {
			$statuses[ str_replace( 'wc-', '', $key ) ] = $label;
		}
		return $statuses;
	}

	/**
	 * What may be chosen as a trigger.
	 *
	 * Separate from order_statuses() because "any" is not a status: the
	 * import filter and the conditional rules both need the real list, and
	 * offering them a pseudo-status would break their queries.
	 *
	 * @return array value => label
	 */
	public static function trigger_choices() {
		return array( self::TRIGGER_ANY => __( 'Any status — sends as soon as the order is created or changes', 'ordercarry-order-automation-for-woocommerce' ) )
			+ self::order_statuses();
	}

	/**
	 * The shared secret the user pastes into their Apps Script.
	 *
	 * Sent in the request body to Google, so treat it as low trust. Never
	 * use it to authenticate anything else -- see the constants above.
	 *
	 * @return string
	 */
	public static function secret() {
		return self::ensure_secret( self::SECRET );
	}

	/**
	 * The key that signs the X-Ordsync-Signature header on webhooks.
	 *
	 * Never leaves this server: only the HMAC of the payload is transmitted,
	 * never the key itself.
	 *
	 * @return string
	 */
	public static function webhook_secret() {
		return self::ensure_secret( self::WEBHOOK_SECRET );
	}

	/**
	 * Create both secrets once, if missing.
	 *
	 * Called on activation, and lazily by the accessors so a site that
	 * upgraded from a build with only one secret still gets a webhook key
	 * without needing a migration routine.
	 *
	 * @return void
	 */
	public static function maybe_create_secret() {
		self::ensure_secret( self::SECRET );
		self::ensure_secret( self::WEBHOOK_SECRET );
	}

	/**
	 * Read one secret, creating it if this site has never had one.
	 *
	 * @param string $option Option name.
	 * @return string
	 */
	private static function ensure_secret( $option ) {
		$value = (string) get_option( $option, '' );

		if ( '' === $value ) {
			$value = wp_generate_password( 32, false, false );
			update_option( $option, $value, false );
		}

		return $value;
	}

	/**
	 * Throw away the Apps Script secret and make a new one.
	 *
	 * Does not touch the webhook key -- a leaked sheet must not force every
	 * webhook receiver to be reconfigured.
	 *
	 * @return string
	 */
	public static function regenerate_secret() {
		$secret = wp_generate_password( 32, false, false );
		update_option( self::SECRET, $secret, false );
		return $secret;
	}

	/**
	 * Last known outcome for one destination.
	 *
	 * @param string $instance_id Destination instance id.
	 * @return array {ok:int, err:int, msg:string}
	 */
	public static function status( $instance_id ) {
		$all = get_option( self::STATUS, array() );

		$row = isset( $all[ $instance_id ] ) && is_array( $all[ $instance_id ] )
			? $all[ $instance_id ]
			: array();

		return array(
			'ok'   => isset( $row['ok'] ) ? (int) $row['ok'] : 0,
			'err'  => isset( $row['err'] ) ? (int) $row['err'] : 0,
			'test' => isset( $row['test'] ) ? (int) $row['test'] : 0,
			'msg'  => isset( $row['msg'] ) ? (string) $row['msg'] : '',
		);
	}

	/**
	 * Which of the four states this destination is in.
	 *
	 * Lives here rather than in the view so the precedence can be tested.
	 * Real orders always outrank a test: once orders are genuinely flowing,
	 * "a test passed once" is the less useful thing to report. The test state
	 * exists only to fill the gap before the first real order, because a
	 * successful test followed by a chip reading "Nothing sent yet" reads as
	 * a contradiction on the one screen where trust is won.
	 *
	 * @param string $instance_id Destination instance id.
	 * @return string error|sent|test|idle
	 */
	public static function status_state( $instance_id ) {
		$s = self::status( $instance_id );

		// Only call it failing if the failure is the most recent thing to happen.
		if ( $s['err'] && $s['err'] >= $s['ok'] && $s['err'] >= $s['test'] ) {
			return 'error';
		}

		if ( $s['ok'] ) {
			return 'sent';
		}

		if ( $s['test'] ) {
			return 'test';
		}

		return 'idle';
	}

	/**
	 * Remember that a send succeeded.
	 *
	 * @param string $instance_id Destination instance id.
	 * @return void
	 */
	public static function record_success( $instance_id ) {
		self::write_status( $instance_id, array( 'ok' => time(), 'msg' => '' ) );
	}

	/**
	 * Remember that a manual test succeeded.
	 *
	 * Deliberately a different field from 'ok': a test proves the connection
	 * works, it does not prove real orders are arriving, and the chip must
	 * not claim the stronger thing.
	 *
	 * @param string $instance_id Destination instance id.
	 * @return void
	 */
	public static function record_test( $instance_id ) {
		self::write_status( $instance_id, array( 'test' => time() ) );
	}

	/**
	 * Remember that a send failed for good.
	 *
	 * @param string $instance_id Destination instance id.
	 * @param string $message     Why.
	 * @return void
	 */
	public static function record_failure( $instance_id, $message ) {
		/*
		 * mb_substr() rather than substr(): error text is often German, and
		 * cutting mid-character would put broken UTF-8 in the tooltip.
		 * WordPress guarantees this function exists -- wp-includes/compat.php
		 * polyfills it when the mbstring extension is missing.
		 */
		self::write_status(
			$instance_id,
			array(
				'err' => time(),
				'msg' => mb_substr( (string) $message, 0, self::STATUS_MSG_MAX ),
			)
		);
	}

	/**
	 * Merge one destination's status back into the option.
	 *
	 * Autoload stays off: this is written on every send, and an autoloaded
	 * option that changes constantly is a cache invalidation problem on
	 * every page of the site, not just the admin.
	 *
	 * @param string $instance_id Destination instance id.
	 * @param array  $patch       Keys to overwrite.
	 * @return void
	 */
	private static function write_status( $instance_id, array $patch ) {
		$all = get_option( self::STATUS, array() );

		if ( ! is_array( $all ) ) {
			$all = array();
		}

		$row = isset( $all[ $instance_id ] ) && is_array( $all[ $instance_id ] )
			? $all[ $instance_id ]
			: array( 'ok' => 0, 'err' => 0, 'test' => 0, 'msg' => '' );

		$all[ $instance_id ] = array_merge( $row, $patch );

		update_option( self::STATUS, $all, false );
	}

	/**
	 * Throw away the webhook signing key and make a new one.
	 *
	 * @return string
	 */
	public static function regenerate_webhook_secret() {
		$secret = wp_generate_password( 32, false, false );
		update_option( self::WEBHOOK_SECRET, $secret, false );
		return $secret;
	}
}
