<?php

namespace WPDRMS\ASP\Synonyms;

use WPDRMS\ASP\Synonyms\ORM\Synonym;
use WPDRMS\PluginCore\Traits\SingletonTrait;
use WPDRMS\Utils\MB;

defined('ABSPATH') || die("You can't access this file directly.");

class SynonymsService {
	use SingletonTrait;

	/**
	 * Nested array of synonyms: [lang][keyword] => string[]
	 *
	 * @var array<string, array<string, string[]>>
	 */
	private array $synonyms = array();

	private bool $initialized = false;

	/**
	 * False until synonymsAsKeywords() has been called.
	 *
	 * @var bool
	 */
	private bool $converted = false;

	/**
	 * Gets keyword synonyms, based on keyword+language. Returns all synonyms when $keyword is empty.
	 *
	 * @param string $keyword
	 * @param string $language
	 * @param bool   $refresh
	 * @return array<string, array<string, string[]>>|string[]|false
	 */
	public function get( string $keyword = '', string $language = '', bool $refresh = false ) {
		if ( $keyword === '' ) {
			if ( $refresh || !$this->initialized ) {
				$this->init();
			}
			return $this->synonyms;
		}

		$keyword = $this->processKeyword($keyword);
		if ( $keyword !== '' ) {
			if ( $refresh || !$this->initialized ) {
				$this->init();
			}
			$lang = $language === '' ? 'default' : $language;
			if ( isset($this->synonyms[ $lang ], $this->synonyms[ $lang ][ $keyword ]) ) {
				return $this->synonyms[ $lang ][ $keyword ];
			}
		}

		return false;
	}

	/**
	 * Checks if a keyword+language pair has synonyms.
	 * If $keyword is empty, checks if any synonyms exist at all.
	 *
	 * @param string $keyword
	 * @param string $language
	 */
	public function exists( string $keyword = '', string $language = '' ): bool {
		$keyword = $this->processKeyword($keyword);
		if ( !$this->initialized ) {
			$this->init();
		}
		if ( $keyword === '' ) {
			return count($this->synonyms) > 0;
		}
		$lang = $language === '' ? 'default' : $language;
		return isset($this->synonyms[ $lang ], $this->synonyms[ $lang ][ $keyword ]);
	}

	/**
	 * Adds a keyword and synonyms to the database.
	 *
	 * @param string          $keyword
	 * @param string|string[] $synonyms
	 * @param string          $language
	 * @return int number of rows inserted
	 */
	public function add( string $keyword, $synonyms, string $language = '' ): int {
		$synonyms_arr = $this->processSynonyms($synonyms);
		$keyword      = $this->processKeyword($keyword);
		$language     = $this->processKeyword($language);

		if ( count($synonyms_arr) < 1 || $keyword === '' ) {
			return 0;
		}
		return Synonym::addIgnore($keyword, implode(',', $synonyms_arr), $language);
	}

	/**
	 * Updates a row based on $keyword+$lang. Creates it if it doesn't exist.
	 *
	 * @param string          $keyword
	 * @param string|string[] $synonyms
	 * @param string          $language
	 * @param bool            $overwrite_existing
	 * @return int number of affected rows
	 */
	public function update( string $keyword, $synonyms, string $language = '', bool $overwrite_existing = true ): int {
		$synonyms_arr = $this->processSynonyms($synonyms);
		$keyword      = $this->processKeyword($keyword);
		$language     = $this->processKeyword($language);

		if ( count($synonyms_arr) < 1 || $keyword === '' ) {
			return 0;
		}

		if ( !$this->exists($keyword, $language) ) {
			return $this->add($keyword, $synonyms_arr, $language);
		}
		if ( $overwrite_existing ) {
			return Synonym::updateSynonyms($keyword, implode(',', $synonyms_arr), $language);
		}
		return 0;
	}

	/**
	 * Converts synonyms into keyword => synonym pairs (bidirectional expansion).
	 *
	 * @noinspection PhpUnused
	 */
	public function synonymsAsKeywords(): void {
		if ( !$this->initialized ) {
			$this->init();
		}
		if ( count($this->synonyms) > 0 && !$this->converted ) {
			foreach ( $this->synonyms as $lang => $synonyms ) {
				$additional_syns = array();
				foreach ( $synonyms as $kw => $syns ) {
					foreach ( $syns as $syn ) {
						if ( !isset($additional_syns[ $syn ]) ) {
							$additional_syns[ $syn ] = array_merge(
								array( $kw ),
								array_diff($syns, array( $syn ))
							);
						}
					}
				}
				if ( count($additional_syns) > 0 ) {
					$this->synonyms[ $lang ] = array_merge(
						$this->synonyms[ $lang ],
						$additional_syns
					);
				}
			}
			$this->converted = true;
		}
	}

	/**
	 * Deletes a row based on keyword+language keys.
	 *
	 * @param string $keyword
	 * @param string $language
	 * @return int number of affected rows
	 */
	public function delete( string $keyword, string $language = '' ): int {
		$keyword = $this->processKeyword($keyword);
		if ( $keyword !== '' ) {
			return Synonym::deleteByKeywordLang($keyword, $language);
		}
		return 0;
	}

	/**
	 * Deletes a row by ID.
	 *
	 * @param int $id
	 * @return int number of affected rows
	 * @noinspection PhpUnused
	 */
	public function deleteByID( int $id ): int {
		if ( $id === 0 ) {
			return 0;
		}
		$model = Synonym::find($id);
		return ( $model !== null && $model->delete() ) ? 1 : 0;
	}

	/** Deletes all rows from the database table. */
	public function wipe(): void {
		Synonym::truncateTable();
	}

	/**
	 * Looks for a synonym based on keyword and language.
	 * If keyword is empty, lists all results from the language.
	 *
	 * @param string $keyword
	 * @param string $language  When 'any', looks in all languages. '' is the default language.
	 * @param int    $limit
	 * @param bool   $exact
	 * @param int    $offset
	 * @return array<int, array<string, string>>
	 */
	public function find( string $keyword = '', string $language = '', int $limit = 30, bool $exact = false, int $offset = 0 ): array {
		return Synonym::search($this->processKeyword($keyword), $language, $limit, $exact, $offset);
	}

	/**
	 * Returns the total number of rows matching a search (for pagination).
	 *
	 * @param string $keyword
	 * @param string $language
	 * @return int
	 */
	public function count( string $keyword = '', string $language = '' ): int {
		return Synonym::countSearch($this->processKeyword($keyword), $language);
	}

	/**
	 * Creates the synonyms table. Delegates to the ORM model.
	 *
	 * @return string[] empty array (return type kept for backward compatibility)
	 */
	public function createTable(): array {
		global $wpdb;
		$table = Synonym::getTableName();

		// On upgrades, earlier schema versions left two overlapping indexes that cause
		// dbDelta to fail with "Duplicate key name". Drop them only when the table exists
		// (on a fresh install the table is absent and this block is skipped entirely).
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
			$rows    = $wpdb->get_results( "SHOW INDEX FROM `{$table}` WHERE Key_name IN ('keyword', 'keyword_lang')", ARRAY_A );
			$to_drop = array_unique( array_column( is_array( $rows ) ? $rows : array(), 'Key_name' ) );
			foreach ( $to_drop as $index_name ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
				$wpdb->query( "ALTER TABLE `{$table}` DROP INDEX `{$index_name}`" );
			}
		}

		Synonym::createTable();
		return array();
	}

	/** Initializes the in-memory synonyms cache from the database. */
	private function init(): void {
		$this->synonyms = array();
		foreach ( Synonym::findBy(array(), 15000, 0, 'id', 'DESC') as $row ) {
			$lang = $row->lang === '' ? 'default' : $row->lang;
			if ( !isset($this->synonyms[ $lang ]) ) {
				$this->synonyms[ $lang ] = array();
			}
			$this->synonyms[ $lang ][ $row->keyword ] = wpd_comma_separated_to_array($row->synonyms);
		}
		$this->initialized = true;
	}

	/**
	 * Normalizes synonyms input to a cleaned string array.
	 *
	 * @param string|string[] $synonyms
	 * @return string[]
	 */
	private function processSynonyms( $synonyms ): array {
		$joined       = is_array($synonyms) ? implode(',', $synonyms) : $synonyms;
		$synonyms_arr = wpd_comma_separated_to_array($joined);

		foreach ( $synonyms_arr as &$w ) {
			$w = MB::strtolower($w);
			$w = trim( (string) preg_replace('/\s+/', ' ', $w));
		}

		return $synonyms_arr;
	}

	/**
	 * Normalizes a keyword: lowercase, trimmed, collapsed whitespace.
	 */
	private function processKeyword( string $keyword ): string {
		return (string) preg_replace('/\s+/', ' ', MB::strtolower(trim($keyword)));
	}
}
