<?php
/**
 * Abstract base class for `CREATE TABLE` migrations.
 *
 * affilun's initial schema pipeline (schema versions
 * 1..N) consists of a sequence of migrations, each of which installs a
 * single plugin-owned table described in
 * `specifications/database-schema.md` §4–§10. The per-table migrations
 * share a narrow set of concerns: resolve the logical table name into
 * its physical prefix-applied form, assemble a `CREATE TABLE` DDL
 * string that follows WordPress's `dbDelta()` formatting rules, and
 * hand the result to the injected {@see SchemaInstaller}. This base
 * class captures that shared skeleton so subclasses only have to
 * declare the bits that actually differ between tables:
 *
 *   - {@see self::targetVersion()}      — schema revision installed.
 *   - {@see self::description()}        — short operator-friendly label.
 *   - {@see self::logicalTableName()}   — logical, unprefixed AFFILUN name
 *                                         (for example `affilun_products`).
 *   - {@see self::columnDefinitions()}  — list of column definition
 *                                         fragments (one entry per
 *                                         column, without the trailing
 *                                         comma).
 *   - {@see self::indexDefinitions()}   — list of PRIMARY / UNIQUE /
 *                                         KEY fragments (one entry per
 *                                         index, without the trailing
 *                                         comma).
 *
 * Formatting guarantees provided by this base class (so subclasses do
 * not have to re-implement — or accidentally skip — any of them):
 *
 *   - `dbDelta()` formatting rules are honored uniformly: one column
 *     per line, two-space indent on every definition line, commas
 *     between definitions, no trailing comma before the closing paren,
 *     and the two-space separator after `PRIMARY KEY` that
 *     `dbDelta()` relies on to detect the primary key line. Subclasses
 *     must emit `PRIMARY KEY  (col)` (two spaces) in their index list;
 *     the base class does not rewrite the text.
 *
 *   - Engine and charset clauses are always `ENGINE=InnoDB` followed
 *     by the injected charset/collation clause (per `database-schema.md`
 *     §17.1–17.2). An empty injected clause falls back to
 *     `DEFAULT CHARSET=utf8mb4` so the emitted DDL is still valid on
 *     test beds that do not surface `$wpdb->get_charset_collate()`.
 *
 *   - The physical table name is resolved through the injected
 *     {@see TableNameResolver} exactly once per {@see self::up()} call
 *     and passed to the subclass's DDL-assembly path, so repeated
 *     calls to {@see self::tableName()} from tests and diagnostics
 *     stay consistent with the name used in the actually-emitted SQL.
 *
 * Idempotency and failure semantics are inherited from the
 * {@see Migration} contract and the {@see SchemaInstaller} it delegates
 * to: `dbDelta()` is already idempotent at the DDL level, and any
 * exception raised by the installer propagates verbatim so the
 * {@see \Affilun\Infrastructure\Persistence\MigrationRunner}
 * can treat the step as failed.
 *
 * Per the {@see Migration} contract, subclasses MUST NOT persist the
 * schema version themselves; that responsibility belongs to the
 * runner, which writes to the
 * {@see \Affilun\Infrastructure\Persistence\SchemaVersionRepository}
 * after {@see self::up()} returns without throwing.
 *
 * @package Affilun\Infrastructure\Persistence\Migrations
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence\Migrations;

use Affilun\Infrastructure\Persistence\Migration;
use Affilun\Infrastructure\Persistence\SchemaInstaller;
use Affilun\Infrastructure\Persistence\SchemaVersion;
use Affilun\Infrastructure\Persistence\TableNameResolver;

/**
 * Shared skeleton for migrations that install a single AFFILUN table.
 *
 * See the file-level docblock for the template-method contract that
 * subclasses implement and the formatting guarantees this base class
 * provides.
 */
abstract class AbstractCreateTableMigration implements Migration {

	/**
	 * Schema installer used to apply the `CREATE TABLE` DDL.
	 *
	 * Injected via the constructor so subclasses remain unit-testable
	 * against an in-memory fake that captures the issued SQL; the
	 * production wiring code passes
	 * {@see \Affilun\Infrastructure\Persistence\WpdbSchemaInstaller}.
	 */
	private SchemaInstaller $installer;

	/**
	 * Resolver used to map each subclass's logical table name to the
	 * physical, prefix-applied table name that goes into the DDL.
	 *
	 * Injected via the constructor so tests can resolve to
	 * deterministic names (for example `test_affilun_products`) without
	 * standing up a `$wpdb` instance.
	 */
	private TableNameResolver $tableNames;

	/**
	 * Charset and collation clause appended to the `CREATE TABLE`
	 * statement.
	 *
	 * In production this is `$wpdb->get_charset_collate()`, which
	 * returns a string such as
	 * `DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci`.
	 * Injected as a plain string so tests can supply a stable value
	 * (typically `DEFAULT CHARSET=utf8mb4`) and assert on the emitted
	 * SQL without instantiating `$wpdb`.
	 *
	 * An empty string is accepted and triggers the
	 * `DEFAULT CHARSET=utf8mb4` fallback in
	 * {@see self::buildCreateTableSql()}; the resulting DDL is still
	 * valid, though the table will use the server's default collation
	 * rather than WordPress's configured one.
	 */
	private string $charsetCollate;

	/**
	 * Construct the migration with its explicit collaborators.
	 *
	 * @param SchemaInstaller   $installer      Installer used to apply the DDL.
	 * @param TableNameResolver $tableNames     Resolver that produces the
	 *                                          physical table name.
	 * @param string            $charsetCollate Charset / collation clause
	 *                                          appended to the
	 *                                          `CREATE TABLE` statement.
	 *                                          Production callers pass
	 *                                          `$wpdb->get_charset_collate()`;
	 *                                          an empty string triggers
	 *                                          the `DEFAULT CHARSET=utf8mb4`
	 *                                          fallback.
	 */
	public function __construct(
		SchemaInstaller $installer,
		TableNameResolver $tableNames,
		string $charsetCollate
	) {
		$this->installer      = $installer;
		$this->tableNames     = $tableNames;
		$this->charsetCollate = $charsetCollate;
	}

	/**
	 * Target schema version installed by this migration.
	 *
	 * Subclasses return the revision they install (for example
	 * `SchemaVersion::of( 1 )` for the initial `affilun_products` table).
	 * See `database-schema.md` §13.2 for the canonical version
	 * numbering.
	 */
	abstract public function targetVersion(): SchemaVersion;

	/**
	 * Short, human-readable description used in diagnostics and logs.
	 *
	 * Kept terse and operator-friendly per the
	 * {@see Migration::description()} contract, for example
	 * `"create affilun_products table"`.
	 */
	abstract public function description(): string;

	/**
	 * Logical, unprefixed AFFILUN table name installed by this migration.
	 *
	 * The returned string is the bare identifier used in
	 * `database-schema.md` (for example `affilun_products`,
	 * `affilun_embed_items`). It is passed through the injected
	 * {@see TableNameResolver} to produce the physical name used in
	 * the emitted DDL.
	 */
	abstract protected function logicalTableName(): string;

	/**
	 * Column definition fragments for this table, in the order they
	 * should appear in the emitted DDL.
	 *
	 * Each entry is a single column definition without its trailing
	 * comma and without the leading indent — for example
	 * `"id bigint unsigned NOT NULL AUTO_INCREMENT"` or
	 * `"sync_state varchar(16) NOT NULL DEFAULT 'fresh'"`. The base
	 * class adds the two-space indent, inter-column commas, and line
	 * breaks when assembling the final SQL.
	 *
	 * @return list<string>
	 */
	abstract protected function columnDefinitions(): array;

	/**
	 * Index definition fragments for this table, in the order they
	 * should appear in the emitted DDL.
	 *
	 * Each entry is a single PRIMARY / UNIQUE / KEY clause without
	 * its trailing comma and without the leading indent — for example
	 * `"PRIMARY KEY  (id)"` (note the two-space separator that
	 * `dbDelta()` requires), `"UNIQUE KEY uq_provider_item (provider, provider_item_id)"`,
	 * or `"KEY idx_created_at (created_at)"`. The base class adds the
	 * two-space indent, inter-index commas, and line breaks when
	 * assembling the final SQL.
	 *
	 * The list MUST be non-empty: every AFFILUN table declares at least a
	 * primary key, and an empty index list is therefore a programming
	 * error that the base class does not attempt to mask.
	 *
	 * @return list<string>
	 */
	abstract protected function indexDefinitions(): array;

	/**
	 * Apply the schema change.
	 *
	 * Resolves the physical table name, assembles the canonical DDL
	 * from the subclass's column and index manifests, and hands the
	 * resulting SQL to the injected {@see SchemaInstaller}. Per the
	 * {@see Migration} contract, this method does NOT persist the
	 * schema version; that is the runner's responsibility.
	 *
	 * Any exception raised by the installer propagates verbatim so the
	 * {@see \Affilun\Infrastructure\Persistence\MigrationRunner}
	 * can treat the step as failed and leave the schema version at
	 * the previous revision.
	 */
	public function up(): void {
		$tableName = $this->tableNames->resolve( $this->logicalTableName() );

		$sql = $this->buildCreateTableSql( $tableName );

		$this->installer->install( $sql );
	}

	/**
	 * Physical table name this migration installs, resolved through
	 * the injected {@see TableNameResolver}.
	 *
	 * Exposed so tests and diagnostics can ask for the resolved name
	 * without replaying the full `up()` path. Not part of the
	 * {@see Migration} contract; production call sites should prefer
	 * {@see self::up()}.
	 */
	public function tableName(): string {
		return $this->tableNames->resolve( $this->logicalTableName() );
	}

	/**
	 * Assemble the canonical `CREATE TABLE` DDL from the subclass's
	 * column and index manifests.
	 *
	 * The formatting follows the conventions that `dbDelta()` requires
	 * (one column per line, two-space indent, explicit index names,
	 * no trailing comma, `ENGINE=InnoDB` followed by the charset
	 * clause). An empty injected charset clause falls back to
	 * `DEFAULT CHARSET=utf8mb4` so the emitted DDL is still valid on
	 * test beds that do not surface `$wpdb->get_charset_collate()`.
	 *
	 * Kept `final` because the exact SQL shape is part of this base
	 * class's contract with the {@see SchemaInstaller}; subclasses
	 * customize the payload through the column / index / name hooks
	 * rather than by rewriting the assembled string.
	 *
	 * @param string $tableName Physical table name to create.
	 */
	final protected function buildCreateTableSql( string $tableName ): string {
		$charsetClause = $this->charsetCollate === ''
			? 'DEFAULT CHARSET=utf8mb4'
			: $this->charsetCollate;

		$definitions = array();

		foreach ( $this->columnDefinitions() as $column ) {
			$definitions[] = '  ' . $column;
		}

		foreach ( $this->indexDefinitions() as $index ) {
			$definitions[] = '  ' . $index;
		}

		$body = implode( ",\n", $definitions );

		return sprintf( "CREATE TABLE %s (\n", $tableName )
			. $body . "\n"
			. sprintf( ') ENGINE=InnoDB %s;', $charsetClause );
	}
}
