<?php
/**
 * Log repository contract.
 *
 * Narrow persistence port for the `affilun_logs` table defined in
 * `specifications/database-schema.md` §9. The contract mirrors the
 * shape established by {@see ProductRepository}, {@see EmbedRepository},
 * and {@see SyncJobRepository} during Phase 2 slice 4a / 4b / 4c — a
 * small CRUD-flavored surface (save / findById / delete) plus a count
 * helper that diagnostics and integration tests use to assert pipeline
 * outcomes — but with one deliberate deviation: {@see self::save()} is
 * insert-only.
 *
 * Design notes:
 *
 *   - The contract speaks in {@see LogRecord} values — a row-shaped
 *     immutable DTO — rather than Phase 3 domain entities. This keeps
 *     the persistence layer from reaching into the domain package and
 *     lets later phases wrap these records in richer observability-
 *     surface types without rewriting the repository surface.
 *
 *   - Unlike the other AFFILUN repositories, {@see self::save()} ONLY
 *     accepts unpersisted records (records whose `id` is null).
 *     Attempting to save a record that already carries an id MUST
 *     raise a {@see RuntimeException}. The rationale is twofold:
 *
 *       1. `database-schema.md` §9.2 deliberately omits an
 *          `updated_at` column — `affilun_logs` is append-only. The
 *          schema itself signals that mutation is not a supported
 *          operation, and the repository contract reflects that.
 *
 *       2. §9.4 documents retention as a purge-job concern: rows are
 *          removed in bulk by the scheduled `cleanup_logs` job (§8.4),
 *          not edited in place. Exposing an update path would
 *          contradict the retention model.
 *
 *     {@see LogRecord} itself has no `withUpdatedAt()` helper for the
 *     same reason, so the only way a caller could produce a
 *     non-null-id record is by taking the return value of a previous
 *     `save()` and calling `save()` on it again — a likely bug that
 *     the contract surfaces loudly rather than silently accepting.
 *
 *   - Unlike `affilun_products` (§4) and `affilun_embeds` (§6), the `affilun_logs`
 *     schema (§9.3) declares NO unique constraints beyond the primary
 *     key — every index is a non-unique secondary index that supports
 *     the diagnostics surface's drill-down query patterns (by channel,
 *     by level, by time window, by related product / job).
 *     Consequently this contract does NOT expose a `findByUuid()`-
 *     style lookup.
 *
 *   - Bulk query surfaces (paginated listing, filter-by-channel,
 *     filter-by-level, time-window scans, correlation-id lookups) are
 *     deliberately NOT part of the Phase 2 contract. They will be
 *     added as dedicated methods — or a separate query-side port —
 *     when the diagnostics viewer and the correlation-based drill-
 *     down surfaces materialize in later phases, so the initial
 *     contract stays small and reversible.
 *
 *   - Bulk deletion / purge (`deleteOlderThan()`, `deleteByChannel()`,
 *     etc.) is also deliberately NOT part of the Phase 2 contract.
 *     The retention windows documented in §9.4 (7–14 days for debug,
 *     30–90 days for standard operational logs, 90–180 days for
 *     security-relevant entries) belong with the `cleanup_logs`
 *     purge-job implementation that consumes {@see SyncJobRepository}
 *     and will be added alongside the worker runtime in a later
 *     slice. Until then, single-row {@see self::delete()} is
 *     sufficient for the tests and diagnostics paths that exist.
 *
 *   - The `createdAt` timestamp on {@see LogRecord} is a UTC
 *     {@see \DateTimeImmutable} instance. 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, 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 record, 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.
 *
 *   - {@see self::save()} is insert-only: a non-null-id record MUST
 *     raise {@see \RuntimeException}. See the design notes above for
 *     the rationale.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use RuntimeException;

/**
 * Persistence port for `affilun_logs` rows.
 *
 * See the file-level docblock for the contract's scope, the record
 * type it exchanges, the insert-only nature of `save()`, and its
 * not-found / delete semantics.
 */
interface LogRepository {

	/**
	 * Persist the given log record.
	 *
	 * This is an INSERT-ONLY operation. The schema's append-only
	 * design (§9.2 omits `updated_at`; §9.4 defers retention to a
	 * purge job) means there is no supported mutation path for an
	 * existing row, so implementations MUST:
	 *
	 *   - Accept only records whose id is null. The implementation
	 *     assigns the auto-increment primary key and returns a new
	 *     record carrying the assigned id.
	 *
	 *   - Raise {@see RuntimeException} when the supplied record
	 *     already carries an id. The append-only schema has no way
	 *     to honor the update, and silently inserting a duplicate
	 *     (or silently ignoring the call) would mislead callers
	 *     about the fate of their data.
	 *
	 * @param LogRecord $record Record to persist. Must carry a null id.
	 *
	 * @return LogRecord The post-insert record, always carrying a
	 *                   non-null id.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error, or when the supplied record
	 *                          already carries a non-null id.
	 */
	public function save( LogRecord $record ): LogRecord;

	/**
	 * 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 RuntimeException When the underlying driver reports an
	 *                          error.
	 */
	public function findById( int $id ): ?LogRecord;

	/**
	 * Delete the row identified by the given id.
	 *
	 * 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}.
	 *
	 * Bulk purge — removing every row older than a cutoff, or every
	 * row on a given channel — is NOT part of this contract; that
	 * operation belongs with the `cleanup_logs` purge job (§8.4 /
	 * §9.4). This method is a single-row helper suitable for tests
	 * and for targeted diagnostics cleanup.
	 *
	 * @param int $id Primary key of the row to delete.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error.
	 */
	public function delete( int $id ): bool;

	/**
	 * Total number of rows currently stored in `affilun_logs`.
	 *
	 * 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/logs` query surface described in
	 * `specifications/rest-api-spec.md` §12.2. The query VO carries
	 * the optional column-level filter axis (`level`, `channel`,
	 * `relatedProductId`, `relatedJobId`) and the pagination axis
	 * (`page`, `perPage`); see {@see LogListQuery} for the exhaustive
	 * validation rules and the deferred JSON-extraction filters
	 * (`event_type`, `request_id`, `correlation_id`, `job_id`) tracked
	 * for Phase 11 / 12.
	 *
	 * The returned slice is ordered newest-first
	 * (`ORDER BY created_at DESC, id DESC`); spec §12.2 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 LogListQuery $query Validated list-query inputs.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error or when a returned row cannot be
	 *                          decoded into a {@see LogRecord}.
	 */
	public function findPaginated( LogListQuery $query ): LogListResult;

	/**
	 * Group every stored row by PSR-3 level and return the per-level
	 * counts.
	 *
	 * Implements the level-histogram half of the `GET /affilun/v1/logs/summary`
	 * surface described in `specifications/rest-api-spec.md` §12.3.
	 * Levels with zero occurrences are OMITTED from the returned map;
	 * callers that need a fully-populated histogram (every PSR-3
	 * level with a numeric count) should default-fill against
	 * {@see LogListQuery::VALID_LEVELS}.
	 *
	 * Implementations SHOULD back this with a single
	 * `SELECT level, COUNT(*) ... GROUP BY level` query rather than
	 * issuing one count per level.
	 *
	 * @return array<string, int> Map of PSR-3 level => count. Levels
	 *                            with zero occurrences are omitted.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error.
	 */
	public function countByLevel(): array;

	/**
	 * Return the most recent log entries, newest-first.
	 *
	 * Implements the recent-entries half of the
	 * `GET /affilun/v1/logs/summary` surface described in
	 * `specifications/rest-api-spec.md` §12.3. Ordering matches
	 * {@see self::findPaginated()} (`ORDER BY created_at DESC, id DESC`)
	 * so a "first page of recent entries" call site cannot disagree
	 * with the histogram about which rows are most recent.
	 *
	 * `$limit` is bounded to the same 1..100 range as
	 * {@see LogListQuery::MAX_PER_PAGE} so the summary endpoint
	 * cannot be coerced into materializing the full table.
	 *
	 * @param int $limit Maximum number of entries to return (1..100).
	 *
	 * @return list<LogRecord>
	 *
	 * @throws \InvalidArgumentException When `$limit` is out of range.
	 * @throws RuntimeException          When the underlying driver
	 *                                   reports an error or when a
	 *                                   returned row cannot be decoded
	 *                                   into a {@see LogRecord}.
	 */
	public function findRecent( int $limit ): array;
}
