<?php

// TODO: [notImportant] sa pot sa adaug functii pe coloane (si cele std si custom)

// TODO: [notImportant] trateaza coloane disabled - sa nu pot sa le adaug din interfata

// TODO: [notImportant] coloanele disabled ar trb si ele traduse, caci e nevoie de ele sa fie afisate ergonomic la administrare raport (eg. coloanele de

// TODO: [refactorReportSerialization] portare rapoarte vechi - trebuie sa adaug tipul coloanei (dbDef) la toate custom columns deja definite (in serializare)

namespace App\Reports\Sources;

abstract class ColumnsManagerAbstract
{

	/**
	 * Associative array of all standard columns as defined in the current report source.
	 * @var array
	 */
	protected array $columnsCfg = [];

	/**
	 * Associative array of standard columns (the key is the column code).
	 * @var array
	 */
	protected array $stdColumns = [];

	/**
	 * Associative array of custom columns (the key is the column code).
	 * @var array
	 */
	protected array $customColumns = [];

	/**
	 * The report table primary key column.
	 * @var string
	 */
	protected string $primaryKeyColumn;

	/**
	 * The columns that are forced into the report.
	 * @var array
	 */
	protected array $forcedColumns;

	/**
	 * The columns dependencies list.
	 * @var array
	 */
	protected array $columnsDependencies;


	protected bool $dependenciesIncluded = false;

	/**
	 * Check that the columnsCfg are always defined.
	 */
	public function __construct()
	{
		if (!$this->columnsCfg) {
			throw new \Exception('This report source does not have the columnsCfg attribute defined: '.$this->getReportSource());
		}

		if (!$this->primaryKeyColumn) {
			throw new \Exception('This report source does not have the primary key attribute defined: '.$this->getReportSource());
		}

		// load columns langs
		\App\Loader_Lang::getInstance()->loadAppFile("_customLoad/reports/".lcfirst($this->getReportSource())."/columns.lang.php");

		// run plugin trigger - so we can extend/update the list of report columns for the current report source
		\Engine\Plugins\Manager::getInstance()->executeEvent("Reports-Sources-Columns-[{$this->getReportSource()}]", ['columnsManager' => $this]);

	}


	/**
	 * Returns the report source based on the current class namespace.
	 * @return string
	 */
	public function getReportSource()
	{
		$ns = getClassNamespace(get_called_class());
		return substr($ns, strrpos($ns, '\\')+1);
	}


	/**
	 * Adds a new standard column.
	 * @param string $columnCode
	 */
	public function addStdColumn(string $code, ?string $name=null, bool $hidden=false) :void
	{
		if (!key_exists($code, $this->columnsCfg)) {
			throw new \App\Reports\Sources\Exception("Invalid column code `{$code}`.");
		}

		if (key_exists($code, $this->stdColumns)) {
			throw new \App\Reports\Sources\Exception("Duplicated std column code `{$code}`.");
		}

		if ($name===null) {
			$_LANG = \App\Loader_Lang::getInstance()->getGlobal();
			$name = $_LANG['reports']['columnNames'][lcfirst($this->getReportSource())][$code];
		}

		// associates the main details for the standard columns but also adds the formula if set
		$columnDetails = [
			'type' => $this->columnsCfg[$code]['type'],
			'dbDef' => $this->columnsCfg[$code]['dbDef'],
			'name' => $name,
			'hidden' => $hidden,
		];
		if (key_exists('formula', $this->columnsCfg[$code])) {
			$columnDetails['formula'] = $this->columnsCfg[$code]['formula'];
		}

		$this->stdColumns[$code] = $columnDetails;

	}

	/**
	 * Returns the standard column config details.
	 * @param string $code
	 * @return array|NULL
	 */
	public function getStdColumnConfig(string $code) :?array
	{
		if (!key_exists($code, $this->columnsCfg)) {
			return null;
		}
		return $this->columnsCfg[$code];
	}

	public function getStdColumnDetails(string $code) :?array
	{
		if (key_exists($code, $this->stdColumns)) {
			return $this->stdColumns[$code];
		} else {
			return null;
		}
	}

	public function addCustomColumn_math(string $columnCode, string $columnName, string $columnType, string $calculation, ?bool $hidden=false) :void
	{
		// checks col - throws exc if issued found
		$this->checkCustomColumnCode($columnCode);

		// check dbDef - throws exception if invalid
		$dbDef = $this->getDbDefByCustomColumnType($columnType);

		// check math formula
		if ($this->getCalculationOperands($calculation)===false) {
			throw new \App\Reports\Sources\Exception("Incorrect math formula for column `{$columnCode}`.");
		}

		// add custom column
		$this->customColumns[$columnCode] = [
			'type' => $columnType,
			'dbDef' => $dbDef,
			'name' => $columnName,
			'hidden' => (bool)$hidden,
			'formula' => [
				'type'=>'math',
				'calculation' => $calculation,
			],
		];
	}


	public function addCustomColumn_entity(string $columnCode, string $columnName, string $columnType, string $entity, string $id, string $attribute, ?bool $hidden=false)
	{

		// checks col - throws exc if issued found
		$this->checkCustomColumnCode($columnCode);

		// check dbDef - throws exception if invalid
		$dbDef = $this->getDbDefByCustomColumnType($columnType);

		// check other details regarding entity formula
		$this->checkEntityFormula($entity, $id, $attribute);

		// add custom column
		$this->customColumns[$columnCode] = [
			'type' => $columnType,
			'dbDef' => $dbDef,
			'name' => $columnName,
			'hidden' => (bool)$hidden,
			'formula' => [
				'type' => 'entity',
				'entity' => $entity,
				'id' => $id,
				'attribute' => $attribute
			]
		];

	}

	/**
	 * Adds custom columns that can work by extracting a value from an associative array; mainly used
	 * for remarks or extraInfo fields.
	 * @param string $columnCode
	 * @param string $columnType
	 * @param string $sourceColumnCode
	 * @param string $key
	 */
	public function addCustomColumn_arrayValue(string $columnCode, string $columnName, string $columnType, string $sourceColumnCode, string $key, ?bool $hidden=false)
	{
		// checks col - throws exc if issued found
		$this->checkCustomColumnCode($columnCode);

		// check dbDef - throws exception if invalid
		$dbDef = $this->getDbDefByCustomColumnType($columnType);

		// checks source and key
		$this->checkArrayValueFormula($sourceColumnCode, $key);

		// add custom column
		$this->customColumns[$columnCode] = [
			'type' => $columnType,
			'dbDef' => $dbDef,
			'name' => $columnName,
			'hidden' => (bool)$hidden,
			'formula' => [
				'type' => 'arrayValue',
				'source' => $sourceColumnCode,
				'key' => $key
			]
		];
	}

	public function getCustomColumnDetails(string $code) :?array
	{
		if (key_exists($code, $this->customColumns)) {
			return $this->customColumns[$code];
		} else {
			return null;
		}
	}

	/**
	 * Check the custom column code.
	 * @param string $columnCode
	 */
	private function checkCustomColumnCode(string $columnCode) :void
	{
		// check column code
		if (!$columnCode) {
			throw new \App\Reports\Sources\Exception("Empty custom column code.");
		}
		if (!preg_match("/^[a-zA-Z]+[a-zA-Z0-9_]+$/", $columnCode)) {
			throw new \App\Reports\Sources\Exception("Invalid custom column code `{$columnCode}` (only letters, digits and underscore is allowed and must start with letter).");
		}
		// must not be set as the name of a std column
		if (key_exists($columnCode, $this->columnsCfg)) {
			throw new \App\Reports\Sources\Exception("Custom column cannot have the code `{$columnCode}` as this is a standard column.");
		}
		// check for duplicates
		if (key_exists($columnCode, $this->customColumns)) {
			throw new \App\Reports\Sources\Exception("Duplicated custom column code `{$columnCode}`.");
		}
	}

	/**
	 * Returns the DB definition based on the type of column.
	 * @param string $columnType
	 * @return string
	 */
	public function getDbDefByCustomColumnType(string $columnType) :string
	{

		switch ($columnType) {
			case 'boolean':
				return "TINYINT UNSIGNED NOT NULL DEFAULT 0";
			case 'int':
				return "BIGINT NOT NULL DEFAULT 0";
//			case 'float': // there are issues with this way mysql stores FLOATs so we replaced them with DECIMALs; try storing the value 17274.34 and you'll see that mysql stores 17274.3
//				return "DECIMAL(16,3) NOT NULL DEFAULT 0"; // TODO: this format is used only for math custom columns but it will be replaced once we refactor the reports so that the user can choose the type of column (FLOAT should and will not be available, PRICE is a better)
			case 'price':
				return "DECIMAL(16,3) NOT NULL DEFAULT 0";
			case 'varchar10':
				return "VARCHAR(10) NOT NULL DEFAULT ''";
			case 'varchar25':
				return "VARCHAR(25) NOT NULL DEFAULT ''";
			case 'varchar50':
				return "VARCHAR(50) NOT NULL DEFAULT ''";
			case 'varchar100':
				return "VARCHAR(100) NOT NULL DEFAULT ''";
			case 'varchar150':
				return "VARCHAR(150) NOT NULL DEFAULT ''";
			case 'varchar200':
				return "VARCHAR(200) NOT NULL DEFAULT ''";
			case 'varchar250':
				return "VARCHAR(250) NOT NULL DEFAULT ''";
			case 'varchar300':
				return "VARCHAR(300) NOT NULL DEFAULT ''";
			case 'varchar400':
				return "VARCHAR(400) NOT NULL DEFAULT ''";
			case 'varchar500':
				return "VARCHAR(500) NOT NULL DEFAULT ''";
			case 'text':
				return "TEXT NOT NULL";
			case 'date': case 'yearMonth':
				return "DATE NULL DEFAULT NULL";
			case 'year':
				return "YEAR NULL DEFAULT NULL";
			case 'datetime':
				return "DATETIME NULL DEFAULT NULL";
			case 'percent':
				return "DECIMAL(10,7) NOT NULL DEFAULT 0";
			default:
				throw new \App\Reports\Sources\Exception('Invalid custom column type - '.$columnType);
		}

	}


	/**
	 * Indicates if a certain column code is included in both standard and custom columns.
	 * This is used for validating report columns as well.
	 */
	public function columnCodeExists($columnCode) : bool
	{
		return key_exists($columnCode, $this->stdColumns) or key_exists($columnCode, $this->customColumns);
	}

	/**
	 * Indicates if the dependencies were already imported.
	 * @return bool
	 */
	public function dependenciesAreIncluded() :bool
	{
		return $this->dependenciesIncluded;
	}

	/**
	 * Adds all needed extra columns that are dependencies for selected columns.
	 */
	public function includeColumnsDependencies() :void
	{

		// check that the method is called only once
		if ($this->dependenciesIncluded) {
			throw new \Exception('You should not call `'.__METHOD__. '` twice.');
		}

		// STEP 1 - INCLUDE THE PRIMARY KEY AND THE FORCED COLUMNS ////////////////////////////////
		if (!key_exists($this->primaryKeyColumn, $this->stdColumns)) {
			$this->addStdColumn($this->primaryKeyColumn, null, true);
		}

		foreach ($this->forcedColumns as $colCode) {
			if (!key_exists($colCode, $this->stdColumns)) {
				$this->addStdColumn($colCode, null, true);
			}
		}

		// STEP 2 - INCLUDE THE CUSTOM COLUMNS DEPENDENCIES (or std cols with formulas) ///////////
		// forces both custom columns and standard columns with formulas (to be treated as custom columns)
		$allCols = array_merge($this->stdColumns, $this->customColumns);
		foreach ($allCols as $columnCode=>$columnDetails) {

			$columnFormula = $columnDetails['formula']??null;

			// skip cols with no formula (proabably standard simple cols)
			if (!$columnFormula) {
				continue;
			}

			switch ($columnFormula['type']) {

				case 'math':
					$mathOperands = $this->getCalculationOperands($columnFormula['calculation']);
					if ($mathOperands) {
						foreach ($mathOperands as $operand) {
							if (!$this->columnCodeExists($operand)) {
								$this->addStdColumn($operand, null, true);
							}
						}
					}
				break;

				case 'entity':
					if (!$this->columnCodeExists($columnFormula['id'])) {
						$this->addStdColumn($columnFormula['id'], null, true);
					}
				break;

				case 'arrayValue':
					if (!key_exists($columnFormula['source'], $this->stdColumns)) {
						$this->addStdColumn($columnFormula['source'], null, true);
					}
				break;
				default:
					throw new \Exception("Invalid type of formula `{$columnFormula['type']}` for custom column `{$columnCode}`.");
			}

		}

		// STEP 2 - INCLUDE DEPENDENCIES FROM COLUMN DEFINITION ///////////////////////////////////
		// we'll have to go in depth as the children of a column might have children on its own
		// loop through all selected columns to add dependencies and try again until no more dependencies found
		do {
			$foundDependency = false;
			// goes through each std column and checks its
			foreach ($this->stdColumns as $colCode=>$colDet) {
				if (key_exists($colCode, $this->columnsDependencies) and $this->columnsDependencies[$colCode]) {
					// add dependencies (that are not yet added)
					foreach ($this->columnsDependencies[$colCode] as $depColCode) {
						if (!key_exists($depColCode, $this->stdColumns)) {
							$foundDependency = true;
							$this->addStdColumn($depColCode, null, true);
						}
					}
				}
			}
		} while ($foundDependency);

		// mark that the dependencies were included
		$this->dependenciesIncluded = true;

	}


	/**
	 * Returns the operands of the math formula, or null if the formula is invalid.
	 * @param string  $formula
	 * @return array|NULL
	 */
	public function getCalculationOperands(string $formula) :array|false
	{

		// check if formula contains '%%'
		if (strpos($formula, '%%')!==false) {
			return false;
		}

		// list of all columun codes (standard and custom)
		$allColumnCodes = array_merge( array_keys($this->columnsCfg), array_keys($this->customColumns) );

		// replace numeric columns from formula with some random values
		$replacedFormula = $formula;
		$operandsList = [];
		foreach($allColumnCodes as $columnCode) {
			if (strpos($formula, '%'.$columnCode.'%')!==false) {
				$operandsList[] = $columnCode;
				$replacedFormula = str_replace('%'.$columnCode.'%', rand(100, 10000), $replacedFormula);
			}
		}

		// check that after the replacing of the operands, we get a correct arithmetic operation
		$cleanedReplacedFormula = preg_replace('/[^0-9\.\-\+\*\/\(\) ]/','', $replacedFormula);
		if ($cleanedReplacedFormula!=$replacedFormula) {
			return false;
		}

		// try to evaluate the formula
		try {
			eval($replacedFormula.';');
		} catch (\ParseError $e) {
			return false;
		}

		return $operandsList;

	}

	/**
	 * Chcks that the formula is correctly defined.
	 * @param string $entity
	 * @param string $id
	 * @param string $attribute
	 */
	public function checkEntityFormula(string $entity, string $id, string $attribute)
	{

		// check entity type
		$class = "\\App\\ReportsData\\EntityFields\\".$entity;
		if (!class_exists($class)) {
			throw new \App\Reports\Sources\CustomColumnFormulaException( sprintf('Invalid formula for entity custom column - invalid entity type; entity=`%s`, id=`%s`, attr=`%s`.', $entity, $id, $attribute) );
		}

		// check entity id - it must be either a std column (in the list of configured columns, that might be included due to dependencies) or a previously defined custom column
		if (!key_exists($id, $this->columnsCfg) and !$this->getCustomColumnDetails($id)) {
			throw new \App\Reports\Sources\CustomColumnFormulaException( sprintf('Invalid formula for entity custom column - invalid entity id column code; entity=`%s`, id=`%s`, attr=`%s`.', $entity, $id, $attribute) );
		}

		// main RD entity call - we initialize with 0 - just to skip a possible useless SQL in the DB
		$entObj = new $class(0);

		// get the attributes as array
		$attributes = explode('.', $attribute);

		// execute the attribute one by one
		$result = $entObj;
		foreach($attributes as $attr) {

			// check if the current object is still an Entity extension
			if ( !($result instanceof \App\ReportsData\EntityFields\GeneralAbstract) ) {
				throw new \App\Reports\Sources\CustomColumnFormulaException( sprintf('Invalid formula for entity custom column - invalid class type; entity=`%s`, id=`%s`, attr=`%s`.', $entity, $id, $attribute) );
			}
			try {
				$result = $result->getValue($attr);
			} catch (\Exception $e) {
				throw new \App\Reports\Sources\CustomColumnFormulaException( sprintf('Invalid formula for entity custom column - invalid attribute access; entity=`%s`, id=`%s`, attr=`%s`. Error: %s', $entity, $id, $attribute, $e->getMessage()) );
			}
		}
		// if, in the end, we still have an object, kill the report
		if (is_object($result)) {
			throw new \App\Reports\Sources\CustomColumnFormulaException( sprintf('Invalid formula for entity custom column - the result must be a scalar value; entity=`%s`, id=`%s`, attr=`%s`.', $entity, $id, $attribute) );
		}

	}

	/**
	 * Checks the array value formula - esp. source column.
	 * @param string $sourceColumnCode
	 * @param string $key
	 */
	public function checkArrayValueFormula(string $sourceColumnCode, string $key)
	{

		// no validations for key -  we just want to make sure it's a string

		// the source column must exist in this source
		if (!key_exists($sourceColumnCode, $this->columnsCfg)) {
			throw new \App\Reports\Sources\CustomColumnFormulaException("Custom column of type `arrayValue` cannot have the source `{$sourceColumnCode}` as this is not a standard column.");
		}
		if (!in_array($this->columnsCfg[$sourceColumnCode]['content'], ['remarks', 'assocArray'])) {
			throw new \App\Reports\Sources\CustomColumnFormulaException("Custom column of type `arrayValue` cannot have the source `{$sourceColumnCode}` as this is not an associative array content column.");
		}

	}

	/**
	 * Returns the all the details of the associated standard columns.
	 * @return array
	 */
	public function getStdColumns() : array
	{
		return $this->stdColumns;
	}

	/**
	 * Returns the all the details of the associated custom columns.
	 * @return array
	 */
	public function getCustomColumns() : array
	{
		return $this->customColumns;
	}

	/**
	 * Returns the PK column.
	 * @return string
	 */
	public function getTablePkColumn() :string
	{
		return $this->primaryKeyColumn;
	}

	public function defineStdColumn(string $code, string $type, string $dbDef, string $category, array $formula, ?string $content=null, bool $disabled = false, string|bool $subtotals = false)
	{

		// check if the column code is set
		if (!strlen($code)) {
			throw new \Exception('Unset column code in '.__METHOD__);
		}
		// synthetic validation that column is not already defined; we might be able to allow it but we do this to catch programmer error
		if (key_exists($code, $this->columnsCfg)) {
			throw new \Exception("Column `{$code}` is defined twice in ".get_called_class());
		}

		// populate the details of the current column - allows to overwrite the standard columns, maybe it's useful at some point
		$newColumn = [
			'type'		=> $type,
			'dbDef'		=> $dbDef,
			'cat'		=> $category,
		];
		// add extra info on the column
		if ($formula) {
			$newColumn['formula'] = $formula;
		}
		if ($content) {
			$newColumn['content'] = $content;
		}
		if ($disabled) {
			$newColumn['disabled'] = $disabled;
		}
		if ($subtotals) {
			$newColumn['subtotals'] = $subtotals;
		}

		$this->columnsCfg[$code] = $newColumn;
	}


	/**
	 * Imports standard columns from an assoc array of code=>name (name can be null and the translation is automatically loaded).
	 * @param array $stdColumns
	 */
	public function importStdColumnsFromArray(array $stdColumns) :void // TODO: [notImportant] we should implement export also - to be used when starting a new report
	{
		foreach ($stdColumns as $columnCode=>$columnName) {
			$this->addStdColumn($columnCode, $columnName);
		}
	}

	/**
	 * Imports custom columns from an assoc array of code=>details.
	 * @param array $customColumns
	 */
	public function importCustomColumnsFromArray(array $customColumns) :void // TODO: [notImportant] we should implement export also - to be used when starting a new report
	{

		foreach ($customColumns as $columnCode=>$columnDetais) {

			$columnFormula = $columnDetais['formula'];

			switch ($columnFormula['type']) {
				case 'math':
					$this->addCustomColumn_math($columnCode, $columnDetais['name'], $columnDetais['type'], $columnFormula['calculation'], $columnDetais['hidden']);
				break;
				case 'arrayValue':
					$this->addCustomColumn_arrayValue($columnCode, $columnDetais['name'], $columnDetais['type'], $columnFormula['source'], $columnFormula['key'], $columnDetais['hidden']);
				break;
				case 'entityValue':
					$this->addCustomColumn_entity($columnCode, $columnDetais['name'], $columnDetais['type'], $columnFormula['entity'], $columnFormula['id'], $columnFormula['attribute'], $columnDetais['hidden']);
				break;
				default:
					\Log::getInstance()->error('Invalid formula for custom column.', [
						'columnCode' => new \Engine\Logger\Context\StringRaw($columnCode),
						'columnDetails' => new \Engine\Logger\Context\ArrayAssoc($columnDetais),
					]);
					throw new \App\Reports\Sources\CustomColumnFormulaException('Invalid formula format when importing custom column from array.');
			}


		}

	}

	/**
	 * Includes columns (standard or custom) based on custom filters (we use the array format used in filters management class).
	 * @param array $customFilters An array of custom filter definitions to be included.
	 * @return void
	 */
	public function includeCustomFilterDependencies(array $customFilters): void
	{
		foreach ($customFilters as $columnCode=>$filterDetails) {
			if (!str_starts_with($columnCode, 'CUSTOM_COLUMN_')) {
				if (!key_exists($columnCode, $this->stdColumns)) {
					$this->addStdColumn($columnCode, null, true);
				}
			}
		}
	}

}

