<?php

namespace MediaWiki\Maintenance;

use Doctrine\SqlFormatter\NullHighlighter;
use Doctrine\SqlFormatter\SqlFormatter;
use JsonException;
use MediaWiki\DB\AbstractSchemaValidationError;
use MediaWiki\DB\AbstractSchemaValidator;
use Wikimedia\Rdbms\DoctrineSchemaBuilderFactory;

/**
 * Helper to generate abstract schema and schema changes in maintenance scripts.
 */
class SchemaGenerator {
	/**
	 * Fetches the abstract schema.
	 *
	 * @param string $jsonPath
	 * @return array
	 * @throws AbstractSchemaValidationError
	 */
	public function validateAndGetSchema( string $jsonPath ): array {
		$json = file_get_contents( $jsonPath );

		if ( !$json ) {
			throw new AbstractSchemaValidationError( "'$jsonPath' does not exist!" );
		}

		try {
			$abstractSchema = json_decode( $json, true, 512, JSON_THROW_ON_ERROR );
		} catch ( JsonException $e ) {
			throw new AbstractSchemaValidationError( "Invalid JSON schema: " . $e->getMessage(), 0, $e );
		}

		$validator = new AbstractSchemaValidator();
		$validator->validate( $jsonPath );

		return $abstractSchema;
	}

	/**
	 * @throws AbstractSchemaValidationError
	 */
	public function generateSchema( string $platform, string $jsonPath ): string {
		$abstractSchemaChange = $this->validateAndGetSchema( $jsonPath );

		$sql = $this->makeSQLComment( 'generateSchemaSql.php', $jsonPath );

		$schemaBuilder = ( new DoctrineSchemaBuilderFactory() )->getSchemaBuilder( $platform );

		foreach ( $abstractSchemaChange as $table ) {
			$schemaBuilder->addTable( $table );
		}
		$tableSqls = $schemaBuilder->getSql();

		$sql .= $this->cleanupSqlArray( $platform, $tableSqls );

		return $sql;
	}

	/**
	 * @throws AbstractSchemaValidationError
	 */
	public function generateSchemaChange( string $platform, string $jsonPath ): string {
		$abstractSchemaChange = $this->validateAndGetSchema( $jsonPath );

		$sql = $this->makeSQLComment( 'generateSchemaChangeSql.php', $jsonPath );

		$schemaChangeBuilder = ( new DoctrineSchemaBuilderFactory() )->getSchemaChangeBuilder( $platform );

		$schemaChangeSqls = $schemaChangeBuilder->getSchemaChangeSql( $abstractSchemaChange );
		if ( !$schemaChangeSqls ) {
			throw new AbstractSchemaValidationError( "No schema changes detected!" );
		}

		$sql .= $this->cleanupSqlArray( $platform, $schemaChangeSqls );

		return $sql;
	}

	private function makeSQLComment( string $scriptName, string $jsonPath ): string {
		global $IP, $wgExtensionDirectory;

		$canonicalJsonPath = $this->normalizePath( $jsonPath, $IP, $wgExtensionDirectory );

		return "-- This file is automatically generated using maintenance/$scriptName.\n" .
			"-- Source: $canonicalJsonPath\n" .
			"-- Do not modify this file directly.\n" .
			"-- See https://www.mediawiki.org/wiki/Manual:Schema_changes\n";
	}

	/**
	 * Make absolute path relative to the root of $IP or extension
	 *
	 * @param string $jsonPath Path to an existing schema file
	 * @param string $IP Root of MediaWiki core installation
	 * @param string $extensionDirectory Root of dir holding MediaWiki extensions
	 * @return string the relative path
	 */
	public static function normalizePath( string $jsonPath, string $IP, string $extensionDirectory ): string {
		$installPath = realpath( $IP );
		$jsonPath = realpath( $jsonPath );
		$extensionDirectory = realpath( $extensionDirectory );

		// For windows
		if ( DIRECTORY_SEPARATOR === '\\' ) {
			$installPath = strtr( $installPath, '\\', '/' );
			$jsonPath = strtr( $jsonPath, '\\', '/' );
			$extensionDirectory = strtr( $extensionDirectory, '\\', '/' );
		}

		if ( str_starts_with( $jsonPath, $extensionDirectory ) ) {
			// Strip $wgExtensionDirectory which might be configured outside of $IP
			// /path/to/mw-ext-all/Example/bar -> Example/bar
			$canonicalJsonPath = str_replace( "$extensionDirectory/", '', $jsonPath );

			// Strip the extension name
			// Example/bar -> bar
			$canonicalJsonPath = explode( '/', $canonicalJsonPath, 2 )[1];
		} else {
			// For mediawiki/core we strip $IP
			// /path/to/mediawiki/foo/bar                -> foo/bar
			$canonicalJsonPath = str_replace( "$installPath/", '', $jsonPath );
		}

		return $canonicalJsonPath;
	}

	/**
	 * Takes the output of DoctrineSchemaBuilder::getSql() or
	 * DoctrineSchemaChangeBuilder::getSchemaChangeSql() and applies presentational changes.
	 *
	 * @param string $platform DB engine identifier
	 * @param array $sqlArray Array of SQL statements
	 * @return string
	 */
	private function cleanupSqlArray( string $platform, array $sqlArray ): string {
		if ( !$sqlArray ) {
			return '';
		}

		// Temporary
		$sql = implode( ";\n\n", $sqlArray ) . ';';
		$sql = ( new SqlFormatter( new NullHighlighter() ) )->format( $sql );

		// Postgres hacks
		if ( $platform === 'postgres' ) {
			// FIXME: Fix a lot of weird formatting issues caused by
			//   presence of partial index's WHERE clause, this should probably
			//   be done in some better way, but for now this can work temporarily
			$sql = str_replace(
				[ "WHERE\n ", "\n  /*_*/\n  ", "    ", "  );", "KEY(\n  " ],
				[ "WHERE", ' ', "  ", ');', "KEY(\n    " ],
				$sql
			);
		}

		// Temporary fixes until the linting issues are resolved upstream.
		// https://github.com/doctrine/sql-formatter/issues/53

		$sql = preg_replace( "!\s+/\*_\*/\s+!", " /*_*/", $sql );
		$sql = preg_replace(
			'!\s+/\*\$wgDBTableOptions\*/\s+;!',
			' /*$wgDBTableOptions*/;',
			$sql
		);

		$sql = str_replace( "; CREATE ", ";\n\nCREATE ", $sql );
		$sql = str_replace( ";\n\nCREATE TABLE ", ";\n\n\nCREATE TABLE ", $sql );
		$sql = preg_replace( '/^(CREATE|DROP|ALTER)\s+(TABLE|VIEW|INDEX)\s+/m', '$1 $2 ', $sql );
		$sql = preg_replace( '/(?<!\s|;)\s+(ADD|DROP|ALTER|MODIFY|CHANGE|RENAME)\s+/', "\n  \$1 ", $sql );

		$sql = str_replace( "; ", ";\n", $sql );

		if ( !str_ends_with( $sql, "\n" ) ) {
			$sql .= "\n";
		}

		// Sqlite hacks
		if ( $platform === 'sqlite' ) {
			// Doctrine prepends __temp__ to the table name and we set the table with the schema prefix causing invalid
			// sqlite.
			$sql = preg_replace( '/__temp__\s*\/\*_\*\//', '/*_*/__temp__', $sql );
		}

		return $sql;
	}
}
