<?php
/**
 * Audit retention purge worker.
 *
 * Phase 2 (KI-006 follow-up) Application-layer service that
 * drives the §10.4 `affilun_audit_events` retention sweep. The
 * purger is a single-tick driver (NOT a long-running process):
 * one call to {@see self::purge()} deletes every row whose
 * `created_at` is strictly older than the
 * {@see AuditRetentionPolicy}-derived cutoff and returns an
 * {@see AuditRetentionPurgeResult} summary the surrounding
 * scheduler (WP-Cron, CLI, integration test) folds into its
 * own observability surface.
 *
 * Responsibilities:
 *
 *   1. Stamp a single UTC "now" instant from the injected
 *      {@see Clock} at the top of the tick. The same instant is
 *      forwarded to {@see AuditRetentionPolicy::cutoffFrom()}
 *      (so the cutoff arithmetic stays reconstructable from the
 *      result VO) and to {@see AuditEventDraft::forSystemAction()}
 *      via the writer's own clock — there is no second clock
 *      read anywhere on the purge path.
 *
 *   2. Compute the strict-less-than cutoff via
 *      {@see AuditRetentionPolicy::cutoffFrom()}. The policy
 *      enforces the §10.4 "180 日〜365 日以上" guidance and the
 *      30-day floor at construction time, so the cutoff this
 *      worker hands to the persistence layer is always within
 *      the sanctioned range.
 *
 *   3. Call
 *      {@see \Affilun\Infrastructure\Persistence\AuditEventRepository::deleteOlderThan()}
 *      with the cutoff. The persistence layer issues a single
 *      `DELETE FROM affilun_audit_events WHERE created_at < %s`
 *      query (Wpdb-backed) or an in-memory equivalent and
 *      returns the affected-row count.
 *
 *   4. Build an {@see AuditRetentionPurgeResult} carrying the
 *      tick-start instant, the cutoff, the policy snapshot, and
 *      the deleted count.
 *
 *   5. Emit one `compliance_relevant_action` audit row through
 *      the optional {@see AuditEventWriter} so the §10.4
 *      "purge 実行自体も audit または operational event として
 *      記録してよい" rule is honored. The audit row is the only
 *      durable record of a purge — operators reviewing the
 *      audit trail months later see exactly which retention
 *      window was in force on which day, what cutoff that
 *      produced, and how many rows the sweep removed.
 *
 * Design notes:
 *
 *   - The {@see AuditEventWriter} is optional. When null the
 *     purger still performs the deletion and returns the
 *     summary VO, but does not emit the meta-audit row. This
 *     keeps pure-unit tests and bootstrap paths that do not
 *     yet bind a writer working without modification, and
 *     mirrors the same "optional audit writer + try/catch
 *     RuntimeException swallow" pattern every other Phase 2
 *     KI-006 follow-up call site established (settings save,
 *     schema migration, capability grant, sync worker
 *     terminal).
 *
 *   - Audit emit failures are swallowed inside
 *     {@see self::emitPurgeAudit()} per the
 *     {@see AuditEventWriter} "tolerate audit emit failure"
 *     contract — losing one meta-audit row is preferable to
 *     derailing a successful purge sweep. The deletion has
 *     already happened by the time the writer is invoked, so
 *     the operator-visible outcome (rows are gone, summary VO
 *     is returned) is preserved.
 *
 *   - Persistence-layer failures from
 *     {@see AuditEventRepository::deleteOlderThan()} bubble out
 *     unchanged. The surrounding cron action handler in
 *     {@see \Affilun\Bootstrap\SyncCronServiceProvider}
 *     wraps the purge call in `try` / `catch Throwable` and
 *     logs to `error_log()`; the audit row recording the
 *     attempted purge is NOT emitted in that case because the
 *     deletion may have partially succeeded and the row count
 *     would be inaccurate.
 *
 *   - The audit row uses
 *     {@see AuditEventDraft::forSystemAction()} because the
 *     purge runs without a human actor (a daily WP-Cron event,
 *     not an admin-triggered action). The `audit_kind` is
 *     `compliance_relevant_action` rather than
 *     `operational_action` because retention purges are exactly
 *     the kind of compliance-adjacent state mutation §10.1 /
 *     §10.4 are intended to track.
 *
 *   - The worker emits the meta-audit row even on a "deleted =
 *     0" idle tick. The §10.4 audit trail is the canonical
 *     evidence that the retention policy actually ran on a
 *     given day; an idle tick without an audit row is
 *     indistinguishable from "the cron event never fired" when
 *     reviewing the trail months later, which defeats the
 *     observability purpose. The summary card the §10 admin
 *     page renders can elide idle-tick rows visually if needed,
 *     but the underlying evidence stays complete.
 *
 * @package Affilun\Application\Audit
 */

declare(strict_types=1);

namespace Affilun\Application\Audit;

use Affilun\Domain\Clock;
use Affilun\Infrastructure\Persistence\AuditEventRepository;
use DateTimeImmutable;
use DateTimeZone;
use RuntimeException;

/**
 * Single-tick §10.4 audit-events retention purge driver.
 *
 * See the file-level docblock for the per-tick stamp → cutoff →
 * delete → summarize → emit-meta-audit-row flow, the optional-
 * writer / failure-tolerance contract, and the rationale for
 * emitting the meta-audit row even on an idle tick.
 */
final class AuditRetentionPurger {

	/**
	 * `event_type` token stamped on the meta-audit row that
	 * records every purge sweep.
	 *
	 * Held as a class constant so the test suite, the §10 admin
	 * page filter dropdowns, and any future diagnostics surface
	 * can reference the exact value without duplicating the
	 * literal.
	 */
	public const EVENT_TYPE = 'audit.retention_purge';

	/**
	 * `audit_kind` token stamped on the meta-audit row.
	 *
	 * `compliance_relevant_action` rather than
	 * `operational_action` because retention purges are exactly
	 * the kind of compliance-adjacent state mutation §10.1 /
	 * §10.4 are intended to track.
	 */
	public const AUDIT_KIND = 'compliance_relevant_action';

	/**
	 * `action` token stamped on the meta-audit row. The §10.2
	 * action vocabulary is open-ended; `purge` matches the
	 * domain term used in `database-schema.md` §9.4 and §10.4.
	 */
	public const ACTION = 'purge';

	/**
	 * `target_type` token stamped on the meta-audit row.
	 *
	 * The purge mutates a AFFILUN-owned table; the `table` target
	 * type lets the §10 admin-page filter "show me every event
	 * that touched a affilun_* table" drill-down work.
	 */
	public const TARGET_TYPE = 'table';

	/**
	 * `target_id` token stamped on the meta-audit row, naming
	 * the logical AFFILUN table the purge swept.
	 */
	public const TARGET_ID = 'affilun_audit_events';

	/**
	 * Persistence-layer port the purger drains.
	 */
	private AuditEventRepository $events;

	/**
	 * Retention policy supplying the per-tick cutoff arithmetic.
	 */
	private AuditRetentionPolicy $policy;

	/**
	 * Clock port producing the per-tick "now" instant.
	 */
	private Clock $clock;

	/**
	 * Optional §10.1 audit-event writer.
	 *
	 * When non-null, every successful purge tick emits one
	 * `compliance_relevant_action` audit row through this
	 * writer. When null (the default) the purger stays silent
	 * on the audit channel — 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::emitPurgeAudit()} so losing one meta-audit
	 * row never derails a successful purge sweep.
	 */
	private ?AuditEventWriter $auditWriter;

	/**
	 * Construct the purger with explicit collaborator injection.
	 *
	 * @param AuditEventRepository  $events      Persistence-layer
	 *                                           port the purger
	 *                                           drains.
	 * @param AuditRetentionPolicy  $policy      Retention policy
	 *                                           supplying the
	 *                                           per-tick cutoff.
	 * @param Clock                 $clock       Clock port
	 *                                           producing the
	 *                                           per-tick "now"
	 *                                           instant.
	 * @param AuditEventWriter|null $auditWriter Optional §10.1
	 *                                           audit writer; when
	 *                                           null, the purger
	 *                                           does not emit the
	 *                                           meta-audit row.
	 */
	public function __construct(
		AuditEventRepository $events,
		AuditRetentionPolicy $policy,
		Clock $clock,
		?AuditEventWriter $auditWriter = null
	) {
		$this->events      = $events;
		$this->policy      = $policy;
		$this->clock       = $clock;
		$this->auditWriter = $auditWriter;
	}

	/**
	 * Configured retention policy.
	 *
	 * Exposed so diagnostics surfaces and tests can introspect
	 * the policy snapshot without re-reading the constructor
	 * arguments.
	 */
	public function policy(): AuditRetentionPolicy {
		return $this->policy;
	}

	/**
	 * Drive one §10.4 retention purge sweep and return the
	 * resulting tick summary.
	 *
	 * Stamps a single UTC "now" instant at the top of the tick,
	 * derives the strict-less-than cutoff via
	 * {@see AuditRetentionPolicy::cutoffFrom()}, calls
	 * {@see AuditEventRepository::deleteOlderThan()} with the
	 * cutoff, builds an {@see AuditRetentionPurgeResult}
	 * carrying the policy snapshot + deleted count, and emits
	 * one meta-audit row through the optional writer per the
	 * §10.4 "purge 実行自体も audit または operational event
	 * として記録してよい" rule.
	 *
	 * The meta-audit row is emitted even on an idle tick
	 * (`deleted === 0`) so the audit trail carries the
	 * canonical evidence that the retention policy actually ran
	 * on a given day; an idle tick without an audit row is
	 * indistinguishable from "the cron event never fired" when
	 * reviewing the trail months later.
	 *
	 * Persistence-layer failures from
	 * {@see AuditEventRepository::deleteOlderThan()} bubble out
	 * unchanged; the meta-audit row is NOT emitted in that case
	 * because the deletion may have partially succeeded and
	 * the row count would be inaccurate. The surrounding cron
	 * action handler is responsible for catching the throwable
	 * and routing it to `error_log()`.
	 *
	 * Audit emit failures are swallowed inside
	 * {@see self::emitPurgeAudit()} so the operator-visible
	 * purge outcome (rows are gone, summary VO is returned) is
	 * preserved even when the meta-audit emission fails.
	 *
	 * @throws RuntimeException When
	 *                          {@see AuditEventRepository::deleteOlderThan()}
	 *                          reports a driver error.
	 */
	public function purge(): AuditRetentionPurgeResult {
		$now    = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) );
		$cutoff = $this->policy->cutoffFrom( $now );

		$deleted = $this->events->deleteOlderThan( $cutoff );

		$result = new AuditRetentionPurgeResult(
			$now,
			$cutoff,
			$this->policy->retentionDays(),
			$deleted
		);

		$this->emitPurgeAudit( $result );

		return $result;
	}

	/**
	 * Emit a `compliance_relevant_action` audit row describing
	 * the just-completed purge sweep.
	 *
	 * 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 meta-audit row is preferable to
	 * derailing a successful purge.
	 *
	 * The draft uses {@see AuditEventDraft::forSystemAction()}
	 * because the purge runs without a human actor (a daily
	 * WP-Cron event, not an admin-triggered action). The
	 * `safe_summary` carries a fixed-shape sentence with the
	 * deleted count and retention window; no row content from
	 * the purged audit rows is leaked into the meta-audit row
	 * (which would defeat the §10.4 retention purpose by
	 * preserving the very content the policy just removed).
	 *
	 * The `context_json` payload carries:
	 *
	 *   - `cutoff` — ISO-8601 UTC timestamp the persistence
	 *     layer compared row `created_at` values against.
	 *
	 *   - `deleted` — number of rows removed (`>= 0`).
	 *
	 *   - `retention_days` — the policy snapshot at the moment
	 *     of the sweep, so the meta-audit row carries the
	 *     policy state even if a later reconfiguration changes
	 *     the live policy.
	 *
	 * @param AuditRetentionPurgeResult $result Purge outcome to
	 *                                          encode.
	 */
	private function emitPurgeAudit( AuditRetentionPurgeResult $result ): void {
		if ( $this->auditWriter === null ) {
			return;
		}

		try {
			$draft = AuditEventDraft::forSystemAction(
				self::EVENT_TYPE,
				self::AUDIT_KIND,
				self::ACTION,
				AuditEventDraft::RESULT_SUCCESS,
				sprintf(
					'Audit retention purge removed %d row(s) older than %s (retention window: %d day(s)).',
					$result->deleted(),
					$result->cutoff()->format( DateTimeImmutable::ATOM ),
					$result->retentionDays()
				),
				self::TARGET_TYPE,
				self::TARGET_ID,
				array(),
				array(
					'cutoff'         => $result->cutoff()->format( DateTimeImmutable::ATOM ),
					'deleted'        => $result->deleted(),
					'retention_days' => $result->retentionDays(),
				)
			);

			$this->auditWriter->record( $draft );
		} catch ( RuntimeException $unused ) {
			// Per the AuditEventWriter contract, audit emit
			// failure must not derail the surrounding business
			// outcome. The deletion has already happened by the
			// time we reach this branch, so the operator-visible
			// result (rows are gone, summary VO is returned) is
			// preserved even when the meta-audit emission fails.
			unset( $unused );
		}
	}
}
