<?php

/*
 * @copyright   Copyright (C) 2010-2024 Combodo SAS
 * @license     http://opensource.org/licenses/AGPL-3.0
 */

// The BOM is added at the head of exported UTF-8 CSV data, and removed (if present) from input UTF-8 data.
// This helps MS-Excel (Version > 2007, Windows only) in changing its interpretation of a CSV file (by default Excel reads data as ISO-8859-1 -not 100% sure!)
use Combodo\iTop\Application\WebPage\iTopWebPage;
use Combodo\iTop\Application\WebPage\WebPage;

define('UTF8_BOM', chr(239).chr(187).chr(191)); // 0xEF, 0xBB, 0xBF

/**
 * CellChangeSpec
 * A series of classes, keeping the information about a given cell: could it be changed or not (and why)?
 *
 * @package     iTopORM
 */
abstract class CellChangeSpec
{
	protected $m_proposedValue;
	protected $m_sOql; // in case of ambiguity

	public function __construct($proposedValue, $sOql = '')
	{
		$this->m_proposedValue = $proposedValue;
		$this->m_sOql = $sOql;
	}

	public function GetPureValue()
	{
		// Todo - distinguish both values
		return $this->m_proposedValue;
	}

	/**
	 * @throws \Exception
	 * @since 3.2.0
	 */
	public function GetCLIValue(bool $bLocalizedValues = false): string
	{
		if (is_object($this->m_proposedValue)) {
			if ($this->m_proposedValue instanceof ReportValue) {
				return $this->m_proposedValue->GetAsCSV($bLocalizedValues, ',', '"');
			}
			throw new Exception('Unexpected class : '.get_class($this->m_proposedValue));
		}
		return $this->m_proposedValue;
	}

	/**
	 * @throws \Exception
	 * @since 3.2.0
	 */
	public function GetHTMLValue(bool $bLocalizedValues = false): string
	{
		if (is_object($this->m_proposedValue)) {
			if ($this->m_proposedValue instanceof ReportValue) {
				return $this->m_proposedValue->GetAsHTML($bLocalizedValues);
			}
			throw new Exception('Unexpected class : '.get_class($this->m_proposedValue));
		}
		return utils::EscapeHtml($this->m_proposedValue);
	}

	/**
	 * @since 3.1.0 N°5305
	 */
	public function SetDisplayableValue(string $sDisplayableValue)
	{
		$this->m_proposedValue = $sDisplayableValue;
	}

	public function GetOql()
	{
		return $this->m_sOql;
	}

	/**
	 * @since 3.2.0
	 */
	public function GetCLIValueAndDescription(): string
	{
		return sprintf(
			"%s%s",
			$this->GetCLIValue(),
			$this->GetDescription()
		);
	}

	abstract public function GetDescription();
}

class CellStatus_Void extends CellChangeSpec
{
	public function GetDescription()
	{
		return '';
	}
}

class CellStatus_Modify extends CellChangeSpec
{
	protected $m_previousValue;

	public function __construct($proposedValue, $previousValue = null)
	{
		// Unused (could be costly to know -see the case of reconciliation on ext keys)
		//$this->m_previousValue = $previousValue;
		parent::__construct($proposedValue);
	}

	public function GetDescription()
	{
		return Dict::S('UI:CSVReport-Value-Modified');
	}

	//public function GetPreviousValue()
	//{
	//	return $this->m_previousValue;
	//}
}

class CellStatus_Issue extends CellStatus_Modify
{
	protected $m_sReason;

	public function __construct($proposedValue, $previousValue, $sReason)
	{
		$this->m_sReason = $sReason;
		parent::__construct($proposedValue, $previousValue);
	}

	public function GetCLIValue(bool $bLocalizedValues = false): string
	{
		if (is_null($this->m_proposedValue)) {
			return Dict::Format('UI:CSVReport-Value-SetIssue');
		}
		return Dict::Format('UI:CSVReport-Value-ChangeIssue', $this->m_proposedValue);
	}

	public function GetHTMLValue(bool $bLocalizedValues = false): string
	{
		if (is_null($this->m_proposedValue)) {
			return Dict::Format('UI:CSVReport-Value-SetIssue');
		}
		if ($this->m_proposedValue instanceof ReportValue) {
			return Dict::Format('UI:CSVReport-Value-ChangeIssue', $this->m_proposedValue->GetAsHTML($bLocalizedValues));
		}
		return Dict::Format('UI:CSVReport-Value-ChangeIssue', utils::EscapeHtml($this->m_proposedValue));
	}

	public function GetDescription()
	{
		return $this->m_sReason;
	}
	/*
	 * @since 3.2.0
	 */
	public function GetCLIValueAndDescription(): string
	{
		return sprintf(
			"%s. %s",
			$this->GetCLIValue(),
			$this->GetDescription()
		);
	}
}

class CellStatus_SearchIssue extends CellStatus_Issue
{
	/** @var string|null $m_sAllowedValues */
	private $m_sAllowedValues;

	/**
	 * @since 3.1.0 N°5305
	 * @var string $sSerializedSearch
	 */
	private $sSerializedSearch;

	/** @var string|null $m_sTargetClass */
	private $m_sTargetClass;

	/**
	 * @since 3.1.0 N°5305
	 * @var string $sAllowedValuesSearch
	 */
	private $sAllowedValuesSearch;

	/**
	 * CellStatus_SearchIssue constructor.
	 * @since 3.1.0 N°5305
	 *
	 * @param string $sOql : main message
	 * @param string $sReason : main message
	 * @param null $sClass : used for additional message that provides allowed values for current class $sClass
	 * @param null $sAllowedValues : used for additional message that provides allowed values $sAllowedValues for current class
	 * @param string|null $sAllowedValuesSearch : used to search all allowed values
	 */
	public function __construct($sSerializedSearch, $sReason, $sClass = null, $sAllowedValues = null, ?string $sAllowedValuesSearch = null)
	{
		parent::__construct(null, null, $sReason);
		$this->sSerializedSearch = $sSerializedSearch;
		$this->m_sAllowedValues = $sAllowedValues;
		$this->m_sTargetClass = $sClass;
		$this->sAllowedValuesSearch = $sAllowedValuesSearch;
	}

	public function GetCLIValue(bool $bLocalizedValues = false): string
	{
		if (null === $this->m_sReason) {
			return Dict::Format('UI:CSVReport-Value-NoMatch', '');
		}

		return $this->m_sReason;
	}

	public function GetHTMLValue(bool $bLocalizedValues = false): string
	{
		if (null === $this->m_sReason) {
			return Dict::Format('UI:CSVReport-Value-NoMatch', '');
		}

		return utils::EscapeHtml($this->m_sReason);
	}

	public function GetDescription()
	{
		if (\utils::IsNullOrEmptyString($this->m_sAllowedValues) ||
			\utils::IsNullOrEmptyString($this->m_sTargetClass)) {
			return '';
		}

		return Dict::Format('UI:CSVReport-Value-NoMatch-PossibleValues', $this->m_sTargetClass, $this->m_sAllowedValues);
	}

	/**
	 * @since 3.1.0 N°5305
	 * @return string
	 */
	public function GetSearchLinkUrl()
	{
		return sprintf(
			"UI.php?operation=search&filter=%s",
			rawurlencode($this->sSerializedSearch ?? "")
		);
	}

	/**
	 * @since 3.1.0 N°5305
	 * @return null|string
	 */
	public function GetAllowedValuesLinkUrl(): ?string
	{
		return sprintf(
			"UI.php?operation=search&filter=%s",
			rawurlencode($this->sAllowedValuesSearch ?? "")
		);
	}
}

class CellStatus_NullIssue extends CellStatus_Issue
{
	public function __construct()
	{
		parent::__construct(null, null, null);
	}

	public function GetDescription()
	{
		return Dict::S('UI:CSVReport-Value-Missing');
	}
}

/**
 * Class to differ formatting depending on the caller
 */
class ReportValue
{
	/**
	 * @param DBObject $oObject
	 * @param string $sAttCode
	 * @param bool $bOriginal
	 */
	public function __construct(protected DBObject $oObject, protected string $sAttCode, protected bool $bOriginal)
	{
	}

	public function GetAsHTML(bool $bLocalizedValues)
	{
		if ($this->bOriginal) {
			return $this->oObject->GetOriginalAsHTML($this->sAttCode, $bLocalizedValues);
		}
		return $this->oObject->GetAsHTML($this->sAttCode, $bLocalizedValues);
	}
	public function GetAsCSV(bool $bLocalizedValues, string $sCsvSep, string $sCsvDelimiter)
	{
		if ($this->bOriginal) {
			return $this->oObject->GetOriginalAsCSV($this->sAttCode, $sCsvSep, $sCsvDelimiter, $bLocalizedValues);
		}
		return $this->oObject->GetAsCSV($this->sAttCode, $sCsvSep, $sCsvDelimiter, $bLocalizedValues);
	}
}

class CellStatus_Ambiguous extends CellStatus_Issue
{
	protected $m_iCount;
	/**
	 * @since 3.1.0 N°5305
	 * @var string
	 */
	protected $sSerializedSearch;

	/**
	 * @since 3.1.0 N°5305
	 *
	 * @param $previousValue
	 * @param int $iCount
	 * @param string $sSerializedSearch
	 *
	 */
	public function __construct($previousValue, $iCount, $sSerializedSearch)
	{
		$this->m_iCount = $iCount;
		$this->sSerializedSearch = $sSerializedSearch;
		parent::__construct(null, $previousValue, '');
	}

	public function GetDescription()
	{
		$sCount = $this->m_iCount;
		return Dict::Format('UI:CSVReport-Value-Ambiguous', $sCount);
	}

	/**
	 * @since 3.1.0 N°5305
	 * @return string
	 */
	public function GetSearchLinkUrl()
	{
		return sprintf(
			"UI.php?operation=search&filter=%s",
			rawurlencode($this->sSerializedSearch ?? "")
		);
	}
}

/**
 * RowStatus
 * A series of classes, keeping the information about a given row: could it be changed or not (and why)?
 *
 * @package     iTopORM
 */
abstract class RowStatus
{
	public function __construct()
	{
	}

	abstract public function GetDescription();
}

class RowStatus_NoChange extends RowStatus
{
	public function GetDescription()
	{
		return Dict::S('UI:CSVReport-Row-Unchanged');
	}
}

class RowStatus_NewObj extends RowStatus
{
	public function GetDescription()
	{
		return Dict::S('UI:CSVReport-Row-Created');
	}
}

class RowStatus_Modify extends RowStatus
{
	protected $m_iChanged;

	public function __construct($iChanged)
	{
		$this->m_iChanged = $iChanged;
	}

	public function GetDescription()
	{
		return Dict::Format('UI:CSVReport-Row-Updated', $this->m_iChanged);
	}
}

class RowStatus_Disappeared extends RowStatus_Modify
{
	public function GetDescription()
	{
		return Dict::Format('UI:CSVReport-Row-Disappeared', $this->m_iChanged);
	}
}

class RowStatus_Issue extends RowStatus
{
	protected $m_sReason;

	public function __construct($sReason)
	{
		$this->m_sReason = $sReason;
	}

	public function GetDescription()
	{
		return Dict::Format('UI:CSVReport-Row-Issue', $this->m_sReason);
	}
}

/**
 * class dedicated to testability
 * not used/ignored in csv imports UI/CLI
 * @since 3.1.0 N°5305
 */
class RowStatus_Error extends RowStatus
{
	/** @var string */
	protected $m_sError;

	public function __construct($sError)
	{
		$this->m_sError = $sError;
	}

	public function GetDescription()
	{
		return $this->m_sError;
	}
}

/**
 * BulkChange
 * Interpret a given data set and update the DB accordingly (fake mode avail.)
 *
 * @package iTopORM
 */
class BulkChange
{
	/** @var string */
	protected $m_sClass;
	protected $m_aData; // Note: hereafter, iCol maybe actually be any acceptable key (string)
	// #@# todo: rename the variables to sColIndex
	/** @var array<string, string> attcode as key, iCol as value */
	protected $m_aAttList;
	/** @var array<string, array<string, string>> sExtKeyAttCode as key, array of sExtReconcKeyAttCode/iCol as value */
	protected $m_aExtKeys;
	/** @var string[] list of attcode (attcode = 'id' for the pkey) */
	protected $m_aReconcilKeys;
	/** @var string OQL - if specified, then the missing items will be reported */
	protected $m_sSynchroScope;
	/**
	 * @var array<string, mixed> attcode as key, attvalue as value. Values to be set when an object gets out of scope
	 *                          (ignored if no scope has been defined)
	 */
	protected $m_aOnDisappear;
	/**
	 * @see DateTime::createFromFormat
	 * @var string Date format specification
	 */
	protected $m_sDateFormat;
	/**
	 * @see AttributeEnum
	 * @var boolean true if Values in the data set are localized
	 */
	protected $m_bLocalizedValues;
	/** @var array Cache for resolving external keys based on the given search criterias */
	protected $m_aExtKeysMappingCache;
	/** @var int number of columns */
	protected $m_iNbCol;

	public function __construct($sClass, $aData, $aAttList, $aExtKeys, $aReconcilKeys, $sSynchroScope = null, $aOnDisappear = null, $sDateFormat = null, $bLocalize = false, $iNbCol = 0)
	{
		$this->m_sClass = $sClass;
		$this->m_aData = $aData;
		$this->m_aAttList = $aAttList;
		$this->m_aReconcilKeys = $aReconcilKeys;
		$this->m_aExtKeys = $aExtKeys;
		$this->m_sSynchroScope = $sSynchroScope;
		$this->m_aOnDisappear = $aOnDisappear;
		$this->m_sDateFormat = $sDateFormat;
		$this->m_bLocalizedValues = $bLocalize;
		$this->m_aExtKeysMappingCache = [];
		$this->m_iNbCol = $iNbCol;
	}

	protected function ResolveExternalKey($aRowData, $sAttCode, &$aResults)
	{
		$oExtKey = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode);
		$oReconFilter = new DBObjectSearch($oExtKey->GetTargetClass());
		foreach ($this->m_aExtKeys[$sAttCode] as $sReconKeyAttCode => $iCol) {
			if ($sReconKeyAttCode == 'id') {
				$value = (int) $aRowData[$iCol];
			} else {
				// The foreign attribute is one of our reconciliation key
				$oForeignAtt = MetaModel::GetAttributeDef($oExtKey->GetTargetClass(), $sReconKeyAttCode);
				$value = $oForeignAtt->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues);
			}
			$oReconFilter->AddCondition($sReconKeyAttCode, $value, '=');
			$aResults[$iCol] = new CellStatus_Void($aRowData[$iCol]);
		}

		$oExtObjects = new CMDBObjectSet($oReconFilter);
		$aKeys = $oExtObjects->ToArray();
		return [$oReconFilter, $aKeys];
	}

	// Returns true if the CSV data specifies that the external key must be left undefined
	protected function IsNullExternalKeySpec($aRowData, $sAttCode)
	{
		//$oExtKey = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode);
		foreach ($this->m_aExtKeys[$sAttCode] as $sForeignAttCode => $iCol) {
			// The foreign attribute is one of our reconciliation key
			if (isset($aRowData[$iCol]) && strlen($aRowData[$iCol]) > 0) {
				return false;
			}
		}
		return true;
	}

	/**
	 * @param DBObject $oTargetObj
	 * @param array $aRowData
	 * @param array $aErrors
	 *
	 * @return array
	 * @throws \CoreException
	 * @throws \CoreUnexpectedValue
	 * @throws \MissingQueryArgument
	 * @throws \MySQLException
	 * @throws \MySQLHasGoneAwayException
	 */
	protected function PrepareObject(&$oTargetObj, $aRowData, &$aErrors)
	{
		$aResults = [];
		$aErrors = [];

		// External keys reconciliation
		//
		foreach ($this->m_aExtKeys as $sAttCode => $aReconKeys) {
			// Skip external keys used for the reconciliation process
			// if (!array_key_exists($sAttCode, $this->m_aAttList)) continue;

			$oExtKey = MetaModel::GetAttributeDef(get_class($oTargetObj), $sAttCode);

			if ($this->IsNullExternalKeySpec($aRowData, $sAttCode)) {
				foreach ($aReconKeys as $sReconKeyAttCode => $iCol) {
					// Default reporting
					// $aRowData[$iCol] is always null
					$aResults[$iCol] = new CellStatus_Void($aRowData[$iCol]);
				}
				if ($oExtKey->IsNullAllowed()) {
					$oTargetObj->Set($sAttCode, $oExtKey->GetNullValue());
					$aResults[$sAttCode] = new CellStatus_Void($oExtKey->GetNullValue());
				} else {
					$aErrors[$sAttCode] = Dict::S('UI:CSVReport-Value-Issue-Null');
					$aResults[$sAttCode] = new CellStatus_Issue(null, $oTargetObj->Get($sAttCode), Dict::S('UI:CSVReport-Value-Issue-Null'));
				}
			} else {
				$oReconFilter = new DBObjectSearch($oExtKey->GetTargetClass());

				$aCacheKeys = [];
				foreach ($aReconKeys as $sReconKeyAttCode => $iCol) {
					// The foreign attribute is one of our reconciliation key
					if ($sReconKeyAttCode == 'id') {
						$value = $aRowData[$iCol];
					} else {
						$oForeignAtt = MetaModel::GetAttributeDef($oExtKey->GetTargetClass(), $sReconKeyAttCode);
						$value = $oForeignAtt->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues);
					}
					$aCacheKeys[] = $value;
					$oReconFilter->AddCondition($sReconKeyAttCode, $value, '=');
					$aResults[$iCol] = new CellStatus_Void($aRowData[$iCol]);
				}
				$sCacheKey = implode('_|_', $aCacheKeys); // Unique key for this query...
				$iForeignKey = null;
				// TODO: check if *too long* keys can lead to collisions... and skip the cache in such a case...
				if (!array_key_exists($sAttCode, $this->m_aExtKeysMappingCache)) {
					$this->m_aExtKeysMappingCache[$sAttCode] = [];
				}
				if (array_key_exists($sCacheKey, $this->m_aExtKeysMappingCache[$sAttCode])) {
					// Cache hit
					$iObjectFoundCount = $this->m_aExtKeysMappingCache[$sAttCode][$sCacheKey]['c'];
					$iForeignKey = $this->m_aExtKeysMappingCache[$sAttCode][$sCacheKey]['k'];
					// Record the hit
					$this->m_aExtKeysMappingCache[$sAttCode][$sCacheKey]['h']++;
				} else {
					// Cache miss, let's initialize it
					$oExtObjects = new CMDBObjectSet($oReconFilter);
					$iObjectFoundCount = $oExtObjects->Count();
					if ($iObjectFoundCount == 1) {
						$oForeignObj = $oExtObjects->Fetch();
						$iForeignKey = $oForeignObj->GetKey();
					}
					$this->m_aExtKeysMappingCache[$sAttCode][$sCacheKey] = [
						'c' => $iObjectFoundCount,
						'k' => $iForeignKey,
						'oql' => $oReconFilter->ToOql(),
						'h' => 0, // number of hits on this cache entry
					];
				}
				switch ($iObjectFoundCount) {
					case 0:
						$oCellStatus_SearchIssue = $this->GetCellSearchIssue($oReconFilter);
						$aResults[$sAttCode] = $oCellStatus_SearchIssue;
						$aErrors[$sAttCode] = Dict::S('UI:CSVReport-Value-Issue-NotFound');
						break;

					case 1:
						// Do change the external key attribute
						$oTargetObj->Set($sAttCode, $iForeignKey);
						break;

					default:
						$aErrors[$sAttCode] = Dict::Format('UI:CSVReport-Value-Issue-FoundMany', $iObjectFoundCount);
						$aResults[$sAttCode] = new CellStatus_Ambiguous($oTargetObj->Get($sAttCode), $iObjectFoundCount, $oReconFilter->serialize());
				}
			}

			// Report
			if (!array_key_exists($sAttCode, $aResults)) {
				$iForeignObj = $oTargetObj->Get($sAttCode);
				if (array_key_exists($sAttCode, $oTargetObj->ListChanges())) {
					if ($oTargetObj->IsNew()) {
						$aResults[$sAttCode] = new CellStatus_Void($iForeignObj);
					} else {
						$aResults[$sAttCode] = new CellStatus_Modify($iForeignObj, $oTargetObj->GetOriginal($sAttCode));
						foreach ($aReconKeys as $sReconKeyAttCode => $iCol) {
							// Report the change on reconciliation values as well
							$aResults[$iCol] = new CellStatus_Modify($aRowData[$iCol]);
						}
					}
				} else {
					$aResults[$sAttCode] = new CellStatus_Void($iForeignObj);
				}
			}
		}

		// Set the object attributes
		//
		foreach ($this->m_aAttList as $sAttCode => $iCol) {
			// skip the private key, if any
			if (($sAttCode == 'id') || ($sAttCode == 'friendlyname')) {
				continue;
			}

			$oAttDef = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode);

			// skip reconciliation keys
			if (!$oAttDef->IsWritable() && in_array($sAttCode, $this->m_aReconcilKeys)) {
				continue;
			}

			$aReasons = [];
			$iFlags = ($oTargetObj->IsNew())
				? $oTargetObj->GetInitialStateAttributeFlags($sAttCode, $aReasons)
				: $oTargetObj->GetAttributeFlags($sAttCode, $aReasons);
			if ((($iFlags & OPT_ATT_READONLY) == OPT_ATT_READONLY) && ($oTargetObj->Get($sAttCode) != $oAttDef->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues))) {
				$aErrors[$sAttCode] = Dict::Format('UI:CSVReport-Value-Issue-Readonly', $sAttCode, $oTargetObj->Get($sAttCode), $aRowData[$iCol]);
			} elseif ($oAttDef->IsLinkSet() && $oAttDef->IsIndirect()) {
				try {
					$oSet = $oAttDef->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues);
					$oTargetObj->Set($sAttCode, $oSet);
				} catch (CoreException $e) {
					$aErrors[$sAttCode] = Dict::Format('UI:CSVReport-Value-Issue-Format', $e->getMessage());
				}
			} else {
				$value = $oAttDef->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues);
				if (is_null($value) && (strlen($aRowData[$iCol]) > 0)) {
					if ($oAttDef instanceof AttributeEnum || $oAttDef instanceof AttributeTagSet) {
						/** @var AttributeDefinition $oAttributeDefinition */
						$oAttributeDefinition = $oAttDef;
						$aErrors[$sAttCode] = Dict::Format('UI:CSVReport-Value-Issue-AllowedValues', $sAttCode, implode(',', $oAttributeDefinition->GetAllowedValues()));
					} else {
						$aErrors[$sAttCode] = Dict::Format('UI:CSVReport-Value-Issue-NoMatch', $sAttCode);
					}
				} else {
					$res = $oTargetObj->CheckValue($sAttCode, $value);
					if ($res === true) {
						$oTargetObj->Set($sAttCode, $value);
					} else {
						// $res is a string with the error description
						$aErrors[$sAttCode] = Dict::Format('UI:CSVReport-Value-Issue-Unknown', $sAttCode, $res);
					}
				}
			}
		}

		// Reporting on fields
		//
		$aChangedFields = $oTargetObj->ListChanges();
		foreach ($this->m_aAttList as $sAttCode => $iCol) {
			if ($sAttCode == 'id') {
				$aResults[$iCol] = new CellStatus_Void($aRowData[$iCol]);
			} else {
				$sCurValue = new ReportValue($oTargetObj, $sAttCode, false);
				$sOrigValue = new ReportValue($oTargetObj, $sAttCode, true);
				if (isset($aErrors[$sAttCode])) {
					$aResults[$iCol] = new CellStatus_Issue($aRowData[$iCol], $sOrigValue, $aErrors[$sAttCode]);
				} elseif (array_key_exists($sAttCode, $aChangedFields)) {
					if ($oTargetObj->IsNew()) {
						$aResults[$iCol] = new CellStatus_Void($sCurValue);
					} else {
						$aResults[$iCol] = new CellStatus_Modify($sCurValue, $sOrigValue);
					}
				} else {
					// By default... nothing happens
					$oAttDef = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode);
					if ($oAttDef instanceof AttributeDateTime) {
						$aResults[$iCol] = new CellStatus_Void($oAttDef->GetFormat()->Format($aRowData[$iCol]));
					} else {
						$aResults[$iCol] = new CellStatus_Void($aRowData[$iCol]);
					}
				}
			}
		}

		// Checks
		//
		$res = $oTargetObj->CheckConsistency();
		if ($res !== true) {
			// $res contains the error description
			$aErrors["GLOBAL"] = Dict::Format('UI:CSVReport-Row-Issue-Inconsistent', $res);
		}
		return $aResults;
	}

	/**
	 * search with current permissions did not match
	 * let's search why and give some more feedbacks to the user through proper labels
	 *
	 * @param DBObjectSearch $oDbSearchWithConditions search used to find external key
	 *
	 * @return \CellStatus_SearchIssue
	 * @throws \CoreException
	 * @throws \MissingQueryArgument
	 * @throws \MySQLException
	 * @throws \MySQLHasGoneAwayException
	 *
	 * @since 3.1.0 N°5305
	 */
	protected function GetCellSearchIssue($oDbSearchWithConditions): CellStatus_SearchIssue
	{
		//current search with current permissions did not match
		//let's search why and give some more feedback to the user

		$sSerializedSearch = $oDbSearchWithConditions->serialize();

		// Count all objects with all permissions without any condition
		$oDbSearchWithoutAnyCondition = new DBObjectSearch($oDbSearchWithConditions->GetClass());
		$oDbSearchWithoutAnyCondition->AllowAllData(true);
		$oExtObjectSet = new CMDBObjectSet($oDbSearchWithoutAnyCondition);
		$iAllowAllDataObjectCount = $oExtObjectSet->Count();

		if ($iAllowAllDataObjectCount === 0) {
			$sReason = Dict::Format('UI:CSVReport-Value-NoMatch-NoObject', $oDbSearchWithConditions->GetClass());
			return new CellStatus_SearchIssue($sSerializedSearch, $sReason);
		}

		// Count all objects with current user permissions
		$oDbSearchWithoutAnyCondition->AllowAllData(false);
		$oExtObjectSetWithCurrentUserPermissions = new CMDBObjectSet($oDbSearchWithoutAnyCondition);
		$iCurrentUserRightsObjectCount = $oExtObjectSetWithCurrentUserPermissions->Count();
		$sAllowedValuesOql = $oDbSearchWithoutAnyCondition->serialize();

		if ($iCurrentUserRightsObjectCount === 0) {
			// No objects visible by current user
			$sReason = Dict::Format('UI:CSVReport-Value-NoMatch-NoObject-ForCurrentUser', $oDbSearchWithConditions->GetClass());
			return new CellStatus_SearchIssue($sSerializedSearch, $sReason);
		}

		try {
			$aDisplayedAllowedValues = [];
			// Possibles values are displayed to UI user. we have to limit the amount of displayed values
			$oExtObjectSetWithCurrentUserPermissions->SetLimit(4);
			for ($i = 0; $i < 3; $i++) {
				/** @var DBObject $oVisibleObject */
				$oVisibleObject = $oExtObjectSetWithCurrentUserPermissions->Fetch();
				if (is_null($oVisibleObject)) {
					break;
				}

				$aCurrentAllowedValueFields = [];
				foreach ($oDbSearchWithConditions->GetInternalParams() as $sForeignAttCode => $sValue) {
					$aCurrentAllowedValueFields[] = $oVisibleObject->Get($sForeignAttCode);
				}
				$aDisplayedAllowedValues[] = implode(" ", $aCurrentAllowedValueFields);

			}
			$allowedValues = implode(", ", $aDisplayedAllowedValues);
			if ($oExtObjectSetWithCurrentUserPermissions->Count() > 3) {
				$allowedValues .= "...";
			}
		} catch (Exception $e) {
			IssueLog::Error(
				"failure during CSV import when fetching few visible objects: ",
				null,
				[ 'target_class' => $oDbSearchWithConditions->GetClass(), 'criteria' => $oDbSearchWithConditions->GetCriteria(), 'message' => $e->getMessage()]
			);
			$sReason = Dict::Format('UI:CSVReport-Value-NoMatch-NoObject-ForCurrentUser', $oDbSearchWithConditions->GetClass());
			return new CellStatus_SearchIssue($sSerializedSearch, $sReason);
		}

		if ($iAllowAllDataObjectCount != $iCurrentUserRightsObjectCount) {
			// No match and some objects NOT visible by current user. including current search maybe...
			$sReason = Dict::Format('UI:CSVReport-Value-NoMatch-SomeObjectNotVisibleForCurrentUser', $oDbSearchWithConditions->GetClass());
			return new CellStatus_SearchIssue($sSerializedSearch, $sReason, $oDbSearchWithConditions->GetClass(), $allowedValues, $sAllowedValuesOql);
		}

		// No match. This is not linked to any right issue
		// Possible values: DD,DD
		$aCurrentValueFields = [];
		foreach ($oDbSearchWithConditions->GetInternalParams() as $sValue) {
			$aCurrentValueFields[] = $sValue;
		}
		$value = implode(" ", $aCurrentValueFields);
		$sReason = Dict::Format('UI:CSVReport-Value-NoMatch', $value);
		return new CellStatus_SearchIssue($sSerializedSearch, $sReason, $oDbSearchWithConditions->GetClass(), $allowedValues, $sAllowedValuesOql);
	}

	protected function PrepareMissingObject(&$oTargetObj, &$aErrors)
	{
		$aResults = [];
		$aErrors = [];

		// External keys
		//
		foreach ($this->m_aExtKeys as $sAttCode => $aKeyConfig) {
			//$oExtKey = MetaModel::GetAttributeDef(get_class($oTargetObj), $sAttCode);
			$aResults[$sAttCode] = new CellStatus_Void($oTargetObj->Get($sAttCode));

			foreach ($aKeyConfig as $sForeignAttCode => $iCol) {
				$aResults[$iCol] = new CellStatus_Void('?');
			}
		}

		// Update attributes
		//
		foreach ($this->m_aOnDisappear as $sAttCode => $value) {
			if (!MetaModel::IsValidAttCode(get_class($oTargetObj), $sAttCode)) {
				throw new BulkChangeException('Invalid attribute code', ['class' => get_class($oTargetObj), 'attcode' => $sAttCode]);
			}
			$oTargetObj->Set($sAttCode, $value);
		}

		// Reporting on fields
		//
		$aChangedFields = $oTargetObj->ListChanges();
		foreach ($this->m_aAttList as $sAttCode => $iCol) {
			if ($sAttCode == 'id') {
				$aResults[$iCol] = new CellStatus_Void($oTargetObj->GetKey());
			}
			if (array_key_exists($sAttCode, $aChangedFields)) {
				$aResults[$iCol] = new CellStatus_Modify($oTargetObj->Get($sAttCode), $oTargetObj->GetOriginal($sAttCode));
			} else {
				// By default... nothing happens
				$aResults[$iCol] = new CellStatus_Void($oTargetObj->Get($sAttCode));
			}
		}

		// Checks
		//
		$res = $oTargetObj->CheckConsistency();
		if ($res !== true) {
			// $res contains the error description
			$aErrors["GLOBAL"] = Dict::Format('UI:CSVReport-Row-Issue-Inconsistent', $res);
		}
		return $aResults;
	}

	protected function CreateObject(&$aResult, $iRow, $aRowData, ?CMDBChange $oChange = null)
	{
		$oTargetObj = MetaModel::NewObject($this->m_sClass);

		// Populate the cache for hierarchical keys (only if in verify mode)
		if (is_null($oChange)) {
			// 1. determine if a hierarchical key exists
			foreach ($this->m_aExtKeys as $sAttCode => $aKeyConfig) {
				$oExtKey = MetaModel::GetAttributeDef(get_class($oTargetObj), $sAttCode);
				if (!$this->IsNullExternalKeySpec($aRowData, $sAttCode) && MetaModel::IsParentClass(get_class($oTargetObj), $this->m_sClass)) {
					// 2. Populate the cache for further checks
					$aCacheKeys = [];
					foreach ($aKeyConfig as $sForeignAttCode => $iCol) {
						// The foreign attribute is one of our reconciliation key
						if ($sForeignAttCode == 'id') {
							$value = $aRowData[$iCol];
						} else {
							if (!isset($this->m_aAttList[$sForeignAttCode]) || !isset($aRowData[$this->m_aAttList[$sForeignAttCode]])) {
								// the key is not in the import
								break 2;
							}
							$value = $aRowData[$this->m_aAttList[$sForeignAttCode]];
						}
						$aCacheKeys[] = $value;
					}
					$sCacheKey = implode('_|_', $aCacheKeys); // Unique key for this query...
					$this->m_aExtKeysMappingCache[$sAttCode][$sCacheKey] = [
						'c' => 1,
						'k' => -1,
						'oql' => '',
						'h' => 0, // number of hits on this cache entry
					];
				}
			}
		}

		$aResult[$iRow] = $this->PrepareObject($oTargetObj, $aRowData, $aErrors);

		if (count($aErrors) > 0) {
			$sErrors = implode(', ', $aErrors);
			$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Attribute'));
			//__ERRORS__ used by tests only
			$aResult[$iRow]["__ERRORS__"] = new RowStatus_Error($sErrors);
			return $oTargetObj;
		}

		// Check that any external key will have a value proposed
		$aMissingKeys = [];
		foreach (MetaModel::GetExternalKeys($this->m_sClass) as $sExtKeyAttCode => $oExtKey) {
			if (!$oExtKey->IsNullAllowed()) {
				if (!array_key_exists($sExtKeyAttCode, $this->m_aExtKeys) && !array_key_exists($sExtKeyAttCode, $this->m_aAttList)) {
					$aMissingKeys[] = $oExtKey->GetLabel();
				}
			}
		}
		if (count($aMissingKeys) > 0) {
			$sMissingKeys = implode(', ', $aMissingKeys);
			$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::Format('UI:CSVReport-Row-Issue-MissingExtKey', $sMissingKeys));
			return $oTargetObj;
		}

		// Optionally record the results
		//
		if ($oChange) {
			$newID = $oTargetObj->DBInsert();
		} else {
			$newID = 0;
		}

		$aResult[$iRow]["__STATUS__"] = new RowStatus_NewObj();
		$aResult[$iRow]["finalclass"] = get_class($oTargetObj);
		$aResult[$iRow]["id"] = new CellStatus_Void($newID);

		return $oTargetObj;
	}

	/**
	 * @param array $aResult
	 * @param int $iRow
	 * @param \CMDBObject $oTargetObj
	 * @param array $aRowData
	 * @param \CMDBChange $oChange
	 *
	 * @throws \CoreException
	 * @throws \CoreUnexpectedValue
	 * @throws \MissingQueryArgument
	 * @throws \MySQLException
	 * @throws \MySQLHasGoneAwayException
	 */
	protected function UpdateObject(&$aResult, $iRow, $oTargetObj, $aRowData, ?CMDBChange $oChange = null)
	{
		$aResult[$iRow] = $this->PrepareObject($oTargetObj, $aRowData, $aErrors);

		// Reporting
		//
		$aResult[$iRow]["finalclass"] = get_class($oTargetObj);
		$aResult[$iRow]["id"] = new CellStatus_Void($oTargetObj->GetKey());

		if (count($aErrors) > 0) {
			$sErrors = implode(', ', $aErrors);
			$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Attribute'));
			//__ERRORS__ used by tests only
			$aResult[$iRow]["__ERRORS__"] = new RowStatus_Error($sErrors);
			return;
		}

		$aChangedFields = $oTargetObj->ListChanges();
		if (count($aChangedFields) > 0) {
			$aResult[$iRow]["__STATUS__"] = new RowStatus_Modify(count($aChangedFields));

			// Optionaly record the results
			//
			if ($oChange) {
				try {
					$oTargetObj->DBUpdate();
				} catch (CoreException $e) {
					$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue($e->getMessage());
				}
			}
		} else {
			$aResult[$iRow]["__STATUS__"] = new RowStatus_NoChange();
		}
	}

	/**
	 * @param array $aResult
	 * @param int $iRow
	 * @param \CMDBObject $oTargetObj
	 * @param \CMDBChange $oChange
	 *
	 * @throws \BulkChangeException
	 */
	protected function UpdateMissingObject(&$aResult, $iRow, $oTargetObj, ?CMDBChange $oChange = null)
	{
		$aResult[$iRow] = $this->PrepareMissingObject($oTargetObj, $aErrors);

		// Reporting
		//
		$aResult[$iRow]["finalclass"] = get_class($oTargetObj);
		$aResult[$iRow]["id"] = new CellStatus_Void($oTargetObj->GetKey());

		if (count($aErrors) > 0) {
			$sErrors = implode(', ', $aErrors);
			$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Attribute'));
			//__ERRORS__ used by tests only
			$aResult[$iRow]["__ERRORS__"] = new RowStatus_Error($sErrors);
			return;
		}

		$aChangedFields = $oTargetObj->ListChanges();
		if (count($aChangedFields) > 0) {
			$aResult[$iRow]["__STATUS__"] = new RowStatus_Disappeared(count($aChangedFields));

			// Optionaly record the results
			//
			if ($oChange) {
				try {
					$oTargetObj->DBUpdate();
				} catch (CoreException $e) {
					$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue($e->getMessage());
				}
			}
		} else {
			$aResult[$iRow]["__STATUS__"] = new RowStatus_Disappeared(0);
		}
	}

	public function Process(?CMDBChange $oChange = null)
	{
		if ($oChange) {
			CMDBObject::SetCurrentChange($oChange);
		}

		// Note: $oChange can be null, in which case the aim is to check what would be done

		// Debug...
		//
		if (false) {
			echo "<pre>\n";
			echo "Attributes:\n";
			print_r($this->m_aAttList);
			echo "ExtKeys:\n";
			print_r($this->m_aExtKeys);
			echo "Reconciliation:\n";
			print_r($this->m_aReconcilKeys);
			echo "Synchro scope:\n";
			print_r($this->m_sSynchroScope);
			echo "Synchro changes:\n";
			print_r($this->m_aOnDisappear);
			//echo "Data:\n";
			//print_r($this->m_aData);
			echo "</pre>\n";
			exit;
		}

		$aResult = [];

		if (!is_null($this->m_sDateFormat) && (strlen($this->m_sDateFormat) > 0)) {
			$sDateTimeFormat = $this->m_sDateFormat; // the specified format is actually the date AND time format
			$oDateTimeFormat = new DateTimeFormat($sDateTimeFormat);
			$sDateFormat = $oDateTimeFormat->ToDateFormat();
			AttributeDateTime::SetFormat($oDateTimeFormat);
			AttributeDate::SetFormat(new DateTimeFormat($sDateFormat));
			// Translate dates from the source data
			//
			foreach ($this->m_aAttList as $sAttCode => $iCol) {
				if ($sAttCode == 'id') {
					continue;
				}

				$oAttDef = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode);
				if ($oAttDef instanceof AttributeDateTime) { // AttributeDate is derived from AttributeDateTime
					foreach ($this->m_aData as $iRow => $aRowData) {
						$sFormat = $sDateTimeFormat;
						if (!isset($this->m_aData[$iRow][$iCol])) {
							continue;
						}
						$sValue = $this->m_aData[$iRow][$iCol];
						if (!empty($sValue)) {
							if ($oAttDef instanceof AttributeDate) {
								$sFormat = $sDateFormat;
							}
							$oFormat = new DateTimeFormat($sFormat);
							$sDateExample = $oFormat->Format(new DateTime('2022-10-23 16:25:33'));
							$sRegExp = $oFormat->ToRegExpr('/');
							$sErrorMsg = Dict::Format('UI:CSVReport-Row-Issue-ExpectedDateFormat', $sDateExample);
							if (!preg_match($sRegExp, $sValue)) {
								$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-DateFormat'));
								$aResult[$iRow][$iCol] = new CellStatus_Issue($sValue, null, $sErrorMsg);

							} else {
								$oDate = DateTime::createFromFormat($sFormat, $sValue);
								if ($oDate !== false) {
									$sNewDate = $oDate->format($oAttDef->GetInternalFormat());
									$this->m_aData[$iRow][$iCol] = $sNewDate;
								} else {
									// almost impossible ti reproduce since even incorrect dates with correct formats are formated and $oDate will not be false
									// Leave the cell unchanged
									$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-DateFormat'));
									$aResult[$iRow][$iCol] = new CellStatus_Issue($sValue, null, $sErrorMsg);
								}
							}
						} else {
							$this->m_aData[$iRow][$iCol] = '';
						}
					}
				}
			}
		}

		// Compute the results
		//
		if (!is_null($this->m_sSynchroScope)) {
			$aVisited = [];
		}
		$iPreviousTimeLimit = ini_get('max_execution_time');
		$iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop');

		// Avoid too many events
		cmdbAbstractObject::SetEventDBLinksChangedBlocked(true);
		try {
			foreach ($this->m_aData as $iRow => $aRowData) {
				set_time_limit(intval($iLoopTimeLimit));
				// Stop if not the good number of cols in $aRowData
				if ($this->m_iNbCol > 0 && count($aRowData) != $this->m_iNbCol) {
					$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::Format('UI:CSVReport-Row-Issue-NbField', count($aRowData), $this->m_iNbCol));
					continue;
				}

				if (isset($aResult[$iRow]["__STATUS__"])) {
					// An issue at the earlier steps - skip the rest
					continue;
				}
				try {
					$oReconciliationFilter = new DBObjectSearch($this->m_sClass);
					$bSkipQuery = false;
					foreach ($this->m_aReconcilKeys as $sAttCode) {
						$valuecondition = null;
						if (array_key_exists($sAttCode, $this->m_aExtKeys)) {
							if ($this->IsNullExternalKeySpec($aRowData, $sAttCode)) {
								$oExtKey = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode);
								if ($oExtKey->IsNullAllowed()) {
									$valuecondition = $oExtKey->GetNullValue();
									$aResult[$iRow][$sAttCode] = new CellStatus_Void($oExtKey->GetNullValue());
								} else {
									$aResult[$iRow][$sAttCode] = new CellStatus_NullIssue();
								}
							} else {
								// The value has to be found or verified

								/** var DBObjectSearch $oReconFilter */
								list($oReconFilter, $aMatches) = $this->ResolveExternalKey($aRowData, $sAttCode, $aResult[$iRow]);

								if (count($aMatches) == 1) {
									$oRemoteObj = reset($aMatches); // first item
									$valuecondition = $oRemoteObj->GetKey();
									$aResult[$iRow][$sAttCode] = new CellStatus_Void($oRemoteObj->GetKey());
								} elseif (count($aMatches) == 0) {
									$oCellStatus_SearchIssue = $this->GetCellSearchIssue($oReconFilter);
									$aResult[$iRow][$sAttCode] = $oCellStatus_SearchIssue;
								} else {
									$aResult[$iRow][$sAttCode] = new CellStatus_Ambiguous(null, count($aMatches), $oReconFilter->serialize());
								}
							}
						} else {
							// The value is given in the data row
							$iCol = $this->m_aAttList[$sAttCode];
							if ($sAttCode == 'id') {
								$valuecondition = $aRowData[$iCol];
							} else {
								$oAttDef = MetaModel::GetAttributeDef($this->m_sClass, $sAttCode);
								$valuecondition = $oAttDef->MakeValueFromString($aRowData[$iCol], $this->m_bLocalizedValues);
							}
						}
						if (is_null($valuecondition)) {
							$bSkipQuery = true;
						} else {
							$oReconciliationFilter->AddCondition($sAttCode, $valuecondition, '=', true);
						}
					}
					if ($bSkipQuery) {
						$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Reconciliation'));
					} else {
						$oReconciliationSet = new CMDBObjectSet($oReconciliationFilter);
						switch ($oReconciliationSet->Count()) {
							case 0:
								$oTargetObj = $this->CreateObject($aResult, $iRow, $aRowData, $oChange);
								// $aResult[$iRow]["__STATUS__"]=> set in CreateObject
								$aVisited[] = $oTargetObj->GetKey();
								break;
							case 1:
								$oTargetObj = $oReconciliationSet->Fetch();
								$this->UpdateObject($aResult, $iRow, $oTargetObj, $aRowData, $oChange);
								// $aResult[$iRow]["__STATUS__"]=> set in UpdateObject
								if (!is_null($this->m_sSynchroScope)) {
									$aVisited[] = $oTargetObj->GetKey();
								}
								break;
							default:
								// Found several matches, ambiguous
								$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::S('UI:CSVReport-Row-Issue-Ambiguous'));
								$aResult[$iRow]["id"] = new CellStatus_Ambiguous(0, $oReconciliationSet->Count(), $oReconciliationFilter->serialize());
								$aResult[$iRow]["finalclass"] = 'n/a';
						}
					}
				} catch (Exception $e) {
					$aResult[$iRow]["__STATUS__"] = new RowStatus_Issue(Dict::Format('UI:CSVReport-Row-Issue-Internal', get_class($e), $e->getMessage()));
				}
			}

			if (!is_null($this->m_sSynchroScope)) {
				// Compute the delta between the scope and visited objects
				$oScopeSearch = DBObjectSearch::FromOQL($this->m_sSynchroScope);
				$oScopeSet = new DBObjectSet($oScopeSearch);
				while ($oObj = $oScopeSet->Fetch()) {
					$iObj = $oObj->GetKey();
					if (!in_array($iObj, $aVisited)) {
						set_time_limit(intval($iLoopTimeLimit));
						$iRow++;
						$this->UpdateMissingObject($aResult, $iRow, $oObj, $oChange);
					}
				}
			}
		} finally {
			// Send all the retained events for further computations
			cmdbAbstractObject::SetEventDBLinksChangedBlocked(false);
			cmdbAbstractObject::FireEventDbLinksChangedForAllObjects();
		}

		set_time_limit(intval($iPreviousTimeLimit));

		// Fill in the blanks - the result matrix is expected to be 100% complete
		//
		foreach ($this->m_aData as $iRow => $aRowData) {
			foreach ($this->m_aAttList as $iCol) {
				if (!array_key_exists($iCol, $aResult[$iRow])) {
					if (isset($aRowData[$iCol])) {
						$aResult[$iRow][$iCol] = new CellStatus_Void($aRowData[$iCol]);
					} else {
						$aResult[$iRow][$iCol] = new CellStatus_Issue('', null, Dict::S('UI:CSVReport-Value-Issue-NoValue'));
					}
				}
			}
			foreach ($this->m_aExtKeys as $sAttCode => $aForeignAtts) {
				if (!array_key_exists($sAttCode, $aResult[$iRow])) {
					$aResult[$iRow][$sAttCode] = new CellStatus_Void('n/a');
				}
				foreach ($aForeignAtts as $sForeignAttCode => $iCol) {
					if (!array_key_exists($iCol, $aResult[$iRow])) {
						// The foreign attribute is one of our reconciliation key
						if (isset($aRowData[$iCol])) {
							$aResult[$iRow][$iCol] = new CellStatus_Void($aRowData[$iCol]);
						} else {
							$aResult[$iRow][$iCol] = new CellStatus_Issue('', null, 'UI:CSVReport-Value-Issue-NoValue');
						}
					}
				}
			}
		}

		return $aResult;
	}

	/**
	 * Display the history of bulk imports
	 */
	public static function DisplayImportHistory(WebPage $oPage, $bFromAjax = false, $bShowAll = false)
	{
		$sAjaxDivId = "CSVImportHistory";
		if (!$bFromAjax) {
			$oPage->add('<div id="'.$sAjaxDivId.'">');
		}

		$oPage->p(Dict::S('UI:History:BulkImports+').' <span id="csv_history_reload"></span>');

		$oBulkChangeSearch = DBObjectSearch::FromOQL("SELECT CMDBChange WHERE origin IN ('csv-interactive', 'csv-import.php')");

		$iQueryLimit = $bShowAll ? 0 : appUserPreferences::GetPref('default_page_size', MetaModel::GetConfig()->GetMinDisplayLimit());
		$oBulkChanges = new DBObjectSet($oBulkChangeSearch, ['date' => false], [], null, $iQueryLimit);

		$oAppContext = new ApplicationContext();

		$bLimitExceeded = false;
		if ($oBulkChanges->Count() > (appUserPreferences::GetPref('default_page_size', MetaModel::GetConfig()->GetMinDisplayLimit()))) {
			$bLimitExceeded = true;
			if (!$bShowAll) {
				$iMaxObjects = appUserPreferences::GetPref('default_page_size', MetaModel::GetConfig()->GetMinDisplayLimit());
				$oBulkChanges->SetLimit($iMaxObjects);
			}
		}
		$oBulkChanges->Seek(0);

		$aDetails = [];
		while ($oChange = $oBulkChanges->Fetch()) {
			$sDate = '<a href="csvimport.php?step=10&changeid='.$oChange->GetKey().$oAppContext->GetForLink(true).'">'.$oChange->Get('date').'</a>';
			$sUser = $oChange->GetUserName();
			if (preg_match('/^(.*)\\(CSV\\)$/i', $oChange->Get('userinfo'), $aMatches)) {
				$sUser = $aMatches[1];
			} else {
				$sUser = $oChange->Get('userinfo');
			}

			$oOpSearch = DBObjectSearch::FromOQL("SELECT CMDBChangeOpCreate WHERE change = :change_id");
			$oOpSet = new DBObjectSet($oOpSearch, [], ['change_id' => $oChange->GetKey()]);
			$iCreated = $oOpSet->Count();

			// Get the class from the first item found (assumption: a CSV load is done for a single class)
			if ($oCreateOp = $oOpSet->Fetch()) {
				$sClass = $oCreateOp->Get('objclass');
			}

			$oOpSearch = DBObjectSearch::FromOQL("SELECT CMDBChangeOpSetAttribute WHERE change = :change_id");
			$oOpSet = new DBObjectSet($oOpSearch, [], ['change_id' => $oChange->GetKey()]);

			$aModified = [];
			$aAttList = [];
			while ($oModified = $oOpSet->Fetch()) {
				// Get the class (if not done earlier on object creation)
				$sClass = $oModified->Get('objclass');
				$iKey = $oModified->Get('objkey');
				$sAttCode = $oModified->Get('attcode');

				$aAttList[$sClass][$sAttCode] = true;
				$aModified["$sClass::$iKey"] = true;
			}
			$iModified = count($aModified);

			// Assumption: there is only one class of objects being loaded
			// Then the last class found gives us the class for every object
			if (($iModified > 0) || ($iCreated > 0)) {
				$aDetails[] = ['date' => $sDate, 'user' => $sUser, 'class' => $sClass, 'created' => $iCreated, 'modified' => $iModified];
			}
		}

		$aConfig = [ 'date' => ['label' => Dict::S('UI:History:Date'), 'description' => Dict::S('UI:History:Date+')],
					'user' => ['label' => Dict::S('UI:History:User'), 'description' => Dict::S('UI:History:User+')],
					'class' => ['label' => Dict::S('Core:AttributeClass'), 'description' => Dict::S('Core:AttributeClass+')],
					'created' => ['label' => Dict::S('UI:History:StatsCreations'), 'description' => Dict::S('UI:History:StatsCreations+')],
					'modified' => ['label' => Dict::S('UI:History:StatsModifs'), 'description' => Dict::S('UI:History:StatsModifs+')],
		];

		if ($bLimitExceeded) {
			if ($bShowAll) {
				// Collapsible list
				$oPage->add('<p>'.Dict::Format('UI:CountOfResults', $oBulkChanges->Count()).'&nbsp;&nbsp;<a class="truncated" onclick="OnTruncatedHistoryToggle(false);">'.Dict::S('UI:CollapseList').'</a></p>');
			} else {
				// Truncated list
				$iMinDisplayLimit = appUserPreferences::GetPref('default_page_size', MetaModel::GetConfig()->GetMinDisplayLimit());
				$sCollapsedLabel = Dict::Format('UI:TruncatedResults', $iMinDisplayLimit, $oBulkChanges->Count());
				$sLinkLabel = Dict::S('UI:DisplayAll');
				$oPage->add('<p>'.$sCollapsedLabel.'&nbsp;&nbsp;<a class="truncated" onclick="OnTruncatedHistoryToggle(true);">'.$sLinkLabel.'</p>');

				$oPage->add_ready_script(
					<<<EOF
	$('#$sAjaxDivId table.listResults').addClass('truncated');
	$('#$sAjaxDivId table.listResults tr:last td').addClass('truncated');
EOF
				);

				$sAppContext = $oAppContext->GetForLink();
				$oPage->add_script(
					<<<EOF
	function OnTruncatedHistoryToggle(bShowAll)
	{
		$('#csv_history_reload').html('<img src="' + GetAbsoluteUrlAppRoot() + 'images/indicator.gif"/>');
		$.get(GetAbsoluteUrlAppRoot()+'pages/ajax.render.php?$sAppContext', {operation: 'displayCSVHistory', showall: bShowAll}, function(data)
			{
				$('#$sAjaxDivId').html(data);
			}
		);
	}
EOF
				);
			}
		} else {
			// Normal display - full list without any decoration
		}

		$oPage->table($aConfig, $aDetails);

		if (!$bFromAjax) {
			$oPage->add('</div>');
		}
	}

	/**
	 * Display the details of an import
	 * @param iTopWebPage $oPage
	 * @param $iChange
	 * @throws Exception
	 */
	public static function DisplayImportHistoryDetails(iTopWebPage $oPage, $iChange)
	{
		if ($iChange == 0) {
			throw new Exception("Missing parameter changeid");
		}
		$oChange = MetaModel::GetObject('CMDBChange', $iChange, false);
		if (is_null($oChange)) {
			throw new Exception("Unknown change: $iChange");
		}
		$oPage->add("<div><p><h1>".Dict::Format('UI:History:BulkImportDetails', $oChange->Get('date'), $oChange->GetUserName())."</h1></p></div>\n");

		// Assumption : change made one single class of objects
		$aObjects = [];
		$aAttributes = []; // array of attcode => occurences

		$oOpSearch = DBObjectSearch::FromOQL("SELECT CMDBChangeOp WHERE change = :change_id");
		$oOpSet = new DBObjectSet($oOpSearch, [], ['change_id' => $iChange]);
		while ($oOperation = $oOpSet->Fetch()) {
			$sClass = $oOperation->Get('objclass');
			$iKey = $oOperation->Get('objkey');
			$iObjId = "$sClass::$iKey";
			if (!isset($aObjects[$iObjId])) {
				$aObjects[$iObjId] = [];
				$aObjects[$iObjId]['__class__'] = $sClass;
				$aObjects[$iObjId]['__id__'] = $iKey;
			}
			if (get_class($oOperation) == 'CMDBChangeOpCreate') {
				$aObjects[$iObjId]['__created__'] = true;
			} elseif ($oOperation instanceof CMDBChangeOpSetAttribute) {
				$sAttCode = $oOperation->Get('attcode');

				if ((get_class($oOperation) == 'CMDBChangeOpSetAttributeScalar') || (get_class($oOperation) == 'CMDBChangeOpSetAttributeURL')) {
					$oAttDef = MetaModel::GetAttributeDef($sClass, $sAttCode);
					if ($oAttDef->IsExternalKey()) {
						$sOldValue = Dict::S('UI:UndefinedObject');
						if ($oOperation->Get('oldvalue') != 0) {
							$oOldTarget = MetaModel::GetObject($oAttDef->GetTargetClass(), $oOperation->Get('oldvalue'));
							$sOldValue = $oOldTarget->GetHyperlink();
						}

						$sNewValue = Dict::S('UI:UndefinedObject');
						if ($oOperation->Get('newvalue') != 0) {
							$oNewTarget = MetaModel::GetObject($oAttDef->GetTargetClass(), $oOperation->Get('newvalue'));
							$sNewValue = $oNewTarget->GetHyperlink();
						}
					} else {
						$sOldValue = $oOperation->GetAsHTML('oldvalue');
						$sNewValue = $oOperation->GetAsHTML('newvalue');
					}
					$aObjects[$iObjId][$sAttCode] = $sOldValue.' -&gt; '.$sNewValue;
				} else {
					$aObjects[$iObjId][$sAttCode] = 'n/a';
				}

				if (isset($aAttributes[$sAttCode])) {
					$aAttributes[$sAttCode]++;
				} else {
					$aAttributes[$sAttCode] = 1;
				}
			}
		}

		$aDetails = [];
		foreach ($aObjects as $iUId => $aObjData) {
			$aRow = [];
			$oObject = MetaModel::GetObject($aObjData['__class__'], $aObjData['__id__'], false);
			if (is_null($oObject)) {
				$aRow['object'] = $aObjData['__class__'].'::'.$aObjData['__id__'].' (deleted)';
			} else {
				$aRow['object'] = $oObject->GetHyperlink();
			}
			if (isset($aObjData['__created__'])) {
				$aRow['operation'] = Dict::S('Change:ObjectCreated');
			} else {
				$aRow['operation'] = Dict::S('Change:ObjectModified');
			}
			foreach ($aAttributes as $sAttCode => $iOccurences) {
				if (isset($aObjData[$sAttCode])) {
					$aRow[$sAttCode] = $aObjData[$sAttCode];
				} elseif (!is_null($oObject)) {
					// This is the current vaslue: $oObject->GetAsHtml($sAttCode)
					// whereas we are displaying the value that was set at the time
					// the object was created
					// This requires addtional coding...let's do that later
					$aRow[$sAttCode] = '';
				} else {
					$aRow[$sAttCode] = '';
				}
			}
			$aDetails[] = $aRow;
		}

		$aConfig = [];
		$aConfig['object'] = ['label' => MetaModel::GetName($sClass), 'description' => MetaModel::GetClassDescription($sClass)];
		$aConfig['operation'] = ['label' => Dict::S('UI:History:Changes'), 'description' => Dict::S('UI:History:Changes+')];
		foreach ($aAttributes as $sAttCode => $iOccurences) {
			$aConfig[$sAttCode] = ['label' => MetaModel::GetLabel($sClass, $sAttCode), 'description' => MetaModel::GetDescription($sClass, $sAttCode)];
		}
		$oPage->table($aConfig, $aDetails);
	}
}
