<?php
/**
 * Audit-event repository contract.
 *
 * Phase 2 (KI-006) narrow persistence port for the
 * `affilun_audit_events` table defined in
 * `specifications/database-schema.md` §10. Mirrors the
 * {@see LogRepository} append-only contract because both surfaces
 * share the same write-once / paginate-read / never-mutate
 * lifecycle: §10.1 declares the audit trail as append-only
 * evidence and corrections are themselves recorded as new audit
 * rows so the trail remains tamper-evident.
 *
 * Design notes:
 *
 *   - The contract speaks in {@see AuditEventRecord} values — a
 *     row-shaped immutable DTO — rather than richer domain
 *     entities. This keeps the persistence layer from reaching
 *     into the audit-writer surface and lets future slices wrap
 *     these records in writer-owned types without rewriting the
 *     repository surface.
 *
 *   - {@see self::save()} is INSERT-ONLY. Implementations MUST
 *     reject records that already carry a non-null id with
 *     {@see \RuntimeException} so an accidental "save the row I
 *     just read back" call surfaces loudly rather than silently
 *     creating a duplicate audit row. Inserts allocate the
 *     auto-increment id and return a new record carrying the
 *     assigned id, matching the {@see LogRepository::save()}
 *     convention.
 *
 *   - {@see self::findById()} returns null on miss rather than
 *     throwing; "not found" is a success condition for read
 *     methods. Non-positive ids are a programming error and
 *     implementations MAY raise {@see \InvalidArgumentException}.
 *
 *   - {@see self::delete()} is exposed for the §10.4 retention
 *     purge path only (the future `affilun_cron_purge_audit` worker
 *     that prunes rows past the retention window). Application-
 *     layer audit writers MUST NOT call it during normal
 *     operation; corrections to a previously-recorded event are
 *     themselves recorded as new audit rows. The method is
 *     idempotent: returns true when a row was removed and false
 *     when none matched, matching `$wpdb->delete()` semantics.
 *
 *   - {@see self::count()} is exposed so diagnostics surfaces
 *     and integration tests can assert on the pipeline outcome
 *     without enumerating rows. Implementations SHOULD back this
 *     with a `SELECT COUNT(*)` query rather than materializing
 *     every row.
 *
 *   - {@see self::findPaginated()} backs the
 *     `GET /affilun/v1/audit/events` REST endpoint described in
 *     `specifications/rest-api-spec.md` §12.4 and the future
 *     audit admin page. The query VO carries the optional
 *     column-level filter axes (`auditKind`, `actorId`,
 *     `targetType`, `targetId`) and the pagination axis
 *     (`page`, `perPage`); see {@see AuditEventListQuery} for
 *     the exhaustive validation rules.
 *
 *     The returned slice is ordered newest-first
 *     (`ORDER BY created_at DESC, id DESC`); §12.4 does not
 *     expose a sort parameter so the ordering is fixed at the
 *     repository boundary. The total reported on the result
 *     envelope reflects the filter set BEFORE pagination so REST
 *     consumers can compute the page count without re-issuing
 *     the query.
 *
 *   - {@see self::countByAuditKind()} groups every stored row by
 *     audit-kind and returns the per-kind counts. Used by the
 *     §12.5 summary surface follow-up and by diagnostics that
 *     want a quick "what kinds of audit events have been
 *     recorded" overview without paginating the full table.
 *     Statuses with zero occurrences are OMITTED from the
 *     returned map; callers that need a fully-populated
 *     histogram should default-fill against
 *     {@see AuditEventListQuery::VALID_AUDIT_KINDS}.
 *
 *   - All timestamps on {@see AuditEventRecord} are UTC
 *     {@see \DateTimeImmutable} instances. Implementations are
 *     responsible for converting to and from the MySQL
 *     `Y-m-d H:i:s` representation on the storage boundary;
 *     callers never see the string form through this port.
 *
 * Failure semantics:
 *
 *   - Database-level errors (connection loss, constraint
 *     violations, malformed rows) MUST surface as exceptions.
 *     Implementations SHOULD wrap driver-specific errors in a
 *     descriptive {@see \RuntimeException} so call sites can
 *     catch at a single boundary without importing driver
 *     classes.
 *
 *   - "Not found" is a success condition for read methods: they
 *     return null rather than throwing. This keeps the common
 *     case (probe for an existing row, branch on absence)
 *     allocation-free and free of exception control flow.
 *
 *   - Delete is idempotent. {@see self::delete()} returns true
 *     when a row was removed and false when none matched,
 *     matching the `$wpdb->delete()` convention; neither
 *     outcome is an error.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use DateTimeImmutable;
use InvalidArgumentException;
use RuntimeException;

/**
 * Persistence port for `affilun_audit_events` rows.
 *
 * See the file-level docblock for the contract's append-only
 * scope, the record type it exchanges, the
 * {@see self::findPaginated()} ordering rule, and the not-found
 * / delete semantics.
 */
interface AuditEventRepository {

	/**
	 * Persist the given audit-event record.
	 *
	 * This is an INSERT-ONLY operation. Implementations MUST:
	 *
	 *   - Accept only records whose id is null. Records with a
	 *     non-null id raise {@see RuntimeException} because the
	 *     §10.1 append-only invariant rules out any in-place
	 *     mutation path.
	 *
	 *   - Allocate the auto-increment primary key and return a
	 *     new record carrying the assigned id. Callers MUST use
	 *     the returned record (not the input) for any subsequent
	 *     operation so timestamps normalized to the storage
	 *     precision are not silently dropped.
	 *
	 * @param AuditEventRecord $record Record to persist (id MUST be null).
	 *
	 * @return AuditEventRecord The post-save record, always
	 *                          carrying a non-null id.
	 *
	 * @throws RuntimeException When the underlying driver reports
	 *                          an error or when `$record` already
	 *                          carries a non-null id.
	 */
	public function save( AuditEventRecord $record ): AuditEventRecord;

	/**
	 * Look up a record by its auto-increment primary key.
	 *
	 * Returns null when no row matches. Non-positive ids are a
	 * programming error and implementations MAY raise
	 * {@see InvalidArgumentException}; callers that only have an
	 * optional id in hand should filter before calling.
	 *
	 * @param int $id Primary key to look up.
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive.
	 * @throws RuntimeException         When the underlying driver
	 *                                  reports an error.
	 */
	public function findById( int $id ): ?AuditEventRecord;

	/**
	 * Delete the row identified by the given id.
	 *
	 * Reserved for the §10.4 retention purge path; application-
	 * layer audit writers MUST NOT call this during normal
	 * operation. Idempotent: returns true when a row was removed,
	 * false when no row matched. Neither outcome is an error.
	 *
	 * Non-positive ids are a programming error and
	 * implementations MAY raise
	 * {@see InvalidArgumentException}.
	 *
	 * @param int $id Primary key of the row to delete.
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive.
	 * @throws RuntimeException         When the underlying driver
	 *                                  reports an error.
	 */
	public function delete( int $id ): bool;

	/**
	 * Total number of rows currently stored in
	 * `affilun_audit_events`.
	 *
	 * Used by diagnostics and integration tests to assert on the
	 * pipeline outcome without enumerating rows. Implementations
	 * SHOULD back this with a `SELECT COUNT(*)` query rather
	 * than materializing every row.
	 *
	 * @throws RuntimeException When the underlying driver reports
	 *                          an error.
	 */
	public function count(): int;

	/**
	 * Look up a paginated, filtered slice of stored rows.
	 *
	 * Implements the `GET /affilun/v1/audit/events` query surface
	 * described in `specifications/rest-api-spec.md` §12.4. The
	 * query VO carries the optional column-level filter axes
	 * (`auditKind`, `actorId`, `targetType`, `targetId`) and the
	 * pagination axis (`page`, `perPage`); see
	 * {@see AuditEventListQuery} for the exhaustive validation
	 * rules.
	 *
	 * The returned slice is ordered newest-first
	 * (`ORDER BY created_at DESC, id DESC`); §12.4 does not
	 * expose a sort parameter, so the ordering is fixed at the
	 * repository boundary. The total reported on the result
	 * envelope reflects the filter set BEFORE pagination so REST
	 * consumers can compute the page count without re-issuing
	 * the query.
	 *
	 * @param AuditEventListQuery $query Validated list-query inputs.
	 *
	 * @throws RuntimeException When the underlying driver reports
	 *                          an error or when a returned row
	 *                          cannot be decoded into an
	 *                          {@see AuditEventRecord}.
	 */
	public function findPaginated( AuditEventListQuery $query ): AuditEventListResult;

	/**
	 * Group every stored row by `audit_kind` and return the
	 * per-kind counts.
	 *
	 * Used by the §12.5 summary follow-up and by diagnostics
	 * surfaces that want a quick "what kinds of audit events
	 * have been recorded" overview without paginating the full
	 * table. Kinds with zero occurrences are OMITTED from the
	 * returned map; callers that need a fully-populated
	 * histogram (every kind with a numeric count) should
	 * default-fill against
	 * {@see AuditEventListQuery::VALID_AUDIT_KINDS}.
	 *
	 * Implementations SHOULD back this with a single
	 * `SELECT audit_kind, COUNT(*) ... GROUP BY audit_kind`
	 * query rather than issuing one count per kind.
	 *
	 * @return array<string, int> Map of audit-kind => count.
	 *                            Kinds with zero occurrences are
	 *                            omitted.
	 *
	 * @throws RuntimeException When the underlying driver reports
	 *                          an error.
	 */
	public function countByAuditKind(): array;

	/**
	 * Delete every row whose `created_at` is strictly older than
	 * the supplied UTC cutoff and return the number of rows
	 * removed.
	 *
	 * Phase 2 KI-006 follow-up: backs the §10.4 audit-retention
	 * purge worker. The cutoff is a UTC instant computed by the
	 * caller (typically `clock->now() - retentionWindow`); rows
	 * whose `created_at` is **strictly** less than the cutoff are
	 * removed, rows whose `created_at` equals the cutoff are
	 * preserved (so a row landing on the boundary survives at
	 * least one full sweep).
	 *
	 * Implementations SHOULD back this with a single
	 * `DELETE FROM affilun_audit_events WHERE created_at < %s` query
	 * rather than enumerating ids and re-issuing per-row deletes;
	 * the §10.4 retention window is measured in months so a
	 * single sweep can legitimately touch thousands of rows.
	 *
	 * Idempotent: calling the method twice with the same cutoff
	 * leaves the table in the same end state (the second call
	 * returns 0). Negative cutoffs are nonsensical for an
	 * append-only table that only stores `created_at` instants
	 * derived from {@see \Affilun\Domain\Clock::now()}
	 * so implementations MAY treat them as the empty deletion
	 * case rather than raising.
	 *
	 * @param DateTimeImmutable $cutoff UTC cutoff instant; rows
	 *                                  strictly older than this
	 *                                  are removed.
	 *
	 * @return int Number of rows actually deleted.
	 *
	 * @throws RuntimeException When the underlying driver reports
	 *                          an error.
	 */
	public function deleteOlderThan( DateTimeImmutable $cutoff ): int;
}
