<?php
/**
 * Spec for Ability_Registrar.
 *
 * Focused on the two pieces of logic that were ungate'd by direct tests:
 *  - classify_error_result()   — WP_Error code → audit-row result enum.
 *  - wrap_with_logger()        — the try/catch around every write that
 *                                produces one audit row per invocation.
 *
 * The classify_error_result mapping is the activity viewer's category-
 * filter contract; a regression there silently collapses every error back
 * to 'server_error' and nothing else would catch it. Hence the per-mapping
 * assertion below.
 *
 * @package Ppok\AIToolkit\Tests
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Tests;

use Ppok\AIToolkit\Abilities\Ability_Registrar;
use Ppok\AIToolkit\Event_Logger;
use Ppok\AIToolkit\Request_Context;

class Test_Ability_Registrar extends \WP_UnitTestCase {

	protected function setUp(): void {
		parent::setUp();
		Event_Logger::install_schema();
	}

	private function registrar(): Ability_Registrar {
		return new Ability_Registrar( new Event_Logger( new Request_Context() ) );
	}

	private function fetch_last_row(): ?array {
		global $wpdb;
		$row = $wpdb->get_row( 'SELECT * FROM ' . Event_Logger::table_name() . ' ORDER BY id DESC LIMIT 1', ARRAY_A );
		return is_array( $row ) ? $row : null;
	}

	// ---------------------------------------------------------------------
	// classify_error_result — enum mapping
	// ---------------------------------------------------------------------

	/**
	 * @dataProvider classify_provider
	 */
	public function test_classify_maps_known_error_codes( string $error_code, string $expected_result ): void {
		$err = new \WP_Error( $error_code, 'msg' );
		$this->assertSame( $expected_result, Ability_Registrar::classify_error_result( $err ) );
	}

	public function classify_provider(): array {
		return array(
			'rest_forbidden → permission_denied'                  => array( 'rest_forbidden', 'permission_denied' ),
			'rest_cannot_view → permission_denied'                => array( 'rest_cannot_view', 'permission_denied' ),
			'rest_cannot_edit → permission_denied'                => array( 'rest_cannot_edit', 'permission_denied' ),
			'rest_cannot_read → permission_denied'                => array( 'rest_cannot_read', 'permission_denied' ),
			'rest_cannot_create → permission_denied'              => array( 'rest_cannot_create', 'permission_denied' ),
			'rest_cannot_delete → permission_denied'              => array( 'rest_cannot_delete', 'permission_denied' ),
			'ppok_cap_denied → permission_denied'                 => array( 'ppok_cap_denied', 'permission_denied' ),
			'rest_post_invalid_id → not_found'                    => array( 'rest_post_invalid_id', 'not_found' ),
			'rest_post_invalid_page_number → not_found'           => array( 'rest_post_invalid_page_number', 'not_found' ),
			'ppok_post_not_found → not_found'                     => array( 'ppok_post_not_found', 'not_found' ),
			'rest_invalid_param → validation_error'               => array( 'rest_invalid_param', 'validation_error' ),
			'rest_missing_callback_param → validation_error'      => array( 'rest_missing_callback_param', 'validation_error' ),
			'response_too_large → response_too_large'             => array( 'response_too_large', 'response_too_large' ),
			'unknown_block → unknown_block'                       => array( 'unknown_block', 'unknown_block' ),
			'scope_blocked → scope_blocked'                       => array( 'scope_blocked', 'scope_blocked' ),
		);
	}

	public function test_classify_unknown_code_falls_back_to_server_error(): void {
		$err = new \WP_Error( 'some_brand_new_unmapped_error', 'no mapping yet' );
		$this->assertSame( 'server_error', Ability_Registrar::classify_error_result( $err ) );
	}

	public function test_classify_empty_code_falls_back_to_server_error(): void {
		$err = new \WP_Error();
		$this->assertSame( 'server_error', Ability_Registrar::classify_error_result( $err ) );
	}

	// ---------------------------------------------------------------------
	// wrap_with_logger — audit-row side-effects
	// ---------------------------------------------------------------------

	public function test_wrap_records_ok_row_on_successful_callback(): void {
		$registrar = $this->registrar();
		$wrapped   = $registrar->wrap_with_logger(
			'ppok/test-ability',
			static function () {
				return array( 'ok' => true );
			}
		);

		$result = $wrapped( array( 'reason' => 'phpunit ok path' ) );
		$this->assertSame( array( 'ok' => true ), $result );

		$row = $this->fetch_last_row();
		$this->assertSame( 'ppok/test-ability', $row['ability_slug'] );
		$this->assertSame( 'ok', $row['result'] );
		$this->assertNull( $row['error_code'] );
		$this->assertSame( 'phpunit ok path', $row['reason'] );
	}

	public function test_wrap_records_classified_result_on_wp_error_return(): void {
		$registrar = $this->registrar();
		$wrapped   = $registrar->wrap_with_logger(
			'ppok/test-ability',
			static function () {
				return new \WP_Error( 'response_too_large', 'over the cap', array( 'limit_bytes' => 1024 ) );
			}
		);

		$result = $wrapped( array( 'reason' => 'phpunit too large' ) );
		$this->assertInstanceOf( \WP_Error::class, $result );

		$row = $this->fetch_last_row();
		$this->assertSame( 'response_too_large', $row['result'] );
		$this->assertSame( 'response_too_large', $row['error_code'] );
	}

	public function test_wrap_maps_unknown_wp_error_to_server_error_with_original_code(): void {
		$registrar = $this->registrar();
		$wrapped   = $registrar->wrap_with_logger(
			'ppok/test-ability',
			static function () {
				return new \WP_Error( 'some_third_party_thing', 'unmapped' );
			}
		);

		$wrapped( array( 'reason' => 'phpunit unmapped error' ) );
		$row = $this->fetch_last_row();
		$this->assertSame( 'server_error', $row['result'] );
		$this->assertSame( 'some_third_party_thing', $row['error_code'], 'Precise WP_Error code must still survive in error_code column.' );
	}

	public function test_wrap_records_php_exception_then_rethrows(): void {
		$registrar = $this->registrar();
		$wrapped   = $registrar->wrap_with_logger(
			'ppok/test-ability',
			static function () {
				throw new \RuntimeException( 'kaboom' );
			}
		);

		try {
			$wrapped( array( 'reason' => 'phpunit boom' ) );
			$this->fail( 'Expected RuntimeException to propagate after audit row is written.' );
		} catch ( \RuntimeException $caught ) {
			$this->assertSame( 'kaboom', $caught->getMessage() );
		}

		$row = $this->fetch_last_row();
		$this->assertSame( 'server_error', $row['result'] );
		$this->assertSame( 'php_exception', $row['error_code'] );
	}

	public function test_wrap_strips_reason_from_payload_input_args(): void {
		$registrar = $this->registrar();
		$wrapped   = $registrar->wrap_with_logger(
			'ppok/test-ability',
			static function () {
				return array( 'ok' => true );
			}
		);

		$wrapped(
			array(
				'reason'  => 'phpunit reason text',
				'post_id' => 99,
				'extra'   => 'kept',
			)
		);

		$row     = $this->fetch_last_row();
		$payload = json_decode( (string) $row['payload'], true );
		$this->assertArrayHasKey( 'input_args', $payload );
		$this->assertSame( 99, $payload['input_args']['post_id'] );
		$this->assertSame( 'kept', $payload['input_args']['extra'] );
		$this->assertArrayNotHasKey( 'reason', $payload['input_args'], 'reason must live in its own indexed column, not be duplicated into payload.input_args.' );
	}

	public function test_wrap_captures_target_post_id_when_post_id_arg_present(): void {
		$registrar = $this->registrar();
		$wrapped   = $registrar->wrap_with_logger(
			'ppok/test-ability',
			static function () {
				return array( 'ok' => true );
			}
		);

		$wrapped( array( 'reason' => 'phpunit target capture', 'post_id' => 4242 ) );
		$row = $this->fetch_last_row();
		$this->assertSame( 4242, (int) $row['target_post_id'] );
	}

	public function test_wrap_captures_duration_ms_as_non_negative_int(): void {
		$registrar = $this->registrar();
		$wrapped   = $registrar->wrap_with_logger(
			'ppok/test-ability',
			static function () {
				return array( 'ok' => true );
			}
		);

		$wrapped( array( 'reason' => 'phpunit duration probe' ) );
		$row = $this->fetch_last_row();
		$this->assertGreaterThanOrEqual( 0, (int) $row['duration_ms'] );
	}

	// ---------------------------------------------------------------------
	// write_ability_choices — admin UI helper
	// ---------------------------------------------------------------------

	public function test_write_ability_choices_includes_only_write_side_abilities(): void {
		$choices = Ability_Registrar::write_ability_choices();
		// At minimum the writes we ship today must surface; reads must not.
		$this->assertArrayHasKey( 'ppok/rest-call', $choices );
		$this->assertArrayHasKey( 'ppok/set-post-blocks', $choices );
		$this->assertArrayHasKey( 'ppok/replace-in-content', $choices );

		$this->assertArrayNotHasKey( 'ppok/ping', $choices );
		$this->assertArrayNotHasKey( 'ppok/list-posts', $choices );
		$this->assertArrayNotHasKey( 'ppok/get-post-blocks', $choices );
		$this->assertArrayNotHasKey( 'ppok/abilities', $choices );
	}

	public function test_write_ability_choices_values_are_human_readable_labels(): void {
		$choices = Ability_Registrar::write_ability_choices();
		foreach ( $choices as $slug => $label ) {
			$this->assertIsString( $label );
			$this->assertNotSame( $slug, $label, "Choice for {$slug} should be a label, not the slug itself." );
		}
	}
}
