<?php
/**
 * Product-meta repository contract.
 *
 * Narrow persistence port for the `affilun_product_meta` table defined in
 * `specifications/database-schema.md` §5. The contract mirrors the
 * shape established by {@see ProductRepository} / {@see EmbedRepository}
 * during Phase 2 slice 4a / 4b — a small CRUD surface
 * (save / findById / findByProductAndKey / delete / count) — and surfaces
 * the §5.3 composite unique axis `(product_id, meta_key)` as a
 * dedicated lookup so callers can probe for an existing row without
 * scanning the table.
 *
 * Design notes:
 *
 *   - The contract speaks in {@see ProductMetaRecord} 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 types
 *     (provider snapshots that flow optional attributes into the meta
 *     table) 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 composite unique constraint `(product_id, meta_key)` from
 *     §5.3 is surfaced as a dedicated lookup
 *     ({@see self::findByProductAndKey()}) so the common
 *     "upsert by product + key" pattern can probe for an existing
 *     row, decide insert vs. update, and keep the unique axis intact
 *     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.
 *
 *   - Bulk query surfaces (list every meta row for a given product,
 *     filter by key prefix, paginated traversal) and bulk delete
 *     surfaces (`deleteByProduct()` cascade when a product is removed)
 *     are deliberately NOT part of the Phase 2 contract. They will be
 *     added as dedicated methods — or a separate query-side port —
 *     when Phase 3 (`ProductSnapshot` reconstitution) and Phase 9
 *     (sync cascade) materialize, so the initial contract stays small
 *     and reversible. Callers that need cascade semantics today are
 *     responsible for enumerating ids and calling
 *     {@see self::delete()} per row.
 *
 *   - All timestamps on {@see ProductMetaRecord} 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_product_meta` 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 ProductMetaRepository {

	/**
	 * 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.
	 *
	 * The `(product_id, meta_key)` composite unique axis from §5.3
	 * MUST be honored by implementations. A save that would violate
	 * the constraint (insert a duplicate axis, or move an existing
	 * row onto an axis already occupied by another row) MUST raise a
	 * {@see RuntimeException}.
	 *
	 * @param ProductMetaRecord $record Record to persist.
	 *
	 * @return ProductMetaRecord The post-save record, always carrying
	 *                           a non-null id.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error, when the
	 *                          `(product_id, meta_key)` unique
	 *                          constraint is violated, or when an
	 *                          update targets a row that no longer
	 *                          exists.
	 */
	public function save( ProductMetaRecord $record ): ProductMetaRecord;

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

	/**
	 * Look up a record by its `(product_id, meta_key)` composite
	 * unique key from §5.3.
	 *
	 * Returns null when no row matches. Non-positive product ids and
	 * empty meta keys are programming errors and implementations MAY
	 * raise {@see \InvalidArgumentException}.
	 *
	 * This is the primary lookup path for the "upsert by product +
	 * key" pattern: callers probe for an existing row, branch on
	 * absence, and either insert a fresh record or update the
	 * returned one. The composite axis itself is enforced by the
	 * underlying unique constraint, so this method is consistent
	 * with {@see self::save()} by construction.
	 *
	 * @param int    $productId Owning `affilun_products.id` reference.
	 * @param string $metaKey   Meta key.
	 *
	 * @throws RuntimeException When the underlying driver reports an
	 *                          error.
	 */
	public function findByProductAndKey( int $productId, string $metaKey ): ?ProductMetaRecord;

	/**
	 * 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}.
	 *
	 * Note: cascade semantics for "delete every meta row owned by
	 * product X when X itself is removed" are NOT part of this
	 * contract. Callers that need cascade today are responsible for
	 * enumerating ids and calling this method per row; a dedicated
	 * `deleteByProduct()` helper will be added when the Phase 9 sync
	 * cascade materializes.
	 *
	 * @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_product_meta`.
	 *
	 * 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;
}
