<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 */

namespace MediaWiki\Tests\Structure;

use Doctrine\SqlFormatter\NullHighlighter;
use Doctrine\SqlFormatter\SqlFormatter;
use Generator;
use MediaWiki\DB\AbstractSchemaValidationError;
use MediaWiki\DB\AbstractSchemaValidator;
use MediaWiki\Maintenance\SchemaGenerator;
use MediaWikiIntegrationTestCase;

/**
 * Validates all abstract schemas against the abstract-schema schemas in the docs/ folder, and makes sure that
 * they match the autogenerated output.
 *
 * Extensions can extend this class, implementing its abstract methods, to validate their DB schemas.
 */
abstract class AbstractSchemaTestBase extends MediaWikiIntegrationTestCase {
	private AbstractSchemaValidator $validator;

	protected function setUp(): void {
		parent::setUp();

		$this->validator = new AbstractSchemaValidator();
	}

	/**
	 * @dataProvider provideSchemas
	 * @param string $path Path to tables.json file
	 */
	public function testSchemaIsValid( string $path ): void {
		try {
			$this->validator->validate( $path );
			// All good
			$this->addToAssertionCount( 1 );
		} catch ( AbstractSchemaValidationError $e ) {
			$this->fail( "The abstract schema in $path is not valid: " . $e->getMessage() );
		}
	}

	/**
	 * @dataProvider provideSchemaChanges
	 * @param string $path Path to the schema change JSON file
	 */
	public function testSchemaChangesAreValid( string $path ): void {
		try {
			$this->validator->validate( $path );
			// All good
			$this->addToAssertionCount( 1 );
		} catch ( AbstractSchemaValidationError $e ) {
			$this->fail( "The abstract schema change in $path is not valid: " . $e->getMessage() );
		}
	}

	/**
	 * @dataProvider provideSchemas
	 */
	public function testSchemasHaveAutoGeneratedFiles( string $jsonPath ) {
		$jsonName = basename( $jsonPath );
		// Same as SchemaMaintenance::getSqlPathWithFileName()
		$nameReplacement = str_starts_with( $jsonName, 'tables' ) ? '-generated.sql' : '.sql';
		$expectedSQLName = str_replace( '.json', $nameReplacement, $jsonName );

		foreach ( static::getSchemaSQLDirs() as $platform => $dir ) {
			$expectedSQLPath = rtrim( $dir, '/' ) . '/' . $expectedSQLName;
			$realSQLPath = realpath( $expectedSQLPath );
			$this->assertFileExists(
				$expectedSQLPath,
				"Schema file $realSQLPath for $platform not found. Generate it using generateSchemaSql.php."
			);
			$actualSQL = file_get_contents( $expectedSQLPath );
			$expectedSQL = ( new SchemaGenerator() )->generateSchema( $platform, $jsonPath );
			$this->assertSQLSame(
				$expectedSQL,
				$actualSQL,
				"SQL in $realSQLPath for $platform does not appear to be autogenerated. Re-generate it " .
				"using generateSchemaSql.php."
			);
		}
	}

	/**
	 * @dataProvider provideSchemaChanges
	 */
	public function testSchemaChangesHaveAutoGeneratedFiles( string $jsonPath ) {
		$expectedSQLName = str_replace( '.json', '.sql', basename( $jsonPath ) );

		$foundOne = false;
		foreach ( static::getSchemaChangesSQLDirs() as $platform => $dir ) {
			$expectedSQLPath = rtrim( $dir, '/' ) . '/' . $expectedSQLName;
			if ( !file_exists( $expectedSQLPath ) ) {
				// Schema changes don't necessarily have to exist for all platforms. One is enough.
				continue;
			}
			$foundOne = true;
			$actualSQL = file_get_contents( $expectedSQLPath );
			$expectedSQL = ( new SchemaGenerator() )->generateSchemaChange( $platform, $jsonPath );
			$realSQLPath = realpath( $expectedSQLPath );
			$this->assertSQLSame(
				$expectedSQL,
				$actualSQL,
				"SQL in $realSQLPath for $platform does not appear to be autogenerated. Re-generate it " .
				"using generateSchemaChangeSql.php."
			);
		}
		$realJSONPath = realpath( $jsonPath );
		$this->assertTrue(
			$foundOne,
			"Did not find SQL files for any platforms for schema change $realJSONPath. Generate them using " .
			"generateSchemaChangeSql.php."
		);
	}

	/**
	 * Asserts that two SQL strings are identical, modulo formatting (whitespace) differences. We can't just compare
	 * the raw output, otherwise changing the format would be massively painful due to test failures everywhere.
	 */
	private function assertSQLSame( string $expected, string $actual, string $message ): void {
		$formatter = new SqlFormatter( new NullHighlighter() );
		$this->assertSame(
			$formatter->format( $expected ),
			$formatter->format( $actual ),
			$message
		);
	}

	final public static function provideSchemas(): Generator {
		$schemaDir = rtrim( static::getSchemasDirectory(), '/' ) . '/';
		foreach ( glob( $schemaDir . '*.json' ) as $schemaFile ) {
			yield basename( $schemaFile ) => [ $schemaFile ];
		}
	}

	final public static function provideSchemaChanges(): Generator {
		$schemaChangesDir = rtrim( static::getSchemaChangesDirectory(), '/' ) . '/';
		foreach ( glob( $schemaChangesDir . '*.json' ) as $schemaChange ) {
			yield basename( $schemaChange ) => [ $schemaChange ];
		}
	}

	/**
	 * Returns the path to a directory with JSON files for the schema. All JSON files in this directory are assumed
	 * to be schema files and tested accordingly.
	 */
	abstract protected static function getSchemasDirectory(): string;

	/**
	 * Returns the path to a directory with schema change JSON files. All JSON files in this directory are assumed
	 * to be schema change files and tested accordingly.
	 */
	abstract protected static function getSchemaChangesDirectory(): string;

	/**
	 * Returns a map of supported DB types to the path of a directory with the .sql files for that DB type's schema.
	 */
	abstract protected static function getSchemaSQLDirs(): array;

	/**
	 * Returns a map of supported DB types to the path of a directory with the .sql schema change files for that DB
	 * type. Uses the same directory as {@see self::getSchemaSQLDirs} by default.
	 */
	protected static function getSchemaChangesSQLDirs(): array {
		return static::getSchemaSQLDirs();
	}
}
