<?php
/**
 * Embed-item repository contract.
 *
 * Narrow persistence port for the `affilun_embed_items` junction table
 * defined in `specifications/database-schema.md` §7. The table holds
 * the ordered association between a reusable embed definition
 * (`affilun_embeds`) and one or more products (`affilun_products`) for
 * single-card and grid display modes. Phase 2 introduces this port
 * primarily to back the KI-002 per-product render-cache invalidation
 * sweep — given a `product_id`, the
 * {@see \Affilun\Application\Cache\CacheInvalidator}
 * needs a fast, indexed lookup of every embed that references the
 * product so the targeted `forgetByPrefix()` call on the render
 * cache can replace the Phase 9 MVP overshoot
 * (`renderCache->flush()`).
 *
 * Design notes:
 *
 *   - The contract is deliberately narrow: it exposes only the
 *     reverse-lookup surface the cache invalidator needs
 *     ({@see self::findEmbedIdsByProductId()}) plus the minimal
 *     write path used by the embed-editing surface
 *     ({@see self::replaceEmbedItems()},
 *     {@see self::deleteByEmbedId()}). A future slice that needs to
 *     enumerate items for an embed (for diagnostics, REST surfaces,
 *     or the editor preview) can extend this contract without
 *     re-shaping the existing methods.
 *
 *   - {@see self::replaceEmbedItems()} owns the §7.1 "delete-then-
 *     insert" semantics: the Phase 9 design treats the junction
 *     row as immutable per insert (no `updated_at` column on the
 *     table; see `database-schema.md` §7.2), so the editing
 *     surface MUST clear the existing rows for an embed and
 *     re-insert the desired set rather than diff-and-update.
 *     Implementations SHOULD perform the delete + insert pair
 *     inside a single transaction when the underlying driver
 *     supports it so a partial failure does not leave the embed
 *     half-updated.
 *
 *   - {@see self::findEmbedIdsByProductId()} returns a `list<int>`
 *     of embed ids ordered by ascending embed id. The ordering is
 *     NOT a §7.3 spec requirement; it is fixed at the repository
 *     boundary so the cache invalidator's per-embed `forgetByPrefix()`
 *     loop is deterministic and tests can assert on the exact
 *     iteration order.
 *
 *   - The contract speaks in primitive arrays of integers rather
 *     than dedicated record types because the junction row carries
 *     no domain-meaningful state beyond the (embed_id, product_id,
 *     sort_order) triple. Wrapping that in a record type would add
 *     ceremony without enabling new behavior; the persistence
 *     layer's repository ports (per `EmbedRepository`,
 *     `ProductRepository`) reserve record types for tables that
 *     carry richer state.
 *
 *   - The `(embed_id, product_id)` uniqueness constraint declared
 *     in §7.3 is enforced by the schema; implementations SHOULD
 *     surface duplicate-insert failures as
 *     {@see \RuntimeException} rather than silently swallowing
 *     them, so a programming error in the editing surface
 *     (re-inserting a (embed_id, product_id) pair before the
 *     replace path runs) fails loudly.
 *
 * 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.
 *
 *   - "No matching items" is a success condition for read methods:
 *     {@see self::findEmbedIdsByProductId()} returns an empty list
 *     rather than null or throwing. This keeps the common case
 *     (probe for embed references on every sync, branch on
 *     emptiness) allocation-free and free of exception control
 *     flow.
 *
 *   - {@see self::deleteByEmbedId()} is idempotent: it returns the
 *     count of removed rows, with zero indicating "nothing
 *     matched". Neither outcome is an error.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;
use RuntimeException;

/**
 * Persistence port for `affilun_embed_items` rows.
 *
 * See the file-level docblock for the contract's scope, the
 * delete-then-insert semantics of {@see self::replaceEmbedItems()},
 * and the reverse-lookup ordering rule on
 * {@see self::findEmbedIdsByProductId()}.
 */
interface EmbedItemRepository {

	/**
	 * Find every embed id that references the supplied product.
	 *
	 * Backs the KI-002 per-product render-cache invalidation
	 * sweep: the cache invalidator iterates the returned ids and
	 * issues a targeted `forgetByPrefix('affilun:render:single:embed:N:')`
	 * / `forgetByPrefix('affilun:render:grid:embed:N:')` per id rather
	 * than flushing the whole render cache.
	 *
	 * Returns an empty list when no embed references the product,
	 * which is the typical case for products only embedded
	 * directly in post bodies via the Gutenberg block (the
	 * post-body path does not materialize a `affilun_embeds` row per
	 * `database-schema.md` §6.1 / §7.1). Callers MUST treat the
	 * empty list as a normal outcome and SHOULD NOT fall back to
	 * a broad render-cache flush — the post-body render keys are
	 * scoped under the `affilun:render:*:adhoc:*` prefix family,
	 * which the invalidator handles separately.
	 *
	 * Ordering: ascending embed id, fixed at the repository
	 * boundary so the cache-invalidator loop and its tests are
	 * deterministic. The §7.3 schema does not require a specific
	 * order for this lookup; the repository owns the choice.
	 *
	 * @param int $productId Local `affilun_products.id` to look up.
	 *                       Must be a positive integer.
	 *
	 * @return list<int> Ascending embed ids.
	 *
	 * @throws InvalidArgumentException When `$productId` is non-positive.
	 * @throws RuntimeException         When the underlying driver
	 *                                  reports an error.
	 */
	public function findEmbedIdsByProductId( int $productId ): array;

	/**
	 * Replace the items associated with `$embedId` with the
	 * supplied ordered product-id list.
	 *
	 * Implements the §7.1 "delete-then-insert" semantics: every
	 * existing junction row for `$embedId` is removed, then the
	 * supplied product ids are inserted with `sort_order` taken
	 * from their position in the list (0-based). An empty
	 * `$productIds` list clears the embed's items.
	 *
	 * Implementations SHOULD execute the delete + insert pair
	 * inside a single transaction when the driver supports it so
	 * a partial failure does not leave the embed half-updated.
	 *
	 * Returns the number of rows inserted (i.e. `count($productIds)`
	 * on a successful call). Callers can compare this against the
	 * input length to detect a partial-write anomaly the driver
	 * reported as success.
	 *
	 * Duplicate product ids in `$productIds` MUST raise
	 * {@see InvalidArgumentException} BEFORE any DB write so the
	 * caller observes the validation failure with the original
	 * embed state intact.
	 *
	 * @param int       $embedId    Positive `affilun_embeds.id`.
	 * @param list<int> $productIds Ordered product ids; positions
	 *                              become `sort_order` values.
	 *
	 * @return int Number of inserted rows.
	 *
	 * @throws InvalidArgumentException When `$embedId` is non-positive,
	 *                                  when `$productIds` contains a
	 *                                  non-positive integer, or when
	 *                                  `$productIds` contains
	 *                                  duplicates.
	 * @throws RuntimeException         When the underlying driver
	 *                                  reports an error.
	 */
	public function replaceEmbedItems( int $embedId, array $productIds ): int;

	/**
	 * Remove every junction row for the supplied embed id.
	 *
	 * Idempotent: returns the number of rows removed, with zero
	 * indicating "no rows matched". Neither outcome is an error.
	 *
	 * Used by the embed-deletion path (per the
	 * {@see EmbedRepository::delete()} contract note that callers
	 * are responsible for removing child rows before deleting the
	 * parent embed).
	 *
	 * @param int $embedId Positive `affilun_embeds.id`.
	 *
	 * @throws InvalidArgumentException When `$embedId` is non-positive.
	 * @throws RuntimeException         When the underlying driver
	 *                                  reports an error.
	 */
	public function deleteByEmbedId( int $embedId ): int;

	/**
	 * Total number of rows currently stored in `affilun_embed_items`.
	 *
	 * 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;
}
