<?php
/**
 * Product repository contract.
 *
 * Narrow persistence port for the `affilun_products` table defined in
 * `specifications/database-schema.md` §4. The contract is deliberately
 * small during Phase 2: it captures the four CRUD operations the
 * activation / sync paths actually need (save, findById,
 * findByProviderItem, delete), plus a count helper that
 * diagnostics and integration tests use to assert pipeline outcomes.
 *
 * Design notes:
 *
 *   - The contract speaks in {@see ProductRecord} 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 Phase 3 wrap these records in richer domain 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.
 *
 *   - The `(provider, provider_item_id)` unique constraint from §4.3 is
 *     surfaced as a dedicated lookup ({@see self::findByProviderItem()})
 *     so sync code can probe for an existing product without scanning
 *     the table. Implementations MUST honor the unique constraint; a
 *     save that would violate it is a programming error and should
 *     surface verbatim from the underlying driver rather than being
 *     silently coerced.
 *
 *   - Phase 6 (REST API base) extends this surface with a single
 *     paginated listing method, {@see self::findPaginated()}, that
 *     backs the admin list endpoint described in
 *     `specifications/rest-api-spec.md` §5.3 (filter / sort axes) and
 *     §8.2 (`GET /affilun/v1/products`). The method takes a
 *     {@see ProductListQuery} value object so adding a new filter
 *     axis later does not break this interface, and returns a
 *     {@see ProductListResult} envelope that carries both the page
 *     slice and the total count needed to render the §4.2 pagination
 *     envelope. Cursor traversal and additional bulk surfaces remain
 *     out of scope.
 *
 *   - All timestamps on {@see ProductRecord} 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 RuntimeException;

/**
 * Persistence port for `affilun_products` rows.
 *
 * See the file-level docblock for the contract's scope, the record
 * type it exchanges, and its insert-vs-update / not-found / delete
 * semantics.
 */
interface ProductRepository {

	/**
	 * Look up a paginated, filtered, sorted slice of stored rows.
	 *
	 * Backs the REST list endpoint described in
	 * `specifications/rest-api-spec.md` §5.3 and §8.2. The query VO
	 * carries every supported axis (pagination, filters, sort) with
	 * closed-set tokens already validated, so implementations may
	 * interpolate sort tokens directly into SQL without re-validating
	 * and may treat null filters as "do not constrain this axis".
	 *
	 * Implementations MUST compute the total matching row count
	 * AFTER filtering and BEFORE pagination, so the returned
	 * {@see ProductListResult} carries the value `total_pages` is
	 * derived from in §4.2.
	 *
	 * @param ProductListQuery $query Validated list-query inputs.
	 *
	 * @return ProductListResult Page slice plus total-row count.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error.
	 */
	public function findPaginated( ProductListQuery $query ): ProductListResult;

	/**
	 * 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 ProductRecord $record Record to persist.
	 *
	 * @return ProductRecord The post-save record, always carrying a
	 *                       non-null id.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error, when a unique-constraint
	 *                          violation occurs, or when an update
	 *                          targets a row that no longer exists.
	 */
	public function save( ProductRecord $record ): ProductRecord;

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

	/**
	 * Bulk lookup primitive used by Phase-10 grid rendering to avoid
	 * N+1 query patterns. Implementations MUST resolve every supplied
	 * id in a single round-trip — typically a `SELECT ... WHERE id IN
	 * (...)` query — rather than fanning out one
	 * {@see self::findById()} call per id.
	 *
	 * Input handling:
	 *
	 *   - Empty input list returns an empty list with no DB round-
	 *     trip; this is a fast path for callers that gathered an
	 *     empty id set upstream.
	 *   - Duplicate ids in the input MUST be deduplicated before the
	 *     query is issued so the IN-clause stays minimal and the
	 *     returned list never carries the same record twice.
	 *   - Non-positive ids in the input list are a programming error
	 *     and implementations MUST raise
	 *     {@see \InvalidArgumentException} so the offending call
	 *     surfaces loudly rather than being silently dropped.
	 *
	 * Output handling:
	 *
	 *   - Returned records appear in the same order as the
	 *     deduplicated input ids — implementations build a
	 *     `id => record` lookup from the result set and then re-walk
	 *     the input order to pick present entries, so callers can
	 *     zip the returned list against their requested-id sequence
	 *     without an additional sort.
	 *   - "Not found" is silent: ids with no matching row are simply
	 *     omitted from the returned list. Callers that need to
	 *     reconcile present-vs-requested compare the returned record
	 *     ids against the requested id set themselves.
	 *
	 * @param list<int> $ids Positive ids to look up. Empty list
	 *                       allowed; duplicates are deduplicated.
	 *
	 * @return list<ProductRecord> Found records in deduplicated input
	 *                             order; missing ids omitted.
	 *
	 * @throws \InvalidArgumentException When `$ids` contains a
	 *                                   non-positive id.
	 * @throws RuntimeException          When the underlying driver
	 *                                   reports an error.
	 */
	public function findByIds( array $ids ): array;

	/**
	 * Look up a record by the `(provider, provider_item_id)` unique
	 * constraint from §4.3.
	 *
	 * Returns null when no row matches either component. Empty
	 * strings for either argument are a programming error and
	 * implementations MAY raise {@see \InvalidArgumentException}.
	 *
	 * @param string $provider       Provider identifier (for example
	 *                               `amazon`, `rakuten`, `yahoo`).
	 * @param string $providerItemId Provider-side item identifier.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error.
	 */
	public function findByProviderItem( string $provider, string $providerItemId ): ?ProductRecord;

	/**
	 * 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_products`.
	 *
	 * 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;
}
