<?php

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

/**
 * Error recovery for the Niwa AI agent loop.
 *
 * PHP equivalent of OpenClaw/Pi's error recovery system.
 * Classifies API and network errors by type and applies appropriate
 * retry strategies with exponential backoff and jitter.  Context-overflow
 * errors are routed to the compaction subsystem rather than retried.
 *
 * @since 1.0.0
 */
class Niwa_AI_Error_Recovery {

	/* ───────────────────────────────────────────────
	 * Error-type constants
	 * ─────────────────────────────────────────────── */

	/** Authentication / authorization failure (invalid key, forbidden). */
	const ERROR_AUTH = 'auth';

	/** Rate-limit hit (HTTP 429 / "too many requests"). */
	const ERROR_RATE_LIMIT = 'rate_limit';

	/** Request or connection timeout. */
	const ERROR_TIMEOUT = 'timeout';

	/** Server overload or temporary unavailability. */
	const ERROR_OVERLOAD = 'overload';

	/** Context window / token limit exceeded. */
	const ERROR_CONTEXT_OVERFLOW = 'context_overflow';

	/** Billing / quota exhausted. */
	const ERROR_BILLING = 'billing';

	/** Network-level failure (DNS, connection refused, etc.). */
	const ERROR_NETWORK = 'network';

	/** Anything that does not match a known pattern. */
	const ERROR_UNKNOWN = 'unknown';

	/** Session expired (e.g., OAuth token expired mid-loop, HTTP 410). */
	const ERROR_SESSION_EXPIRED = 'session_expired';

	/* ───────────────────────────────────────────────
	 * Retry configuration constants
	 * ─────────────────────────────────────────────── */

	/** Maximum number of retry attempts for retryable errors. */
	const MAX_RETRIES = 15;

	/** Base delay in milliseconds before applying exponential growth. */
	const BASE_DELAY_MS = 250;

	/** Absolute upper bound for any single retry delay in milliseconds. */
	const MAX_DELAY_MS = 1500;

	/** Fraction of the computed delay used for random jitter (±30 %). */
	const JITTER_FACTOR = 0.3;

	/** Maximum number of compaction attempts for context-overflow errors. */
	const COMPACTION_MAX_ATTEMPTS = 3;

	/* ───────────────────────────────────────────────
	 * Error classification
	 * ─────────────────────────────────────────────── */

	/**
	 * Classify an error into one of the known error-type constants.
	 *
	 * The method inspects both the HTTP status code (when available) and
	 * the error message string to determine the most specific category.
	 *
	 * @param string $error_message The error message or exception text.
	 * @param int    $http_status   Optional HTTP response status code.
	 *
	 * @return string One of the ERROR_* constants.
	 */
	public function classify_error( $error_message, $http_status = 0 ) {
		$error_message = strtolower( (string) $error_message );
		$http_status   = (int) $http_status;

		// ── Authentication / authorization ──────────────────────────
		if ( in_array( $http_status, array( 401, 403 ), true ) ) {
			return self::ERROR_AUTH;
		}
		if ( preg_match( '/auth|unauthorized|invalid.*key|forbidden/i', $error_message ) ) {
			return self::ERROR_AUTH;
		}
		if ( preg_match( '/billing|quota|insufficient.*funds|insufficient_quota|exceeded.*current.*quota/i', $error_message ) ) {
			return self::ERROR_BILLING;
		}

		// ── Rate limiting ───────────────────────────────────────────
		if ( 429 === $http_status ) {
			return self::ERROR_RATE_LIMIT;
		}
		if ( preg_match( '/rate.*limit|too many requests/i', $error_message ) ) {
			return self::ERROR_RATE_LIMIT;
		}

		// ── Timeout ─────────────────────────────────────────────────
		if ( preg_match( '/timeout|timed out|etimedout/i', $error_message ) ) {
			return self::ERROR_TIMEOUT;
		}

		// ── Server overload ─────────────────────────────────────────
		if ( in_array( $http_status, array( 503, 529 ), true ) ) {
			return self::ERROR_OVERLOAD;
		}
		if ( preg_match( '/overload|capacity|server.*error/i', $error_message ) ) {
			return self::ERROR_OVERLOAD;
		}

		// ── Context overflow ────────────────────────────────────────
		if ( preg_match( '/context.*length|maximum.*context|token.*limit|context.*window/i', $error_message ) ) {
			return self::ERROR_CONTEXT_OVERFLOW;
		}

		// ── Billing / quota ─────────────────────────────────────────
		if ( 402 === $http_status ) {
			return self::ERROR_BILLING;
		}
		if ( preg_match( '/billing|quota|insufficient.*funds/i', $error_message ) ) {
			return self::ERROR_BILLING;
		}

		// ── Session expired ────────────────────────────────────────
		if ( 410 === $http_status ) {
			return self::ERROR_SESSION_EXPIRED;
		}
		if ( preg_match( '/session.*expir|token.*expir|session.*invalid/i', $error_message ) ) {
			return self::ERROR_SESSION_EXPIRED;
		}

		// ── Network ─────────────────────────────────────────────────
		if ( preg_match( '/network|connection|econnrefused|enotfound|stream_error|stream.*fail/i', $error_message ) ) {
			return self::ERROR_NETWORK;
		}

		return self::ERROR_UNKNOWN;
	}

	/* ───────────────────────────────────────────────
	 * Retry logic
	 * ─────────────────────────────────────────────── */

	/**
	 * Determine whether a given error type should be retried.
	 *
	 * Auth, billing, and context-overflow errors are never retried
	 * (context overflow is handled by the compaction subsystem).
	 * Unknown errors are retried at most once.
	 *
	 * @param string $error_type One of the ERROR_* constants.
	 * @param int    $attempt    Zero-based attempt counter (0 = first retry).
	 *
	 * @return bool True if the request should be retried.
	 */
	public function should_retry( $error_type, $attempt ) {
		$attempt = (int) $attempt;

		switch ( $error_type ) {
			case self::ERROR_AUTH:
			case self::ERROR_BILLING:
			case self::ERROR_SESSION_EXPIRED:
			case self::ERROR_CONTEXT_OVERFLOW:
				return false;

			case self::ERROR_RATE_LIMIT:
			case self::ERROR_TIMEOUT:
			case self::ERROR_OVERLOAD:
			case self::ERROR_NETWORK:
				return $attempt < self::MAX_RETRIES;

			case self::ERROR_UNKNOWN:
			default:
				return $attempt < 1;
		}
	}

	/**
	 * Calculate the retry delay in milliseconds for a given attempt.
	 *
	 * Uses exponential backoff capped at MAX_DELAY_MS with random jitter
	 * of ±JITTER_FACTOR.  Rate-limit errors receive a 4x multiplier so
	 * that the client backs off more aggressively.
	 *
	 * @param string $error_type One of the ERROR_* constants.
	 * @param int    $attempt    Zero-based attempt counter.
	 *
	 * @return int Delay in milliseconds (always >= BASE_DELAY_MS).
	 */
	public function get_retry_delay_ms( $error_type, $attempt ) {
		$attempt = (int) $attempt;

		// Exponential base: BASE_DELAY_MS * 2^attempt.
		$base = self::BASE_DELAY_MS * pow( 2, $attempt );

		// Rate-limit errors get a longer cool-down.
		if ( self::ERROR_RATE_LIMIT === $error_type ) {
			$base *= 4;
		}

		// Cap to the configured ceiling.
		$base = min( $base, self::MAX_DELAY_MS );

		// Apply random jitter (±JITTER_FACTOR * base).
		$jitter_range = self::JITTER_FACTOR * $base;
		$jitter       = mt_rand( (int) ( -$jitter_range * 1000 ), (int) ( $jitter_range * 1000 ) ) / 1000;

		$delay = (int) round( $base + $jitter );

		// Never go below the base floor.
		return max( self::BASE_DELAY_MS, $delay );
	}

	/**
	 * Sleep for the computed retry delay, then return the delay used.
	 *
	 * Internally calls `usleep()` so the delay is honoured with
	 * microsecond granularity.
	 *
	 * @param string $error_type One of the ERROR_* constants.
	 * @param int    $attempt    Zero-based attempt counter.
	 *
	 * @return int The actual delay applied, in milliseconds.
	 */
	public function sleep_for_retry( $error_type, $attempt ) {
		$delay_ms = $this->get_retry_delay_ms( $error_type, $attempt );

		usleep( $delay_ms * 1000 );

		return $delay_ms;
	}

	/* ───────────────────────────────────────────────
	 * Compaction helper
	 * ─────────────────────────────────────────────── */

	/**
	 * Check whether the error should trigger context compaction.
	 *
	 * Only context-overflow errors are eligible for compaction; all other
	 * error types should go through the normal retry path.
	 *
	 * @param string $error_type One of the ERROR_* constants.
	 *
	 * @return bool True if compaction should be attempted.
	 */
	public function should_compact( $error_type ) {
		return self::ERROR_CONTEXT_OVERFLOW === $error_type;
	}

	/* ───────────────────────────────────────────────
	 * Logging
	 * ─────────────────────────────────────────────── */

	/**
	 * Format an error for structured log output.
	 *
	 * Returns a single-line string containing the error type, attempt
	 * number, and the original error message — suitable for the Niwa AI
	 * diagnostics log.
	 *
	 * @param string $error_type    One of the ERROR_* constants.
	 * @param string $error_message The original error message.
	 * @param int    $attempt       Zero-based attempt counter.
	 *
	 * @return string Formatted log line.
	 */
	public function format_error_for_log( $error_type, $error_message, $attempt ) {
		$attempt = (int) $attempt;

		return sprintf(
			'[Niwa AI Error Recovery] type=%s | attempt=%d | message=%s',
			$error_type,
			$attempt,
			$error_message
		);
	}

	/* ───────────────────────────────────────────────
	 * Static helpers
	 * ─────────────────────────────────────────────── */

	/**
	 * Check whether an HTTP status code is generally retryable.
	 *
	 * Covers the standard server-error and rate-limit codes that most
	 * LLM API providers may return during transient failures.
	 *
	 * @param int $status HTTP response status code.
	 *
	 * @return bool True if the status code is retryable.
	 */
	public static function is_retryable_http_status( $status ) {
		$status = (int) $status;

		return in_array( $status, array( 429, 500, 502, 503, 504, 529 ), true );
	}
}
