<?php
/**
 * Sync-job repository contract.
 *
 * Narrow persistence port for the `affilun_sync_jobs` table defined in
 * `specifications/database-schema.md` §8. The contract mirrors the
 * shape established by {@see ProductRepository} / {@see EmbedRepository}
 * during Phase 2 slice 4a / 4b: a small CRUD surface
 * (save / findById / delete) plus a count helper that diagnostics and
 * integration tests use to assert pipeline outcomes.
 *
 * Design notes:
 *
 *   - The contract speaks in {@see SyncJobRecord} 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 worker-owned
 *     types without rewriting the repository surface.
 *
 *   - {@see self::save()} owns the insert-vs-update decision: callers
 *     pass a record that either carries an id (update) or does not
 *     (insert). Implementations assign the auto-increment id on insert
 *     and return a new record carrying the assigned id, so callers do
 *     not have to round-trip through {@see self::findById()} to learn
 *     which id was allocated.
 *
 *   - Unlike `affilun_products` (§4) and `affilun_embeds` (§6), the
 *     `affilun_sync_jobs` schema (§8.3) declares NO unique constraints
 *     beyond the primary key — every index is a non-unique secondary
 *     index that supports the worker's "pick next runnable job"
 *     query pattern described in §8.5. Consequently this contract
 *     deliberately does NOT expose a `findByUuid()`-style lookup; the
 *     slot is reserved for a future `claimNext()` helper that owns
 *     the §8.5 lock-acquisition transition, which belongs with the
 *     worker slice rather than with the Phase 2 repository skeleton.
 *
 *   - The Phase 2 skeleton intentionally shipped with no bulk query
 *     surface; Phase 11 slice 11b adds {@see self::findPaginated()}
 *     and {@see self::countByStatus()} to back the
 *     `GET /affilun/v1/sync/jobs` REST endpoint described in
 *     `specifications/rest-api-spec.md` §11.3 and the sync-jobs
 *     admin page. Phase 2 (post-MVP) slice 2a adds
 *     {@see self::claimNext()} as the worker primitive that owns
 *     the §8.5 lock-acquisition transition (claim a `pending` job
 *     that has reached `available_at`, or reclaim a `running` job
 *     whose `locked_at` is older than the supplied lock-timeout
 *     cutoff). Per-batch lock-timeout reclamation sweeps remain
 *     opportunistic (folded into each `claimNext()` call) rather
 *     than a separate scheduled scan.
 *
 *   - All timestamps on {@see SyncJobRecord} 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 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.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use DateTimeImmutable;
use InvalidArgumentException;
use RuntimeException;

/**
 * Persistence port for `affilun_sync_jobs` rows.
 *
 * See the file-level docblock for the contract's scope, the record
 * type it exchanges, the reason `findByUuid()` is intentionally
 * absent, and its insert-vs-update / not-found / delete semantics.
 */
interface SyncJobRepository {

	/**
	 * Persist the given record.
	 *
	 * Implementations decide insert vs. update based on whether the
	 * supplied record carries an id:
	 *
	 *   - Null id: insert. The implementation assigns the auto-
	 *     increment primary key and returns a new record carrying
	 *     the assigned id. The returned record reflects any other
	 *     column defaults the database applied (for example
	 *     timestamps normalized to the storage precision).
	 *
	 *   - Non-null id: update. The implementation writes every column
	 *     on the record to the matching row. The returned record is
	 *     the canonical post-save shape; callers MUST use the returned
	 *     record (not the input) for any subsequent operation so
	 *     `updated_at` and any implementation-normalized values are
	 *     not silently dropped.
	 *
	 * A save that targets a non-null id which no longer exists in the
	 * database MUST raise a {@see RuntimeException} rather than
	 * silently inserting a new row with a caller-controlled id — the
	 * application's update-after-delete race should be a visible
	 * error, not a data-integrity surprise.
	 *
	 * @param SyncJobRecord $record Record to persist.
	 *
	 * @return SyncJobRecord The post-save record, always carrying a
	 *                       non-null id.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error or when an update targets a row
	 *                          that no longer exists.
	 */
	public function save( SyncJobRecord $record ): SyncJobRecord;

	/**
	 * 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 ): ?SyncJobRecord;

	/**
	 * 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}.
	 *
	 * @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_sync_jobs`.
	 *
	 * 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/sync/jobs` query surface described
	 * in `specifications/rest-api-spec.md` §11.3. The query VO carries
	 * the optional column-level filter axis (`status`, `jobType`) and
	 * the pagination axis (`page`, `perPage`); see
	 * {@see SyncJobListQuery} for the exhaustive validation rules and
	 * the wire ⇔ DB status vocabulary mapping (wire `queued` ⇔ DB
	 * `pending` per `rest-api-spec.md` §6.0). The REST controller in
	 * slice 11e is responsible for translating the wire-side
	 * vocabulary before constructing the query VO; the persistence
	 * layer speaks DB vocabulary throughout.
	 *
	 * The returned slice is ordered newest-first
	 * (`ORDER BY created_at DESC, id DESC`); spec §11.3 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 SyncJobListQuery $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 SyncJobRecord}.
	 */
	public function findPaginated( SyncJobListQuery $query ): SyncJobListResult;

	/**
	 * Group every stored row by DB-vocabulary status and return the
	 * per-status counts.
	 *
	 * Implements the status-histogram surface consumed by the
	 * sync-jobs admin page. Statuses with zero occurrences are
	 * OMITTED from the returned map; callers that need a fully-
	 * populated histogram (every status with a numeric count) should
	 * default-fill against {@see SyncJobListQuery::VALID_STATUSES}.
	 *
	 * Implementations SHOULD back this with a single
	 * `SELECT status, COUNT(*) ... GROUP BY status` query rather than
	 * issuing one count per status.
	 *
	 * The returned keys use DB vocabulary (`pending|running|...`); the
	 * REST controller in slice 11e maps DB `pending` to wire `queued`
	 * before responding to clients per `rest-api-spec.md` §6.0.
	 *
	 * @return array<string, int> Map of DB-vocabulary status => count.
	 *                            Statuses with zero occurrences are
	 *                            omitted.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error.
	 */
	public function countByStatus(): array;

	/**
	 * Find an unfinished `sync_product` job for the supplied product id.
	 *
	 * Used by periodic/manual enqueue paths to avoid stacking duplicate
	 * work for the same product while an existing row is still queued
	 * or running. Terminal rows (`succeeded`, `failed`, etc.) are not
	 * considered open and MUST NOT suppress a future enqueue.
	 *
	 * @param int $productId Local `affilun_products.id` carried in the job payload.
	 *
	 * @throws InvalidArgumentException When `$productId` is non-positive.
	 * @throws RuntimeException         When the underlying driver reports an error.
	 */
	public function findOpenProductSync( int $productId ): ?SyncJobRecord;

	/**
	 * Atomically claim the next runnable job for the supplied worker
	 * and return the post-claim record, or null when no job is
	 * eligible.
	 *
	 * Eligibility (per `database-schema.md` §8.5 / ADR-0021 §8.2):
	 *
	 *   - A `pending` row whose `available_at <= $now` is eligible
	 *     for a fresh claim.
	 *
	 *   - A `running` row whose `locked_at` is non-null AND strictly
	 *     older than `$lockTimeoutCutoff` is eligible for
	 *     reclamation. The §8.5 "一定時間以上 `running` のままなら
	 *     タイムアウト回収対象" rule is enforced here so the worker
	 *     loop does not have to drive a separate sweep.
	 *
	 * Among the eligible candidates, implementations MUST honor the
	 * §13.2 priority ordering — `priority ASC` first (smaller is
	 * higher priority), then `available_at ASC` (oldest first), then
	 * `id ASC` (stable tiebreak). The selected row is transitioned
	 * to `status='running'`, `locked_at = $now`, `locked_by =
	 * $workerId`, and `updated_at = $now`. The `attempts` counter is
	 * NOT incremented on claim — the handler increments it on the
	 * terminal transition so that a worker crash mid-flight does not
	 * burn an attempt the handler never observed.
	 *
	 * Atomicity:
	 *
	 *   - The claim MUST be safe against concurrent workers. The
	 *     `WpdbSyncJobRepository` implementation uses a `SELECT`
	 *     candidate scan plus a conditional `UPDATE` that only
	 *     succeeds when the row is still in the eligible state, and
	 *     advances to the next candidate when the conditional update
	 *     reports zero affected rows. Implementations SHOULD attempt
	 *     a small fixed number of candidates before returning null
	 *     so the loop cannot wedge on a hot row.
	 *
	 *   - When no eligible row exists (or every candidate was
	 *     stolen by a competing worker before the conditional update
	 *     landed), the method returns null. Returning null is the
	 *     normal idle path; callers SHOULD NOT treat it as an error.
	 *
	 * @param string            $workerId          Lock-holder identifier
	 *                                             stamped onto
	 *                                             `locked_by`. Must be
	 *                                             non-empty and at most
	 *                                             64 bytes (matching the
	 *                                             column width).
	 * @param DateTimeImmutable $now               Current UTC instant;
	 *                                             stamped onto
	 *                                             `locked_at` and
	 *                                             `updated_at`, and used
	 *                                             as the upper bound for
	 *                                             the `pending` /
	 *                                             `available_at`
	 *                                             eligibility check.
	 * @param DateTimeImmutable $lockTimeoutCutoff Threshold for the
	 *                                             `running` reclamation
	 *                                             branch: rows whose
	 *                                             `locked_at` is
	 *                                             strictly older than
	 *                                             this instant are
	 *                                             eligible for re-claim.
	 *                                             To disable
	 *                                             reclamation entirely,
	 *                                             pass an instant equal
	 *                                             to or earlier than
	 *                                             every possible
	 *                                             `locked_at` value
	 *                                             (for example
	 *                                             `1970-01-01T00:00:00Z`).
	 *
	 * @throws InvalidArgumentException When `$workerId` is empty or
	 *                                  exceeds 64 bytes.
	 * @throws RuntimeException         When the underlying driver
	 *                                  reports an error.
	 */
	public function claimNext(
		string $workerId,
		DateTimeImmutable $now,
		DateTimeImmutable $lockTimeoutCutoff
	): ?SyncJobRecord;
}
