<?php
/**
 * REST endpoints for plugin settings.
 *
 * @package StoreSheet
 */

declare( strict_types=1 );

namespace StoreSheet\Rest;

defined( 'ABSPATH' ) || exit;

use StoreSheet\Sheets\SpreadsheetManager;
use StoreSheet\Sync\WcToSheets\OrderPusher;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

class SettingsController {

	private const NAMESPACE  = 'storesheet/v1';
	private const KEEP_TOKEN = '__KEEP_EXISTING__';

	public function register_routes(): void {
		register_rest_route(
			self::NAMESPACE,
			'/settings',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_settings' ),
					'permission_callback' => array( $this, 'admin_permission' ),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'save_settings' ),
					'permission_callback' => array( $this, 'admin_permission' ),
				),
			)
		);

		register_rest_route(
			self::NAMESPACE,
			'/sheets/create',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'create_spreadsheet' ),
				'permission_callback' => array( $this, 'admin_permission' ),
			)
		);

		register_rest_route(
			self::NAMESPACE,
			'/sheets/validate',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'validate_spreadsheet' ),
				'permission_callback' => array( $this, 'admin_permission' ),
			)
		);

		register_rest_route(
			self::NAMESPACE,
			'/sheets/tabs',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_tabs_info' ),
				'permission_callback' => array( $this, 'admin_permission' ),
			)
		);
	}

	public function get_settings(): WP_REST_Response {
		$settings = get_option( 'storesheet_settings', array() );
		return new WP_REST_Response( $this->mask_secrets( $settings ), 200 );
	}

	public function save_settings( WP_REST_Request $request ): WP_REST_Response {
		$input    = $request->get_json_params();
		$clean    = $this->sanitize_settings( $input );
		$existing = get_option( 'storesheet_settings', array() );

		/*
		 * Preserve masked values the frontend sent back untouched.
		 *
		 * The client id is included alongside the secrets. It is not a secret,
		 * but it is now displayed masked, so a form submitted without retyping
		 * that field sends the sentinel — and without this it would be
		 * overwritten with the sentinel string, breaking the connection on the
		 * next authorise.
		 */
		$preserve = array_merge( $this->secret_keys(), array( 'oauth_client_id' ) );

		foreach ( $preserve as $key ) {
			if ( ( $clean[ $key ] ?? '' ) === self::KEEP_TOKEN ) {
				$clean[ $key ] = $existing[ $key ] ?? '';
			}
		}

		update_option( 'storesheet_settings', array_merge( $existing, $clean ) );

		// When the per-status feature is switched on, eagerly create the 7
		// status tabs so the merchant sees them immediately. Only on the
		// OFF→ON transition, and only with a spreadsheet configured.
		$was_on = ! empty( $existing['orders_per_status_tabs'] );
		$now_on = ! empty( $clean['orders_per_status_tabs'] );
		if ( $now_on && ! $was_on && ! empty( $existing['spreadsheet_id'] ) ) {
			$ensure = ( new SpreadsheetManager( storesheet()->sheets_client() ) )->ensure_order_status_tabs();
			if ( is_wp_error( $ensure ) ) {
				storesheet()->log( 'Failed to create per-status order tabs on enable: ' . $ensure->get_error_message(), 'warning' );
			}
		}

		// Schedule or tear down the daily digest job on the toggle transition.
		// Guard on array_key_exists so a partial save from another admin page
		// (which never sends this key — patch semantics drop it from $clean)
		// cannot accidentally unschedule an enabled digest.
		if ( array_key_exists( 'enable_daily_digest', $clean ) ) {
			$digest_now = ! empty( $clean['enable_daily_digest'] );
			$digest_was = ! empty( $existing['enable_daily_digest'] );
			if ( $digest_now && ! $digest_was ) {
				\StoreSheet\Queue\SyncQueue::schedule_digest();
			} elseif ( ! $digest_now && $digest_was ) {
				\StoreSheet\Queue\SyncQueue::unschedule_digest();
			}
		}

		// Backfill existing orders' line items when the feature is switched on.
		// array_key_exists guard: a partial save from another admin page must
		// not trigger a resync.
		if ( array_key_exists( 'sync_order_items', $clean ) ) {
			$items_now = ! empty( $clean['sync_order_items'] );
			$items_was = ! empty( $existing['sync_order_items'] );
			if ( $items_now && ! $items_was && function_exists( 'as_enqueue_async_action' ) ) {
				as_enqueue_async_action( 'storesheet_resync_orders_chunk', array( 0 ), 'storesheet' );
			}
		}

		// Reschedule cron if interval changed.
		if ( isset( $clean['sync_interval'] ) ) {
			\StoreSheet\Queue\SyncQueue::unschedule();
			\StoreSheet\Queue\SyncQueue::schedule();
		}

		wp_cache_delete( 'storesheet_overview_stats', 'storesheet' );

		return new WP_REST_Response( array( 'saved' => true ), 200 );
	}

	public function create_spreadsheet( WP_REST_Request $request ): WP_REST_Response {
		// If a link/ID is supplied, adopt that existing spreadsheet instead of
		// creating a new one.
		$existing = trim( (string) ( $request->get_param( 'spreadsheet_url' ) ?? '' ) );
		if ( '' !== $existing ) {
			return $this->use_existing_spreadsheet( $existing );
		}

		$title   = sanitize_text_field( $request->get_param( 'title' ) ?? '' );
		$manager = new SpreadsheetManager( storesheet()->sheets_client() );
		$result  = $manager->create( $title );

		if ( is_wp_error( $result ) ) {
			return new WP_REST_Response( array( 'message' => $result->get_error_message() ), 400 );
		}

		$settings = get_option( 'storesheet_settings', array() );
		return new WP_REST_Response(
			array(
				'spreadsheet_id'  => $result,
				'spreadsheet_url' => $settings['spreadsheet_url'] ?? '',
			),
			200
		);
	}

	/**
	 * Point StoreSheet at an existing spreadsheet the user already owns, adding
	 * any missing tabs. Accepts a full Sheets URL or a bare spreadsheet ID.
	 */
	private function use_existing_spreadsheet( string $input ): WP_REST_Response {
		$id = $this->extract_spreadsheet_id( $input );
		if ( '' === $id ) {
			return new WP_REST_Response(
				array( 'message' => __( 'That does not look like a Google Sheets link or ID.', 'storesheet' ) ),
				400
			);
		}

		// Save the ID first so a freshly built client + manager target it, keeping
		// the prior settings so a bad pick can be rolled back.
		$original = get_option( 'storesheet_settings', array() );
		update_option(
			'storesheet_settings',
			array_merge(
				$original,
				array(
					'spreadsheet_id'  => $id,
					'spreadsheet_url' => 'https://docs.google.com/spreadsheets/d/' . $id . '/edit',
				)
			)
		);

		$manager = new SpreadsheetManager( storesheet()->sheets_client() );
		$result  = $manager->ensure_structure();

		if ( is_wp_error( $result ) ) {
			update_option( 'storesheet_settings', $original ); // roll back.
			return new WP_REST_Response( array( 'message' => $result->get_error_message() ), 400 );
		}

		wp_cache_delete( 'storesheet_overview_stats', 'storesheet' );

		return new WP_REST_Response(
			array(
				'spreadsheet_id'  => $id,
				'spreadsheet_url' => 'https://docs.google.com/spreadsheets/d/' . $id . '/edit',
			),
			200
		);
	}

	/**
	 * Pull a spreadsheet ID out of a full Google Sheets URL, or accept a bare ID.
	 *
	 * @return string Empty string when the input is neither.
	 */
	private function extract_spreadsheet_id( string $input ): string {
		if ( preg_match( '#/spreadsheets/d/([a-zA-Z0-9_-]+)#', $input, $matches ) ) {
			return $matches[1];
		}
		if ( preg_match( '/^[a-zA-Z0-9_-]{20,}$/', $input ) ) {
			return $input;
		}
		return '';
	}

	public function validate_spreadsheet(): WP_REST_Response {
		$manager = new SpreadsheetManager( storesheet()->sheets_client() );
		$result  = $manager->validate();

		if ( is_wp_error( $result ) ) {
			return new WP_REST_Response(
				array(
					'valid'   => false,
					'message' => $result->get_error_message(),
				),
				200
			);
		}

		return new WP_REST_Response( array( 'valid' => true ), 200 );
	}

	public function get_tabs_info(): WP_REST_Response {
		$result = storesheet()->sheets_client()->get_spreadsheet();
		if ( is_wp_error( $result ) ) {
			return new WP_REST_Response(
				array(
					'tabs'  => array(),
					'error' => $result->get_error_message(),
				),
				200
			);
		}

		$tabs = array();
		foreach ( $result['sheets'] ?? array() as $sheet ) {
			$props = $sheet['properties'] ?? array();
			if ( str_starts_with( $props['title'] ?? '', '_' ) ) {
				continue; // skip hidden internal tabs
			}
			$tabs[] = array(
				'title'     => $props['title'] ?? '',
				'sheet_id'  => $props['sheetId'] ?? 0,
				'row_count' => $props['gridProperties']['rowCount'] ?? 0,
				'col_count' => $props['gridProperties']['columnCount'] ?? 0,
				'index'     => $props['index'] ?? 0,
			);
		}

		$settings = get_option( 'storesheet_settings', array() );

		return new WP_REST_Response(
			array(
				'tabs'            => $tabs,
				'spreadsheet_url' => $settings['spreadsheet_url'] ?? '',
				'last_sync'       => get_option( 'storesheet_last_sync', null ),
			),
			200
		);
	}

	public function admin_permission(): bool {
		return current_user_can( 'manage_woocommerce' );
	}

	// -------------------------------------------------------------------------
	// Sanitize
	// -------------------------------------------------------------------------

	/**
	 * Public wrapper around the settings sanitizer.
	 *
	 * Lets other components (e.g. the Pro settings importer) run untrusted
	 * input through the exact same whitelist + type coercion the REST save
	 * uses, so no caller has to re-implement — or drift from — these rules.
	 *
	 * @since 1.1.0
	 * @param array<string,mixed> $input Raw settings input.
	 * @return array<string,mixed> Sanitized settings (only keys present in $input).
	 */
	public function sanitize( array $input ): array {
		return $this->sanitize_settings( $input );
	}

	private function sanitize_settings( array $input ): array {
		$all = array(
			'oauth_client_id'         => sanitize_text_field( $input['oauth_client_id'] ?? '' ),
			'oauth_client_secret'     => sanitize_text_field( $input['oauth_client_secret'] ?? '' ),
			'spreadsheet_id'          => sanitize_text_field( $input['spreadsheet_id'] ?? '' ),
			'sync_direction'          => $this->sanitize_enum( $input['sync_direction'] ?? 'wc_to_sheets', array( 'wc_to_sheets', 'sheets_to_wc', 'both' ) ),
			'sync_interval'           => max( 5, min( 1440, absint( $input['sync_interval'] ?? 15 ) ) ),
			'enable_real_time'        => ! empty( $input['enable_real_time'] ),
			'enable_logging'          => ! empty( $input['enable_logging'] ),
			'enable_daily_digest'     => ! empty( $input['enable_daily_digest'] ),
			'sync_products'           => ! empty( $input['sync_products'] ),
			'sync_variations'         => ! empty( $input['sync_variations'] ),
			'sync_orders'             => ! empty( $input['sync_orders'] ),
			'sync_coupons'            => ! empty( $input['sync_coupons'] ),
			'sync_customers'          => ! empty( $input['sync_customers'] ),
			'sync_product_image'      => ! empty( $input['sync_product_image'] ),
			'sync_product_attributes' => ! empty( $input['sync_product_attributes'] ),
			'order_statuses'          => $this->sanitize_status_list( $input['order_statuses'] ?? array() ),
			'orders_per_status_tabs'  => ! empty( $input['orders_per_status_tabs'] ),
			'sync_order_items'        => ! empty( $input['sync_order_items'] ),
			'conflict_strategy'       => $this->sanitize_enum( $input['conflict_strategy'] ?? 'sheets_wins', array( 'sheets_wins', 'wc_wins', 'newest_wins' ) ),
			'slack_webhook_url'       => esc_url_raw( $input['slack_webhook_url'] ?? '' ),
			'webhook_secret'          => sanitize_text_field( $input['webhook_secret'] ?? '' ),
		);

		// Patch semantics: only return keys the request actually sent, so a
		// per-page save (which posts a subset) never resets the fields it
		// omitted back to their defaults via the array_merge in save_settings().
		$clean = array_intersect_key( $all, $input );

		/**
		 * Filter the sanitized settings before they are persisted, so add-ons
		 * can retain their own keys.
		 *
		 * @since 1.1.0
		 * @param array<string,mixed> $clean Sanitized core keys the request sent.
		 * @param array<string,mixed> $input Raw request input.
		 */
		return apply_filters( 'storesheet_settings_sanitized', $clean, $input );
	}

	/**
	 * @param string   $value
	 * @param string[] $allowed
	 */
	private function sanitize_enum( string $value, array $allowed ): string {
		return in_array( $value, $allowed, true ) ? $value : $allowed[0];
	}

	/**
	 * @param mixed $statuses
	 * @return string[]
	 */
	private function sanitize_status_list( $statuses ): array {
		if ( ! is_array( $statuses ) ) {
			return OrderPusher::DEFAULT_STATUSES;
		}
		$allowed = array( 'pending', 'processing', 'on-hold', 'completed', 'cancelled', 'refunded', 'failed' );
		return array_values( array_intersect( $statuses, $allowed ) );
	}

	private function mask_secrets( array $settings ): array {
		$key_map = array(
			'oauth_client_secret' => 'oauth_client_secret_exists',
			'oauth_refresh_token' => 'oauth_token_exists',
			'webhook_secret'      => 'webhook_secret_exists',
		);

		foreach ( $this->secret_keys() as $key ) {
			$exists_key = $key_map[ $key ] ?? "{$key}_exists";

			if ( ! empty( $settings[ $key ] ) ) {
				$settings[ $key ]        = self::KEEP_TOKEN;
				$settings[ $exists_key ] = true;
			} else {
				$settings[ $exists_key ] = false;
				unset( $settings[ $key ] );
			}
		}

		/*
		 * The client id is partially masked, not replaced wholesale.
		 *
		 * It is not a credential — Google publishes it in the consent URL — so
		 * this is about not putting a full identifier of somebody's Cloud
		 * project into the support screenshots people take of this screen.
		 * Enough is shown to recognise which client is configured.
		 *
		 * The value itself round-trips through the same KEEP_TOKEN sentinel as
		 * the secrets, so saving the form without retyping the field cannot
		 * overwrite the stored id with its own mask. See
		 * Settings\Assets::mask_client_id() for the rendering; both paths use
		 * it so the localized bootstrap and this endpoint cannot disagree.
		 */
		if ( ! empty( $settings['oauth_client_id'] ) ) {
			$settings['oauth_client_id_masked'] = \StoreSheet\Settings\Assets::mask_client_id( (string) $settings['oauth_client_id'] );
			$settings['oauth_client_id_exists'] = true;
			$settings['oauth_client_id']        = self::KEEP_TOKEN;
		} else {
			$settings['oauth_client_id_masked'] = '';
			$settings['oauth_client_id_exists'] = false;
			unset( $settings['oauth_client_id'] );
		}

		return $settings;
	}

	/**
	 * The list of settings keys treated as secrets: masked out of REST/localized
	 * output and preserved (rather than overwritten) when the frontend sends the
	 * `__KEEP_EXISTING__` sentinel on save.
	 *
	 * Filterable so add-ons (e.g. the Pro build's `openai_api_key`) can register
	 * their own secret fields without forking this controller.
	 *
	 * @since 1.2.0
	 * @return string[]
	 */
	private function secret_keys(): array {
		/**
		 * Filter the list of settings keys treated as secrets.
		 *
		 * @since 1.2.0
		 * @param string[] $keys Secret setting keys.
		 */
		return (array) apply_filters(
			'storesheet_secret_keys',
			array(
				'oauth_client_secret',
				'oauth_refresh_token',
				'webhook_secret',
			)
		);
	}
}
