<?php
/*
** Copyright (C) 2001-2025 Zabbix SIA
**
** This program is free software: you can redistribute it and/or modify it under the terms of
** the GNU Affero General Public License as published by the Free Software Foundation, version 3.
**
** 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 Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License along with this program.
** If not, see <https://www.gnu.org/licenses/>.
**/


require_once dirname(__FILE__).'/../../include/CWebTest.php';
require_once dirname(__FILE__).'/../behaviors/CMessageBehavior.php';
require_once dirname(__FILE__).'/../behaviors/CTableBehavior.php';

class testSlaReport extends CWebTest {

	/**
	 * Attach MessageBehavior and TableBehavior to the test.
	 *
	 * @return array
	 */
	public function getBehaviors() {
		return [
			CMessageBehavior::class,
			CTableBehavior::class
		];
	}

	public static $reporting_periods = [];
	public static $period_headers = [
		'Daily' => 'Day',
		'Weekly' => 'Week',
		'Monthly' => 'Month',
		'Quarterly' => 'Quarter',
		'Annually' => 'Year'
	];

	private static $actual_creation_time;	// Actual timestamp when data source was executed.
	private static $service_creation_time;	// Service "Service with problem" creation time, needed for downtime calculation.

	const SLA_CREATION_TIME = 1619827200; // SLA creation timestamp as per scenario - 01.05.2021

	public function getSlaDataWithService() {
		return [
			// Daily with downtime.
			[
				[
					'fields' => [
						'SLA' => 'SLA Daily',
						'Service' => 'Service with problem'
					],
					'reporting_period' => 'Daily',
					'downtimes' => ['EXCLUDED DOWNTIME', 'Second downtime'],
					'check_sorting' => true,
					'expected' => [
						'SLO' => '11.111'
					]
				]
			],
			// Daily without downtime.
			[
				[
					'fields' => [
						'SLA' => 'Update SLA',
						'Service' => 'Parent for 2 levels of child services'
					],
					'reporting_period' => 'Daily',
					'expected' => [
						'SLO' => '99.99',
						'SLI' => 100
					]
				]
			],
			// Weekly SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Weekly',
						'Service' => 'Simple actions service'
					],
					'reporting_period' => 'Weekly',
					'expected' => [
						'SLO' => '55.5555',
						'SLI' => 100
					]
				]
			],
			// Monthly SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Monthly',
						'Service' => 'Simple actions service'
					],
					'reporting_period' => 'Monthly',
					'expected' => [
						'SLO' => '22.22',
						'SLI' => 100
					]
				]
			],
			// Quarterly SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Quarterly',
						'Service' => 'Simple actions service'
					],
					'reporting_period' => 'Quarterly',
					'expected' => [
						'SLO' => '33.33',
						'SLI' => 100
					]
				]
			],
			// Annual SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Annual',
						'Service' => 'Service with problem'
					],
					'reporting_period' => 'Annually',
					'expected' => [
						'SLO' => '44.44',
						'SLI' => 100
					]
				]
			],
			// Incorrect SLA and Service combination.
			[
				[
					'fields' => [
						'SLA' => 'SLA Annual',
						'Service' => 'Child 1'
					],
					'reporting_period' => 'Annually',
					'no_data' => true
				]
			]
		];
	}

	public function getSlaDataWithoutService() {
		return [
			// Daily with downtime.
			[
				[
					'fields' => [
						'SLA' => 'SLA Daily'
					],
					'check_sorting' => true,
					'reporting_period' => 'Daily',
					'expected' => [
						'SLO' => '11.111',
						'services' => ['Service with problem']
					]
				]
			],
			// Daily without downtime.
			[
				[
					'fields' => [
						'SLA' => 'Update SLA'
					],
					'reporting_period' => 'Daily',
					'expected' => [
						'SLO' => '99.99',
						'SLI' => 100,
						'services' => ['Parent for 2 levels of child services']
					]
				]
			],
			// Weekly SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Weekly'
					],
					'reporting_period' => 'Weekly',
					'expected' => [
						'SLO' => '55.5555',
						'SLI' => 100,
						'services' => ['Service with multiple service tags', 'Simple actions service']
					]
				]
			],
			// Monthly SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Monthly'
					],
					'reporting_period' => 'Monthly',
					'expected' => [
						'SLO' => '22.22',
						'SLI' => 100,
						'services' => ['Service with multiple service tags', 'Simple actions service']
					]
				]
			],
			// Quarterly SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Quarterly'
					],
					'reporting_period' => 'Quarterly',
					'expected' => [
						'SLO' => '33.33',
						'SLI' => 100,
						'services' => ['Service with multiple service tags', 'Simple actions service']
					]
				]
			],
			// Annual SLA.
			[
				[
					'fields' => [
						'SLA' => 'SLA Annual'
					],
					'reporting_period' => 'Annually',
					'expected' => [
						'SLO' => '44.44',
						'SLI' => 100,
						'services' => ['Service with problem']
					]
				]
			]
		];
	}

	/**
	 * Create the reference array with reporting periods based on the SLA creation time and current date.
	 */
	public function getDateTimeData() {
		self::$actual_creation_time = CDataHelper::get('Sla.creation_time');
		self::$service_creation_time = CDBHelper::getValue(
				'SELECT created_at FROM services WHERE name='.zbx_dbstr('Service with problem')
		);

		// Construct the reference reporting period array based on the period type.
		foreach (['Daily', 'Weekly', 'Monthly', 'Quarterly', 'Annually'] as $reporting_period) {
			$period_values = [];

			switch ($reporting_period) {
				case 'Daily':
					// By default the last 20 periods are displayed.
					for ($i = 0; $i < 20; $i++) {
						$day = strtotime('today '.-$i.' day');
						$period_values[$i]['value'] = date('Y-m-d', $day);
						$period_values[$i]['start'] = $day;
						$period_values[$i]['end'] = strtotime('tomorrow '.-$i.' day - 1 second');
					}
					break;

				case 'Weekly':
					for ($i = 1; $i <= 20; $i++) {
						// Next Sunday should be taken as period start date in case if today is Sunday (0 represents Sunday).
						$start_string = (date('w', time()) == 0) ? 'Sunday next week ' : 'next Sunday ';

						$period_values[$i]['start'] = strtotime($start_string.-$i.' week');
						$period_values[$i]['end'] = strtotime(date('Y-m-d', $period_values[$i]['start']).' + 7 days - 1 second');

						$period_values[$i]['value'] = date('Y-m-d', $period_values[$i]['start']).' – '.
								date('m-d', $period_values[$i]['end']);
					}
					break;

				case 'Monthly':
					// Get the number of Months to be displayed as difference between today and SLA creation day in months.
					$months = CDateTimeHelper::countMonthsBetweenDates(self::SLA_CREATION_TIME, time());

					$months = ($months > 20) ? 20 : $months;

					for ($i = 0; $i < $months; $i++) {
						$month = strtotime('first day of this month '.-$i.' month');
						$period_values[$i]['value'] = date('Y-m', $month);
						$period_values[$i]['start'] = strtotime(date('Y-m').' '.-$i.' month');
						$period_values[$i]['end'] = strtotime(date('Y-m').' '.(-$i+1).' month - 1 second');
					}
					break;

				case 'Quarterly':
					$quarters = ['01 – 03', '04 – 06', '07 – 09', '10 – 12'];
					$current_year = date('Y');
					$current_month = date('m');

					$i = 0;
					for ($year = date('Y', self::SLA_CREATION_TIME); $year <= date('Y'); $year++) {
						foreach ($quarters as $quarter) {
							// Get the last and the first month of the quarter under attention.
							$period_end = ltrim(stristr($quarter, '– '), '– ');
							$period_start = substr($quarter, 0, strpos($quarter, " –"));

							// Skip the quarters before SLA creation quarter in SLA creation year.
							if ($year === date('Y', self::SLA_CREATION_TIME) && $period_end < date("m", self::SLA_CREATION_TIME)) {
								continue;
							}

							// Write periods into reference array if period start is not later than current month.
							if ($year < $current_year || ($year == $current_year && $period_start <= $current_month)) {
								$period_values[$i]['value'] = $year.'-'.$quarter;
								$period_values[$i]['start'] = strtotime($year.'-'.$period_start);
								$period_values[$i]['end'] = strtotime($year.'-'.$period_end.' + 1 month - 1 second');

								$i++;
							}
						}
					}
					$period_values = array_reverse($period_values);
					break;

				case 'Annually':
					// Get the number of Years to be displayed as difference between this year and SLA creation year.
					$years = (date('Y') - date('Y', self::SLA_CREATION_TIME));

					for ($i = 0; $i <= $years; $i++) {
						$year = strtotime('this year '.-$i.' years');
						$period_values[$i]['value'] = date('Y', $year);
						$period_values[$i]['start'] = strtotime(date('Y', $year).'-01-01');
						$period_values[$i]['end'] = strtotime(date('Y', $year).'-01-01 +1 year -1 second');
					}
					break;
			}

			self::$reporting_periods[$reporting_period] = $period_values;
		}
	}

	/**
	 * Check SLA report when SLA is specified together with the corresponding Service.
	 *
	 * @param array		$data		test case related data from data provider.
	 * @param boolean	$widget		flag that specifies whether the check is made in the SLA report or SLA report widget.
	 */
	public function checkLayoutWithService($data, $widget = false) {
		$creation_day = date('Y-m-d', self::$actual_creation_time);

		$table = ($widget)
			? CDashboardElement::find()->one()->getWidget($data['fields']['Name'])->query('class:list-table')->asTable()->one()
			: $this->query('class:list-table')->asTable()->one();

		// Check empty result if non-related SLA + Service or disabled SLA (in widget) is selected and proceed with next test.
		if (CTestArrayHelper::get($data, 'no_data')) {
			$string = (array_key_exists('expected', $data)) ? $data['expected'] : 'No data found';
			$this->assertEquals([$string], $table->getRows()->asText());
			$this->assertFalse($table->query('xpath://div[@class="table-stats"]')->one(false)->isValid());

			return;
		}

		// Get the timestamp when screen was loaded and the reference reporting periods.
		$load_time = time();
		$reference_periods = self::$reporting_periods[$data['reporting_period']];

		// Check table headers text and check that none of them are clickable.
		$this->assertEquals([self::$period_headers[$data['reporting_period']], 'SLO', 'SLI', 'Uptime', 'Downtime',
				'Error budget', 'Excluded downtimes'], $table->getHeadersText()
		);

		if (CTestArrayHelper::get($data, 'check_sorting')) {
			$this->assertEquals([], $table->getSortableHeaders()->asText());
		}

		// This test is written taking into account that only SLA with daily reporting period has ongoing downtimes.
		if (array_key_exists('downtimes', $data)) {
			// Downtime starts from min(SLA creation timestamp, Service creation timestamp).
			$downtime_start = min(self::$actual_creation_time, self::$service_creation_time);
			$downtime_values = [];
			/**
			 * If the date has changed since data source was executed, then downtimes will be divided into 2 days.
			 * Such case is covered in the else statement.
			 */
			if (date('Y-m-d') === $creation_day) {
				foreach ($data['downtimes'] as $downtime_name) {
					/**
					 * A second or two can pass from Downtime duration calculation till report is loaded.
					 * So an array of expected results is created and the presence of actual value in array is checked.
					 */
					$single_downtime = [];

					for ($i = 0; $i <= 3; $i++) {
						$single_downtime[] = date('Y-m-d H:i', $downtime_start).' '.$downtime_name.': '
								.convertUnitsS($load_time - $downtime_start + $i);
					}

					$downtime_values[$downtime_name] = $single_downtime;

					unset($single_downtime);
				}
				// Check that each of the obtained downtimes is present in the created reference arrays.
				$row = $table->findRow(self::$period_headers[$data['reporting_period']], $creation_day);
				$this->checkDowntimePresent($row, $downtime_values);
			}
			else {
				foreach ([date('Y-m-d'), $creation_day] as $day) {
					if ($day === $creation_day) {
						foreach ($data['downtimes'] as $downtime_name) {
							/**
							 * Time is counted from min(SLA creation timestamp, Service creation timestamp) till the start
							 * of next period. This time difference is not dependent on view load time, so no need for "for" cycle.
							 */
							$single_downtime = [];
							$single_downtime[] = date('Y-m-d H:i', $downtime_start).' '.$downtime_name.': '
									.convertUnitsS(strtotime('today') - $downtime_start);
							$downtime_values[] = $single_downtime;

							unset($single_downtime);
						}
					}
					else {
						foreach ($data['downtimes'] as $downtime_name) {
							// Time is counted from  period start till page load time.
							$single_downtime = [];
							for ($i = 0; $i <= 3; $i++) {
								$single_downtime[] = date('Y-m-d H:i', strtotime('today')).' '.$downtime_name.': '
										.convertUnitsS($load_time - strtotime('today') + $i);
							}
							$downtime_values[] = $single_downtime;

							unset($single_downtime);
						}
					}

					$row = $table->findRow(self::$period_headers[$data['reporting_period']], $day);
					$this->checkDowntimePresent($row, $downtime_values);
				}
			}
		}
		else {
			foreach ($reference_periods as $period) {
				// If no downtime is expected, then check that the Downtime column is empty.
				$row = $table->findRow(self::$period_headers[$data['reporting_period']], $period['value']);
				$this->assertEquals('', $row->getColumn('Excluded downtimes')->getText());
			}
		}

		// Check other columns of the displayed report.
		foreach ($reference_periods as $period) {
			$row = $table->findRow(self::$period_headers[$data['reporting_period']], $period['value']);
			$this->assertEquals($data['expected']['SLO'].'%', $row->getColumn('SLO')->getText());

			/**
			 * SLI is displayed for periods from SLA actual creation time to page load time.
			 * If SLI is expected, then Uptime and Error budget should be calculated and checked.
			 */
			if (array_key_exists('SLI', $data['expected']) && $period['end'] > self::$actual_creation_time) {
				$this->assertEquals($data['expected']['SLI'], $row->getColumn('SLI')->getText());

				// Check Uptime and Error budget values. These values are calculated only from the actual SLA creation time.
				$uptime = $row->getColumn('Uptime')->getText();
				if ($period['end'] > $load_time) {
					$reference_uptime = [];
					// If SLA created in current period, calculation starts from creation timestamp, else from period start.
					$start_time = max($period['start'], min(self::$actual_creation_time, self::$service_creation_time));

					/**
					 * Get array of Uptime possible values and check that the correct one is there.
					 * Sometimes uptime start is by 1 second larger than obtained 2 rows above, so $i counter starts from -1.
					 */
					for ($i = -1; $i <= 3; $i++) {
						$reference_uptime[] = convertUnitsS($load_time - $start_time + $i);
					}

					$this->assertTrue(in_array($uptime, $reference_uptime), 'Uptime '.$uptime.' is not among values '.
							implode(', ', $reference_uptime)
					);

					// Calculate the error budet based on the actual uptime and compare with actual error budget.
					$uptime_seconds = 0;
					foreach (explode(' ', $uptime) as $time_unit) {
						$uptime_seconds = $uptime_seconds + timeUnitToSeconds($time_unit);
					}

					// In rare cases expected and actual error budget can slightly differ due to calculation precision.
					foreach([-1, 0, 1] as $delta) {
						$error_budget[] = convertUnitsS(intval($uptime_seconds / floatval($data['expected']['SLO']) * 100)
							- $uptime_seconds + $delta
						);
					}

					$this->assertTrue(in_array($actual_budget = $row->getColumn('Error budget')->getText(), $error_budget),
							'Error budget '.$actual_budget.' is not present among values '.implode(', ', $error_budget)
					);
				}
				else {
					$reference_uptime = [];
					$uptime_start = min(self::$actual_creation_time, self::$service_creation_time);

					// Sometimes uptime start is by 1 second larger than obtained 2 rows above, so $i counter starts from -1.
					for ($i = -1; $i <= 3; $i++) {
						$reference_uptime[] = convertUnitsS($period['end'] - $uptime_start + $i);
					}
					$this->assertTrue(in_array($uptime, $reference_uptime), 'Uptime '.$uptime.' is not among values '.
							implode(', ', $reference_uptime)
					);

					// Error budget is always 0 for periods that have already passed.
					$this->assertEquals('0', $row->getColumn('Error budget')->getText());
				}
			}
			else {
				$this->assertEquals('N/A', $row->getColumn('SLI')->getText());
				$this->assertEquals('0', $row->getColumn('Uptime')->getText());
				$this->assertEquals('0', $row->getColumn('Error budget')->getText());
			}

			$this->assertEquals('0', $row->getColumn('Downtime')->getText());
		}
	}

	/**
	 * Check the SLA report in case if only SLA is specified (without Service).
	 *
	 * @param array		$data		test case related data from data provider
	 * @param boolean	$widget		flag that specifies whether the check is made in the SLA report or SLA report widget.
	 */
	public function checkLayoutWithoutService($data, $widget = false) {
		// This if condition is here specifically to check case when displaying disabled SLA on SLA report widget.
		if (array_key_exists('no_data', $data)) {
			$table = CDashboardElement::find()->one()->getWidget($data['fields']['Name'])->query('class:list-table')->asTable()->one();
			$this->assertEquals([$data['expected']], $table->getRows()->asText());

			return;
		}
		$reference_periods = self::$reporting_periods[$data['reporting_period']];
		$count = count($data['expected']['services']);

		if ($widget) {
			$table = CDashboardElement::find()->one()->getWidget($data['fields']['Name'])->query('class:list-table')->asTable()->one();
			$this->assertEquals('Displaying '.$count.' of '.$count.' found',
				$table->query('xpath:.//td[@class="list-table-footer"]')->one()->getText()
			);
		}
		else {
			$table = $this->query('class:list-table')->asTable()->one();
			$this->assertTableStats($count);
		}

		$headers = ['Service', 'SLO'];
		foreach (array_reverse($reference_periods) as $period) {
			$headers[] = $period['value'];
		}
		$this->assertEquals($headers, $table->getHeadersText());

		if (CTestArrayHelper::get($data, 'check_sorting')) {
			// Only "Service" column is sortable.
			$this->assertEquals($widget ? [] : ['Service'], $table->getSortableHeaders()->asText());
		}

		foreach ($data['expected']['services'] as $service) {
			$row = $table->findRow('Service', $service);

			$this->assertEquals($data['expected']['SLO'].'%', $row->getColumn('SLO')->getText());

			// For SLA without service periods are shown in ascending order, so reference array should be reversed.
			foreach (array_reverse($reference_periods) as $period) {
				if (array_key_exists('SLI', $data['expected']) && $period['end'] > self::$actual_creation_time) {
					$this->assertEquals($data['expected']['SLI'], $row->getColumn($period['value'])->getText());
				}
				else {
					$this->assertEquals('N/A', $row->getColumn($period['value'])->getText());
				}
			}
		}
	}

	/**
	 * Split cell into active downtimes and check that it is present in the reference array.
	 *
	 * @param CTableRowElement	$row				row that contains the downtime values to be checked
	 * @param array				$downtime_values	reference array that should contain the downtime to be checked
	 */
	private function checkDowntimePresent($row, $downtime_values) {
		// Split column value into downtimes.
		foreach (explode("\n", $row->getColumn('Excluded downtimes')->getText()) as $downtime) {
			// Record if downtime found in reference downtime arrays.
			$match_found = false;
			foreach ($downtime_values as $downtime_array) {
				if (in_array($downtime, $downtime_array)) {
					$match_found = true;

					break;
				}
			}

			$this->assertTrue($match_found, 'Downtime "'.$downtime.'" is not present in downtime reference array');
		}
	}

	/**
	 * Check the layout and the contents on the dialogs in SLA and Service multiselect elements.
	 *
	 * @param array		$dialog_data	array that contains all of the reference data needed to check dialog layout
	 * @param boolean	$widget			flag that specified whether the check is made in the SLA report or SLA report widget
	 */
	public function checkDialogContents($dialog_data, $widget = false) {
		$form_selector = ($widget) ? 'name:widget_dialogue_form' : 'name:zbx_filter';
		$form = $this->query($form_selector)->one()->asForm();
		$form->getField($dialog_data['field'])->query('button:Select')->waitUntilClickable()->one()->click();
		$dialog = COverlayDialogElement::find()->waitUntilReady()->all()->last();

		$this->assertEquals($dialog_data['field'], $dialog->getTitle());

		if ($dialog_data['field'] === 'Service') {
			// Check filter form.
			$filter_form = $dialog->query('name:services_filter_form')->one();
			$this->assertEquals('Name', $filter_form->query('xpath:.//label')->one()->getText());
			$filter_input = $filter_form->query('name:filter_name')->one();
			$this->assertEquals(255, $filter_input->getAttribute('maxlength'));

			// Filter out all unwanted services before checking table content.
			$filter_input->fill($dialog_data['check_row']['Name']);
			$dialog->query('button:Filter')->one()->click();
			$dialog->waitUntilReady();

			// Check the content of the services list.
			$this->assertTableData([$dialog_data['check_row']], $dialog_data['table_selector']);

			$filter_form->query('button:Reset')->one()->click();
			$dialog->waitUntilReady();
		}

		$this->assertEquals($dialog_data['headers'], $dialog->query('class:list-table')->asTable()->one()->getHeadersText());

		if (array_key_exists('column_data', $dialog_data)) {
			foreach ($dialog_data['column_data'] as $column => $values) {
				$this->assertTableDataColumn($values, $column, $dialog_data['table_selector']);
			}
		}
		else {
			$table = $dialog->query('class:list-table')->asTable()->one();
			$this->assertEquals(CDBHelper::getCount('SELECT serviceid FROM services'), $table->getRows()->count());
		}

		$this->assertEquals(count($dialog_data['buttons']), $dialog->query('button', $dialog_data['buttons'])->all()
				->filter(new CElementFilter(CElementFilter::CLICKABLE))->count()
		);
		$dialog->query('button:Cancel')->one()->click();
	}
}