<?php

declare( strict_types=1 );

namespace Brightfern\Inbound;

use Brightfern\Archive\NullArchive;
use Brightfern\Settings;
use Brightfern\Support\Logger;
use Brightfern\Transmission\ProviderFactory;
use WP_REST_Request;
use WP_REST_Response;

/**
 * Receives inbound documents two ways:
 *   1. A REST webhook the Access Point calls when a document arrives (preferred,
 *      near-real-time). Authenticated with a shared secret.
 *   2. A polling cron fallback for providers/setups without webhooks.
 *
 * Both funnel into the same {@see InboundReceiver}. This is the machinery behind
 * the "also receives" differentiator — compliant-by-default for the Sep 2026
 * "everyone must be able to receive" rule.
 */
final class WebhookController {

	private const REST_NAMESPACE = 'brightfern/v1';
	private const REST_ROUTE     = '/inbound';
	public const CRON_HOOK       = 'brightfern_poll_inbound';

	public function register(): void {
		add_action( 'rest_api_init', array( $this, 'registerRoute' ) );

		// Polling fallback.
		add_action( self::CRON_HOOK, array( $this, 'runPoll' ) );
		add_filter( 'cron_schedules', array( $this, 'addSchedule' ) );
		$this->syncPollingSchedule();
	}

	public function registerRoute(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			self::REST_ROUTE,
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'handle' ),
				'permission_callback' => array( $this, 'authorize' ),
			)
		);
	}

	/**
	 * The bare webhook URL to paste into the provider's dashboard — no secret
	 * embedded in it. Every request through it lands the shared secret in
	 * cleartext wherever that URL passes through a log (the web server's own
	 * access log, a CDN, a log-shipping pipeline) — anyone with log access
	 * recovers the one thing standing between an attacker and injecting forged
	 * supplier invoices into the merchant's inbox. authorize() checks the
	 * X-Brightfern-Secret header first (see {@see webhookUrlWithSecretQueryArg()}
	 * for the fallback), so that is what gets advertised as the primary form.
	 */
	public static function webhookUrl(): string {
		return rest_url( self::REST_NAMESPACE . self::REST_ROUTE );
	}

	/**
	 * The URL-embedded-secret form, for the minority of providers that cannot
	 * send a custom header. Kept working (authorize() still accepts it) but
	 * demoted to an explicit fallback rather than the default advertised value
	 * — see {@see webhookUrl()}.
	 */
	public static function webhookUrlWithSecretQueryArg(): string {
		$secret = Settings::webhookSecret();
		return '' !== $secret ? add_query_arg( 'secret', rawurlencode( $secret ), self::webhookUrl() ) : self::webhookUrl();
	}

	/**
	 * Constant-time shared-secret check. Refuses if no secret is configured —
	 * an unauthenticated inbound endpoint would let anyone inject documents.
	 */
	public function authorize( WP_REST_Request $request ): bool {
		$expected = Settings::webhookSecret();
		if ( '' === $expected ) {
			return false;
		}
		$provided = (string) ( $request->get_header( 'x_brightfern_secret' ) ?: $request->get_param( 'secret' ) );
		return '' !== $provided && hash_equals( $expected, $provided );
	}

	public function handle( WP_REST_Request $request ): WP_REST_Response {
		$payload = $request->get_json_params();
		if ( ! is_array( $payload ) ) {
			$payload = $request->get_params();
		}

		try {
			$id = self::receiver()->ingestWebhook( (array) $payload );
		} catch ( \Throwable $e ) {
			Logger::error( 'Inbound webhook processing failed: ' . $e->getMessage() );
			return new WP_REST_Response( array( 'ok' => false ), 500 );
		}

		// null means storage genuinely failed (already logged inside receive())
		// — a 5xx here so the Access Point's own retry logic gets a chance to
		// succeed later, instead of a 200 telling it the document is safe when
		// it was actually dropped. 200 covers both real success AND a benign
		// duplicate (id === 0): retrying an already-stored document would be
		// pointless, so "accepted, nothing more to do" is the honest answer
		// for that case specifically.
		if ( null === $id ) {
			return new WP_REST_Response( array( 'ok' => false, 'stored' => false ), 500 );
		}

		return new WP_REST_Response( array( 'ok' => true, 'stored' => $id > 0, 'id' => $id ), 200 );
	}

	public function runPoll(): void {
		try {
			$count = self::receiver()->runPoll();
			if ( $count > 0 ) {
				Logger::info( "Polling fallback stored {$count} inbound document(s)." );
			}
		} catch ( \Throwable $e ) {
			Logger::error( 'Inbound polling failed: ' . $e->getMessage() );
		}
	}

	/** Build the receiver from current settings (provider-agnostic). */
	public static function receiver(): InboundReceiver {
		$provider = ProviderFactory::fromSettings( Settings::providerSettings() );
		return new InboundReceiver(
			$provider,
			new UblInboundParser(),
			new OrderMatcher(),
			new NullArchive()
		);
	}

	/**
	 * @param array<string,array<string,mixed>> $schedules
	 * @return array<string,array<string,mixed>>
	 */
	public function addSchedule( array $schedules ): array {
		$schedules['brightfern_fifteen_minutes'] = array(
			'interval' => 15 * MINUTE_IN_SECONDS,
			'display'  => __( 'Every 15 minutes (Brightfern)', 'brightfern-e-invoicing-for-woocommerce' ),
		);
		return $schedules;
	}

	private function syncPollingSchedule(): void {
		$scheduled = wp_next_scheduled( self::CRON_HOOK );
		if ( Settings::pollingEnabled() && ! $scheduled ) {
			wp_schedule_event( time() + MINUTE_IN_SECONDS, 'brightfern_fifteen_minutes', self::CRON_HOOK );
		} elseif ( ! Settings::pollingEnabled() && $scheduled ) {
			wp_unschedule_event( $scheduled, self::CRON_HOOK );
		}
	}
}
