<?php

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

/**
 * Native HS256 authentication for the Niwa MCP endpoint.
 *
 * Each JTI has its own option row, so issuing/revoking one token cannot lose a
 * concurrent update to another token through a shared read-modify-write array.
 */
class Niwa_AI_MCP_JWT_Auth {
	const OPTION_KEY          = 'niwa_ai_mcp_token_registry'; // Legacy registry.
	const TOKEN_PREFIX        = 'niwa_ai_mcp_token_';
	const MIGRATION_COMPLETE  = 'niwa_ai_mcp_token_registry_v2';
	const MIGRATION_LOCK      = 'niwa_ai_mcp_token_registry_migration_lock';
	const AUDIENCE            = 'niwa-ai-mcp';
	const CLOCK_SKEW          = 60;
	const LEGACY_GRACE        = 604800; // Seven days to replace pre-audience tokens.

	private function base64url_encode( $data ) {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}

	private function base64url_decode( $data ) {
		$decoded = base64_decode( strtr( (string) $data, '-_', '+/' ) . str_repeat( '=', ( 4 - strlen( (string) $data ) % 4 ) % 4 ), true );
		return false === $decoded ? '' : $decoded;
	}

	private function issuer() {
		$url = function_exists( 'get_site_url' ) ? get_site_url() : ( function_exists( 'home_url' ) ? home_url( '/' ) : '' );
		return rtrim( (string) $url, '/' );
	}

	/**
	 * @return string|WP_Error
	 */
	private function get_signing_key() {
		$candidates = array();
		if ( defined( 'NIWA_AI_MCP_JWT_SECRET' ) ) {
			$candidates[] = (string) NIWA_AI_MCP_JWT_SECRET;
		}
		if ( defined( 'SECURE_AUTH_KEY' ) ) {
			$candidates[] = (string) SECURE_AUTH_KEY;
		}
		if ( defined( 'AUTH_KEY' ) ) {
			$candidates[] = (string) AUTH_KEY;
		}
		foreach ( $candidates as $candidate ) {
			$candidate = trim( $candidate );
			if ( strlen( $candidate ) >= 32 && false === stripos( $candidate, 'put your unique phrase here' ) ) {
				return $candidate;
			}
		}

		return new WP_Error(
			'niwa_mcp_auth_secret_missing',
			'Define a strong NIWA_AI_MCP_JWT_SECRET, SECURE_AUTH_KEY, or AUTH_KEY before using MCP tokens.'
		);
	}

	private function token_option( $jti ) {
		return self::TOKEN_PREFIX . hash( 'sha256', (string) $jti );
	}

	private function normalize_scopes( $scopes ) {
		$allowed = array( 'read_options' );
		$scopes  = array_map( 'sanitize_key', is_array( $scopes ) ? $scopes : array() );
		return array_values( array_intersect( $allowed, array_unique( $scopes ) ) );
	}

	private function migrate_legacy_registry() {
		if ( get_option( self::MIGRATION_COMPLETE, false ) ) {
			return true;
		}

		$owner = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : bin2hex( random_bytes( 16 ) );
		$lock  = array( 'owner' => $owner, 'expires' => time() + 30 );
		if ( ! add_option( self::MIGRATION_LOCK, $lock, '', false ) ) {
			$existing = get_option( self::MIGRATION_LOCK, array() );
			if ( ! is_array( $existing ) || intval( $existing['expires'] ?? 0 ) >= time() ) {
				return false;
			}
			delete_option( self::MIGRATION_LOCK );
			if ( ! add_option( self::MIGRATION_LOCK, $lock, '', false ) ) {
				return false;
			}
		}

		try {
			$legacy = get_option( self::OPTION_KEY, array() );
			foreach ( is_array( $legacy ) ? $legacy : array() as $jti => $data ) {
				if ( '' === (string) $jti || ! is_array( $data ) ) {
					continue;
				}
				$exp          = intval( $data['expires_at'] ?? 0 );
				$legacy_until = time() + self::LEGACY_GRACE;
				if ( $exp > 0 ) {
					$legacy_until = min( $legacy_until, $exp );
				}
				$data['jti']          = (string) $jti;
				$data['legacy']       = true;
				$data['legacy_until'] = $legacy_until;
				add_option( $this->token_option( $jti ), $data, '', false );
			}
			update_option( self::MIGRATION_COMPLETE, 1, false );
			delete_option( self::OPTION_KEY );
		} finally {
			$current = get_option( self::MIGRATION_LOCK, array() );
			if ( is_array( $current ) && hash_equals( (string) ( $current['owner'] ?? '' ), $owner ) ) {
				delete_option( self::MIGRATION_LOCK );
			}
		}

		return true;
	}

	private function get_token_entry( $jti ) {
		if ( ! $this->migrate_legacy_registry() ) {
			return array();
		}
		$entry = get_option( $this->token_option( $jti ), array() );
		return is_array( $entry ) ? $entry : array();
	}

	/**
	 * @return array|WP_Error
	 */
	public function generate_token( $user_id, $duration = '1_day', $scopes = array() ) {
		$key = $this->get_signing_key();
		if ( is_wp_error( $key ) ) {
			return $key;
		}
		if ( ! $this->migrate_legacy_registry() ) {
			return new WP_Error( 'niwa_mcp_auth_registry_busy', 'Token registry migration is in progress; retry shortly.' );
		}

		$durations   = array( '1_day' => 86400, '1_month' => 2592000, '1_year' => 31536000, 'never' => 0 );
		$duration    = isset( $durations[ $duration ] ) ? $duration : '1_day';
		$exp_seconds = $durations[ $duration ];
		$now         = time();
		$jti         = wp_generate_uuid4();
		$exp         = $exp_seconds > 0 ? $now + $exp_seconds : 0;
		$scopes      = $this->normalize_scopes( $scopes );
		$header      = $this->base64url_encode( wp_json_encode( array( 'alg' => 'HS256', 'typ' => 'JWT' ) ) );
		$claims      = array(
			'iss' => $this->issuer(),
			'aud' => self::AUDIENCE,
			'sub' => intval( $user_id ),
			'iat' => $now,
			'exp' => $exp,
			'jti' => $jti,
			'scp' => $scopes,
		);
		$payload   = $this->base64url_encode( wp_json_encode( $claims ) );
		$signature = $this->base64url_encode( hash_hmac( 'sha256', $header . '.' . $payload, $key, true ) );
		$entry     = array(
			'jti'        => $jti,
			'user_id'    => intval( $user_id ),
			'created_at' => $now,
			'expires_at' => $exp,
			'duration'   => $duration,
			'scopes'     => $scopes,
		);
		if ( ! add_option( $this->token_option( $jti ), $entry, '', false ) ) {
			return new WP_Error( 'niwa_mcp_auth_registry_write', 'Could not register MCP token.' );
		}

		return array(
			'token'      => $header . '.' . $payload . '.' . $signature,
			'jti'        => $jti,
			'user_id'    => intval( $user_id ),
			'expires_at' => $exp,
			'duration'   => $duration,
			'scopes'     => $scopes,
		);
	}

	/**
	 * @return array|WP_Error
	 */
	public function verify_token( $token ) {
		$key = $this->get_signing_key();
		if ( is_wp_error( $key ) ) {
			return $key;
		}
		$parts = explode( '.', (string) $token );
		if ( 3 !== count( $parts ) ) {
			return new WP_Error( 'niwa_mcp_auth_malformed', 'Malformed JWT token.' );
		}
		list( $header_b64, $payload_b64, $sig_b64 ) = $parts;
		$expected = $this->base64url_encode( hash_hmac( 'sha256', $header_b64 . '.' . $payload_b64, $key, true ) );
		if ( ! hash_equals( $expected, (string) $sig_b64 ) ) {
			return new WP_Error( 'niwa_mcp_auth_invalid_signature', 'Invalid token signature.' );
		}

		$header  = json_decode( $this->base64url_decode( $header_b64 ), true );
		$payload = json_decode( $this->base64url_decode( $payload_b64 ), true );
		if ( ! is_array( $header ) || 'HS256' !== (string) ( $header['alg'] ?? '' ) || 'JWT' !== (string) ( $header['typ'] ?? '' ) ) {
			return new WP_Error( 'niwa_mcp_auth_invalid_header', 'Unsupported JWT header.' );
		}
		if ( ! is_array( $payload ) ) {
			return new WP_Error( 'niwa_mcp_auth_invalid_payload', 'Invalid token payload.' );
		}

		$jti   = trim( (string) ( $payload['jti'] ?? '' ) );
		$entry = '' !== $jti ? $this->get_token_entry( $jti ) : array();
		if ( empty( $entry ) ) {
			return new WP_Error( 'niwa_mcp_auth_revoked', 'Token has been revoked or is unknown.' );
		}

		$now = time();
		$iss = rtrim( (string) ( $payload['iss'] ?? '' ), '/' );
		$aud = (string) ( $payload['aud'] ?? '' );
		$iat = intval( $payload['iat'] ?? 0 );
		$exp = intval( $payload['exp'] ?? 0 );
		$scopes = $this->normalize_scopes( $payload['scp'] ?? array() );
		$registered_scopes = $this->normalize_scopes( $entry['scopes'] ?? array() );
		if ( '' === $iss || ! hash_equals( $this->issuer(), $iss ) ) {
			return new WP_Error( 'niwa_mcp_auth_issuer', 'Token issuer mismatch.' );
		}
		$legacy_audience = ! empty( $entry['legacy'] ) && $now <= intval( $entry['legacy_until'] ?? 0 ) && '' === $aud;
		if ( ! $legacy_audience && ! hash_equals( self::AUDIENCE, $aud ) ) {
			return new WP_Error( 'niwa_mcp_auth_audience', 'Token audience mismatch.' );
		}
		if ( $iat < 1 || $iat > $now + self::CLOCK_SKEW ) {
			return new WP_Error( 'niwa_mcp_auth_iat', 'Token issued-at time is invalid.' );
		}
		if ( $exp > 0 && $exp < $now ) {
			return new WP_Error( 'niwa_mcp_auth_expired', 'Token has expired.' );
		}
		if ( ! empty( array_diff( $scopes, $registered_scopes ) ) ) {
			return new WP_Error( 'niwa_mcp_auth_scope', 'Token scope is not registered.' );
		}

		$user_id = intval( $payload['sub'] ?? 0 );
		if ( $user_id < 1 || intval( $entry['user_id'] ?? 0 ) !== $user_id ) {
			return new WP_Error( 'niwa_mcp_auth_subject_mismatch', 'Token subject does not match its registry entry.' );
		}
		$user = get_user_by( 'id', $user_id );
		if ( ! $user ) {
			return new WP_Error( 'niwa_mcp_auth_user_gone', 'User no longer exists.' );
		}
		if ( intval( $user->user_status ?? 0 ) !== 0 || ! empty( $user->spam ) || ! empty( $user->deleted ) ) {
			return new WP_Error( 'niwa_mcp_auth_user_inactive', 'User account is inactive.' );
		}
		if ( ! user_can( $user, 'manage_options' ) ) {
			return new WP_Error( 'niwa_mcp_auth_forbidden', 'User no longer has permission to use MCP.' );
		}

		return array( 'user_id' => $user_id, 'jti' => $jti, 'exp' => $exp, 'iss' => $iss, 'aud' => self::AUDIENCE, 'iat' => $iat, 'scopes' => $scopes );
	}

	public function revoke_token( $jti ) {
		$jti = trim( (string) $jti );
		if ( '' === $jti || ! $this->migrate_legacy_registry() || empty( $this->get_token_entry( $jti ) ) ) {
			return false;
		}
		return (bool) delete_option( $this->token_option( $jti ) );
	}

	private function all_entries() {
		$this->migrate_legacy_registry();
		$entries = array();
		global $wpdb;
		if ( isset( $wpdb ) && is_object( $wpdb ) && ! empty( $wpdb->options ) && method_exists( $wpdb, 'get_results' ) ) {
			$like = method_exists( $wpdb, 'esc_like' ) ? $wpdb->esc_like( self::TOKEN_PREFIX ) . '%' : self::TOKEN_PREFIX . '%';
			$sql  = method_exists( $wpdb, 'prepare' )
				? $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $like )
				: '';
			$rows = '' !== $sql ? $wpdb->get_results( $sql, ARRAY_A ) : array();
			foreach ( is_array( $rows ) ? $rows : array() as $row ) {
				$data = function_exists( 'maybe_unserialize' ) ? maybe_unserialize( $row['option_value'] ?? '' ) : array();
				if ( is_array( $data ) && ! empty( $data['jti'] ) ) {
					$entries[] = $data;
				}
			}
			return $entries;
		}

		// Test/non-WordPress fallback; production enumerates the option table.
		foreach ( (array) ( $GLOBALS['niwa_test_options'] ?? array() ) as $name => $data ) {
			if ( 0 === strpos( (string) $name, self::TOKEN_PREFIX ) && is_array( $data ) && ! empty( $data['jti'] ) ) {
				$entries[] = $data;
			}
		}
		return $entries;
	}

	public function list_tokens() {
		$this->cleanup_expired();
		$tokens = array();
		foreach ( $this->all_entries() as $data ) {
			$user_id  = intval( $data['user_id'] ?? 0 );
			$user     = get_user_by( 'id', $user_id );
			$tokens[] = array(
				'jti'        => (string) ( $data['jti'] ?? '' ),
				'user_id'    => $user_id,
				'user_login' => $user ? $user->user_login : 'unknown',
				'created_at' => intval( $data['created_at'] ?? 0 ),
				'expires_at' => intval( $data['expires_at'] ?? 0 ),
				'duration'   => (string) ( $data['duration'] ?? '1_day' ),
				'legacy'     => ! empty( $data['legacy'] ),
				'scopes'     => $this->normalize_scopes( $data['scopes'] ?? array() ),
			);
		}
		return $tokens;
	}

	public function cleanup_expired() {
		$now = time();
		foreach ( $this->all_entries() as $data ) {
			$exp          = intval( $data['expires_at'] ?? 0 );
			$legacy_until = intval( $data['legacy_until'] ?? 0 );
			if ( ( $exp > 0 && $exp < $now ) || ( ! empty( $data['legacy'] ) && $legacy_until < $now ) ) {
				$this->revoke_token( (string) ( $data['jti'] ?? '' ) );
			}
		}
	}
}
