<?php
/**
 * Admin-capability activator.
 *
 * Phase 2 (KI-008) Bootstrap-layer service provider that grants
 * the dedicated `affilun_view_diagnostics` / `affilun_view_logs` /
 * `affilun_run_sync` capability literals to the WordPress
 * `administrator` role on plugin activation. Without this grant
 * the {@see \Affilun\Presentation\Admin\AdminCapability}
 * page-token map would route Diagnostics / Logs / Sync Jobs through
 * a `affilun_*` capability literal that no role carries by default,
 * locking even site administrators out of the page surface.
 *
 * Why grant on activation rather than on every request:
 *
 *   - WordPress persists role / capability assignments inside the
 *     `wp_options` table (`{$prefix}user_roles`). Reading and
 *     mutating that array on every page load would be wasteful and
 *     risk re-applying the grant when an operator deliberately
 *     revoked it.
 *
 *   - Activation runs once per (re)activation, so the grant is a
 *     one-shot operation that stays out of the request hot path.
 *
 *   - Operators that want to grant the same capabilities to other
 *     roles (Editor, Shop Manager, etc.) can do so through Members
 *     / User Role Editor without conflicting with this activator,
 *     because `WP_Role::add_cap()` is idempotent.
 *
 * Capabilities granted (per `specifications/admin-ui-spec.md` §3.3
 * and the {@see \Affilun\Presentation\Admin\AdminCapability}
 * page-token map):
 *
 *   - {@see \Affilun\Presentation\Admin\AdminCapability::CAP_VIEW_DIAGNOSTICS}
 *     — Diagnostics admin page.
 *
 *   - {@see \Affilun\Presentation\Admin\AdminCapability::CAP_VIEW_LOGS}
 *     — Logs admin page.
 *
 *   - {@see \Affilun\Presentation\Admin\AdminCapability::CAP_RUN_SYNC}
 *     — Sync Jobs admin page (and the matching REST `sync.run`
 *     write surface).
 *
 * Deactivation deliberately does NOT revoke the capabilities so a
 * subsequent reactivation does not lose any operator-side
 * customization (for example, additional roles the operator
 * granted the same capability to). The capabilities are removed by
 * the uninstall path through the existing options-table cleanup;
 * stranded `affilun_*` capabilities on the `administrator` role are a
 * harmless residue that no other plugin is likely to consult.
 *
 * Failure semantics:
 *
 *   - When `get_role()` / `WP_Role` is unavailable (pure-unit
 *     test context without WordPress loaded, or a host site that
 *     stripped the role API), the activator silently skips the
 *     grant rather than raising. The Phase 9 OR-list fallback
 *     ({@see \Affilun\Presentation\Admin\AdminCapability::requiredCapabilities()}
 *     accepts `manage_options` as a secondary capability for every
 *     KI-008 page) keeps administrators authorized in that
 *     degraded state.
 *
 *   - The `register()` method intentionally has no work to do: the
 *     activator binds nothing into the registry and exposes no
 *     resolvable services. All work happens through the
 *     `affilun_activate` hook wired in `boot()`.
 *
 * @package Affilun\Bootstrap
 */

declare(strict_types=1);

namespace Affilun\Bootstrap;

use Affilun\Application\Audit\AuditEventDraft;
use Affilun\Application\Audit\AuditEventWriter;
use Affilun\Application\Audit\RepositoryAuditEventWriter;
use Affilun\Infrastructure\Clock\SystemClock;
use Affilun\Infrastructure\Persistence\AuditEventRepository;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Presentation\Admin\AdminCapability;
use RuntimeException;

/**
 * Grants the §3.3 dedicated admin capabilities to the
 * `administrator` role on `affilun_activate`.
 *
 * See the file-level docblock for the capability set, the
 * one-shot activation rationale, and the fail-closed behavior
 * when the WordPress role API is unavailable.
 */
final class AdminCapabilityActivator implements ServiceProvider {

	/**
	 * WordPress role slug whose capability set is augmented on
	 * activation. Held as a class constant so tests and
	 * diagnostics surfaces can reference the exact slug without
	 * duplicating the literal.
	 */
	public const ADMINISTRATOR_ROLE = 'administrator';

	/**
	 * Option marker used to backfill admin capabilities after manual plugin
	 * updates, where WordPress does not run the activation hook.
	 */
	public const CAPABILITY_GRANT_OPTION = 'affilun_admin_capabilities_granted';

	/**
	 * Closed list of `affilun_*` capability literals granted on
	 * activation. Mirrors the §3.3 KI-008 page-token map on
	 * {@see AdminCapability}.
	 *
	 * Held as a class constant so the test suite and any
	 * follow-up "grant the capability set to additional roles"
	 * helper can iterate the same canonical list rather than
	 * re-declaring it.
	 *
	 * @var list<string>
	 */
	public const GRANTED_CAPABILITIES = array(
		AdminCapability::CAP_VIEW_DIAGNOSTICS,
		AdminCapability::CAP_VIEW_LOGS,
		AdminCapability::CAP_RUN_SYNC,
	);

	/**
	 * Optional §10.1 audit-event writer.
	 *
	 * When non-null, a successful capability-grant pass emits one
	 * `security_event` audit row through this writer summarizing
	 * the grant target (`administrator` role) and the granted
	 * capability set. When null (the default) the activator stays
	 * silent — pure-unit tests and bootstrap paths that pre-date
	 * the audit-writer port keep working without modification.
	 *
	 * Per the {@see AuditEventWriter} contract, audit emit
	 * failures are tolerated: a {@see RuntimeException} from
	 * `record()` is swallowed inside {@see self::emitAudit()} so
	 * losing one audit row never masks the underlying capability
	 * grant (which is itself idempotent and observable through
	 * the persisted role definition).
	 */
	private ?AuditEventWriter $auditWriter;

	/**
	 * Construct the activator with an optional audit-event writer.
	 *
	 * Empty / no-arg construction is the production default for
	 * pure-unit tests and any bootstrap path that does not want
	 * activation-time audit emission. Production wiring (the
	 * plugin entry file) passes a {@see RepositoryAuditEventWriter}
	 * built around the persistence-layer audit-event repository so
	 * every successful capability grant lands an audit row in
	 * `affilun_audit_events`.
	 *
	 * @param AuditEventWriter|null $auditWriter Optional §10.1
	 *                                           audit writer; when
	 *                                           null, the activator
	 *                                           does not emit audit
	 *                                           rows.
	 */
	public function __construct( ?AuditEventWriter $auditWriter = null ) {
		$this->auditWriter = $auditWriter;
	}

	/**
	 * No-op binding pass.
	 *
	 * The activator publishes no registry bindings; consumers do
	 * not need to resolve anything from it. The two-phase
	 * lifecycle still requires an implementation here so the
	 * provider can subscribe to `affilun_activate` from
	 * {@see self::boot()} after every other provider's
	 * `register()` has run.
	 *
	 * Soft-resolve the {@see AuditEventRepository} from the
	 * registry and self-construct a {@see RepositoryAuditEventWriter}
	 * when no explicit writer was supplied at construction time.
	 *
	 * Resolution rules:
	 *
	 *   - When the constructor was called with an explicit
	 *     {@see AuditEventWriter} (typical test path), this method
	 *     leaves it untouched.
	 *
	 *   - Otherwise the registry is probed for the
	 *     {@see PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY}
	 *     binding. When present and of the expected type, the
	 *     activator self-wires a {@see RepositoryAuditEventWriter}
	 *     against it (using a fresh {@see SystemClock}). When the
	 *     binding is missing or carries an unexpected value, the
	 *     activator stays silent on the audit channel — pure-unit
	 *     bootstrap paths that only seed a subset of the
	 *     persistence bindings keep working without modification.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		if ( $this->auditWriter !== null ) {
			return;
		}

		$key = PersistenceServiceProvider::BINDING_AUDIT_EVENT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return;
		}

		$events = $registry->get( $key );
		if ( ! $events instanceof AuditEventRepository ) {
			return;
		}

		$this->auditWriter = new RepositoryAuditEventWriter( $events, new SystemClock() );
	}

	/**
	 * Subscribe to `affilun_activate` so the role grant fires once
	 * per (re)activation.
	 *
	 * The hook is wired through {@see HookRegistrar} (rather than
	 * a direct `add_action()` call) so tests and the diagnostics
	 * surface can introspect the registration the same way they
	 * inspect every other AFFILUN hook.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		$hooks->addAction(
			'affilun_activate',
			function (): void {
				$this->onActivate();
			}
		);
		$hooks->addAction(
			'admin_init',
			function (): void {
				$this->onAdminInit();
			},
			1,
			0
		);
	}

	/**
	 * Activation entry point: idempotently add every
	 * {@see self::GRANTED_CAPABILITIES} entry to the
	 * `administrator` role.
	 *
	 * Behavior:
	 *
	 *   - When `get_role()` is not available (typical pure-unit
	 *     test path), the method silently returns without
	 *     touching anything. The Phase 9 OR-list fallback keeps
	 *     administrators authorized in that degraded state.
	 *
	 *   - When `get_role( 'administrator' )` returns null (the
	 *     host site stripped the canonical role for some reason),
	 *     the method also returns silently — there is no role to
	 *     mutate.
	 *
	 *   - Otherwise every capability is added through
	 *     `WP_Role::add_cap()`, which is idempotent: a capability
	 *     the role already carries is left untouched.
	 *
	 * Exposed as a public method so the plugin entry file's
	 * direct activation hook (or a CLI helper) can drive the
	 * grant outside the normal `affilun_activate` dispatch path,
	 * matching the {@see SyncCronServiceProvider::onActivate()}
	 * convention.
	 */
	public function onActivate(): void {
		if ( ! $this->grantCapabilitiesToAdministratorRole() ) {
			return;
		}

		$this->markCapabilitiesGranted();
		$this->emitAudit();
	}

	/**
	 * Backfill capabilities on normal admin loads after manual plugin updates.
	 */
	public function onAdminInit(): void {
		if ( $this->capabilitiesGrantIsCurrent() ) {
			return;
		}

		if ( ! $this->grantCapabilitiesToAdministratorRole() ) {
			return;
		}

		$this->markCapabilitiesGranted();
	}

	/**
	 * Idempotently grant the current AFFILUN capability set to administrators.
	 */
	private function grantCapabilitiesToAdministratorRole(): bool {
		if ( ! function_exists( 'get_role' ) ) {
			// WordPress role API not loaded (pure-unit test path).
			return false;
		}

		$role = get_role( self::ADMINISTRATOR_ROLE );
		if ( $role === null ) {
			// Host site stripped the canonical role; nothing to grant.
			return false;
		}

		if ( ! method_exists( $role, 'add_cap' ) ) {
			// Custom role implementation without the canonical
			// add_cap() surface; defensive guard so an unusual
			// host site cannot crash activation.
			return false;
		}

		foreach ( self::GRANTED_CAPABILITIES as $capability ) {
			// add_cap() is idempotent: capabilities the role
			// already carries are left untouched.
			$role->add_cap( $capability );
		}

		return true;
	}

	/**
	 * Report whether the persisted capability marker matches this build.
	 */
	private function capabilitiesGrantIsCurrent(): bool {
		if ( ! function_exists( 'get_option' ) ) {
			return false;
		}

		return get_option( self::CAPABILITY_GRANT_OPTION, '' ) === self::capabilityGrantMarker();
	}

	/**
	 * Persist the capability marker when WordPress options are available.
	 */
	private function markCapabilitiesGranted(): void {
		if ( ! function_exists( 'update_option' ) ) {
			return;
		}

		update_option( self::CAPABILITY_GRANT_OPTION, self::capabilityGrantMarker() );
	}

	/**
	 * Return a marker that changes when the granted capability set changes.
	 */
	private static function capabilityGrantMarker(): string {
		return 'admin-caps:' . implode( ',', self::GRANTED_CAPABILITIES );
	}

	/**
	 * Emit a `security_event` audit row describing the successful
	 * capability grant.
	 *
	 * No-op when no audit writer was injected at construction
	 * time. Failure to persist the row is swallowed per the
	 * {@see AuditEventWriter} "tolerate audit emit failure"
	 * contract — losing one activation audit row is preferable
	 * to derailing the capability grant the operator just
	 * triggered.
	 *
	 * The emitted draft uses {@see AuditEventDraft::forSystemAction()}
	 * because activation runs without a human actor in scope (the
	 * `affilun_activate` hook fires from `register_activation_hook()`
	 * machinery, not from a logged-in admin's request). The
	 * granted capability list is carried in the changed-fields
	 * payload so the §12.4 envelope projection surfaces exactly
	 * which capability literals were granted to the
	 * `administrator` role.
	 */
	private function emitAudit(): void {
		if ( $this->auditWriter === null ) {
			return;
		}

		try {
			$draft = AuditEventDraft::forSystemAction(
				'security.capabilities_granted',
				'security_event',
				'grant',
				AuditEventDraft::RESULT_SUCCESS,
				'Granted AFFILUN admin capabilities to the administrator role.',
				'role',
				self::ADMINISTRATOR_ROLE,
				array(
					'granted_capabilities' => self::GRANTED_CAPABILITIES,
				),
				array(
					'capability_count' => count( self::GRANTED_CAPABILITIES ),
				)
			);

			$this->auditWriter->record( $draft );
		} catch ( RuntimeException $unused ) {
			// Per the AuditEventWriter contract, the call site
			// log-and-continues on persistence failure; the
			// capability grant has already been applied above so
			// the operator-visible business outcome is preserved.
			unset( $unused );
		}
	}
}
