<?php
/**
 * Prefix-based table name resolver.
 *
 * Default {@see TableNameResolver} implementation used in production.
 * Given a WordPress table prefix (typically `$wpdb->prefix`, e.g.
 * `wp_`), it concatenates that prefix with the supplied logical CLB
 * table name to produce the physical name.
 *
 * The prefix is captured once at construction time and held verbatim.
 * Call sites that need per-request prefix lookup (for example
 * multisite installations that switch between sites mid-request) should
 * construct a fresh resolver after each `switch_to_blog()` rather than
 * reusing a cached instance, since `$wpdb->prefix` is mutated by the
 * switch and the resolver's stability contract requires that repeated
 * calls within a single resolver's lifetime return the same physical
 * name.
 *
 * Validation:
 *
 *   - The constructor accepts any string (including empty). A genuinely
 *     empty prefix is a legitimate configuration on non-WordPress test
 *     beds and during fixture construction; treating it as an error
 *     here would force test code to invent a fake prefix it does not
 *     otherwise need.
 *
 *   - {@see self::resolve()} rejects an empty logical name with an
 *     {@see \InvalidArgumentException} because the only way to reach
 *     that path is a programming error at the call site (typically a
 *     forgotten constant). Failing loudly surfaces the bug instead of
 *     producing SQL that targets whatever table happens to share the
 *     site's prefix.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * {@see TableNameResolver} that prepends a fixed WordPress table prefix
 * to each logical CLB table name.
 *
 * See the file-level docblock for the prefix-capture semantics and the
 * empty-name validation rationale.
 */
final class PrefixedTableNameResolver implements TableNameResolver {

	/**
	 * WordPress table prefix captured at construction time.
	 *
	 * Typically `$wpdb->prefix` (for example `wp_` on a default install
	 * or `wp_2_` on the second site of a multisite network). Stored
	 * verbatim; the resolver performs no additional normalization so
	 * the physical name round-trips exactly what WordPress itself
	 * would produce.
	 */
	private string $prefix;

	/**
	 * Construct the resolver with a fixed WordPress table prefix.
	 *
	 * The prefix is captured verbatim and used for every subsequent
	 * {@see self::resolve()} call on this instance. Callers that need
	 * to follow a `switch_to_blog()` mutation of `$wpdb->prefix` must
	 * construct a fresh resolver after the switch; see the file-level
	 * docblock for the stability contract.
	 *
	 * @param string $prefix WordPress table prefix, typically
	 *                       `$wpdb->prefix` (for example `wp_`). An
	 *                       empty string is accepted for test-bed use.
	 */
	public function __construct( string $prefix ) {
		$this->prefix = $prefix;
	}

	/**
	 * Named constructor that captures the prefix from a `$wpdb`-like
	 * object exposing a public `prefix` property.
	 *
	 * Sugar over direct construction for production wiring code that
	 * would otherwise spell out `new PrefixedTableNameResolver( $wpdb->prefix )`.
	 * The parameter is typed as `object` rather than the concrete
	 * `\wpdb` class so this file stays trivially loadable in unit
	 * tests that run outside a WordPress environment.
	 *
	 * @param object $wpdb Object exposing a public string `prefix`
	 *                     property. Typically the `$wpdb` global.
	 *
	 * @throws InvalidArgumentException When the supplied object does
	 *                                  not expose a string `prefix`
	 *                                  property.
	 */
	public static function fromWpdb( object $wpdb ): self {
		if ( ! property_exists( $wpdb, 'prefix' ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException(
				'PrefixedTableNameResolver::fromWpdb() requires an object exposing a public "prefix" property.'
			);
		}

		/** @var mixed $prefix */
		$prefix = $wpdb->prefix;

		if ( ! is_string( $prefix ) ) {
			$rendered = is_scalar( $prefix )
				? sprintf( '%s(%s)', gettype( $prefix ), (string) $prefix )
				: gettype( $prefix );

			$message = sprintf(
				'PrefixedTableNameResolver::fromWpdb() expected a string "prefix" property, got %s.',
				$rendered
			);

			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return new self( $prefix );
	}

	/**
	 * WordPress table prefix this resolver was constructed with.
	 *
	 * Exposed primarily for diagnostics and for tests that want to
	 * verify the resolver captured the expected prefix without having
	 * to resolve a sentinel name.
	 */
	public function prefix(): string {
		return $this->prefix;
	}

	/**
	 * Resolve a logical CLB table name into its physical, prefix-applied form.
	 *
	 * Concatenates the captured prefix with the supplied logical name
	 * verbatim. Rejects an empty logical name with an
	 * {@see InvalidArgumentException} because the only way to reach
	 * that path is a programming error at the call site; see the
	 * file-level docblock for rationale.
	 *
	 * @param string $logicalName Logical, unprefixed CLB table name
	 *                            (for example `clb_products`). Must
	 *                            be non-empty.
	 *
	 * @return string Physical table name suitable for direct
	 *                interpolation into SQL.
	 *
	 * @throws InvalidArgumentException When `$logicalName` is empty.
	 */
	public function resolve( string $logicalName ): string {
		if ( $logicalName === '' ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException(
				'PrefixedTableNameResolver::resolve() requires a non-empty logical table name.'
			);
		}

		return $this->prefix . $logicalName;
	}
}
