<?php

use LAM\PDF\PDFTable;
use LAM\PDF\PDFTableCell;
use LAM\PDF\PDFTableRow;
use LAM\TYPES\ConfiguredType;
use LAM\TYPES\TypeManager;

/*

  This code is part of LDAP Account Manager (http://www.ldap-account-manager.org/)
  Copyright (C) 2015 - 2026  Roland Gruber

  This program is free software; you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation; either version 2 of the License, or
  (at your option) any later version.

  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 General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

*/

/**
 * Manages memberships in NIS net groups.
 *
 * @package modules
 * @author Roland Gruber
 */

/**
 * Manages memberships in NIS net groups.
 *
 * @package modules
 */
class nisNetGroupUser extends baseModule {

	/** @var array<array<string, string|string[]>> list of NIS netgroups the user is memberOf (array(array(name => '', dn => '', host => '', domain => ''))) */
	protected $groups = [];
	/** @var array<array<string, string|string[]>> list of NIS netgroups the user was memberOf (array(array(name => '', dn => '', host => '', domain => ''))) */
	protected $groupsOrig = [];
	/** @var array<string, string[]|string>[]|null $groupCache group cache (array(array(cn => '', dn => '', nisnetgrouptriple => []))) */
	protected $groupCache;
	/** original uid */
	protected ?string $uidOrig = null;

	/**
	 * {@inheritDoc}
	 */
	public function can_manage(): bool {
		return $this->get_scope() === 'user';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_metaData(): array {
		$return = [];
		// icon
		$return['icon'] = 'group.svg';
		// module dependencies
		$return['dependencies'] = ['depends' => [['posixAccount', 'inetOrgPerson']], 'conflicts' => []];
		// alias name
		$return["alias"] = _("NIS net groups");
		// available PDF fields
		$return['PDF_fields']['memberships'] = _('NIS net groups');
		// help Entries
		$return['help'] = [
			'memberships_upload' => [
				"Headline" => _('NIS net groups'),
				"Text" => _("Here you can enter a list of net groups. Group blocks are separated by comma in format GROUP#HOST#DOMAIN. Host and domain are optional.")
			],
		];
		// upload columns
		$return['upload_columns'][] = [
			'name' => 'nisNetGroup_memberships',
			'description' => _('Memberships'),
			'help' => 'memberships_upload',
			'example' => 'group1#host#domain,group2#host#domain'
		];
		return $return;
	}

	/**
	 * {@inheritDoc}
	 */
	protected function load_Messages(): void {
		$this->messages['host'][0] = ['ERROR', _('Host name'), _('Host name contains invalid characters. Valid characters are: a-z, A-Z, 0-9 and .-_ !')];
		$this->messages['domain'][0] = ['ERROR', _('Domain name'), _('Domain name is invalid!')];
	}

	/**
	 * {@inheritDoc}
	 */
	public function init(string $base): void {
		// call parent init
		parent::init($base);
		$this->groups = [];
		$this->groupsOrig = [];
	}

	/**
	 * {@inheritDoc}
	 */
	public function load_attributes(array $attributes): void {
		parent::load_attributes($attributes);
		if (empty($attributes['uid'][0])) {
			return;
		}
		$this->uidOrig = $attributes['uid'][0];
		$typeManager = new TypeManager();
		$types = $typeManager->getConfiguredTypesForScope('netgroup');
		$groupList = [];
		foreach ($types as $type) {
			$filter = '(&(objectClass=nisNetgroup)(nisnetgrouptriple=*))';
			$typeFilter = $type->getAdditionalLdapFilter();
			if (!empty($typeFilter)) {
				if (!str_starts_with($typeFilter, '(')) {
					$typeFilter = '(' . $typeFilter . ')';
				}
				$filter = '(&' . $filter . $typeFilter . ')';
			}
			$groupsFound = searchLDAP($type->getSuffix(), $filter, ['dn', 'cn', 'nisnetgrouptriple']);
			$groupList = array_merge($groupList, $groupsFound);
		}
		$this->groupsOrig = [];
		$tripleRegex = '/^\\(([^,]*),([^,]*),([^,]*)\\)$/';
		foreach ($groupList as $group) {
			foreach ($group['nisnetgrouptriple'] as $triple) {
				$matches = [];
				if (preg_match($tripleRegex, $triple, $matches) == 0) {
					continue;
				}
				$host = $matches[1];
				$user = $matches[2];
				$domain = $matches[3];
				if ($this->isMatchingNetGroup($user, $host, $domain, $this->uidOrig)) {
					$this->groupsOrig[] = [
						'name' => $group['cn'][0],
						'dn' => $group['dn'],
						'host' => $host,
						'user' => $user,
						'domain' => $domain
					];
				}
			}
		}
		usort($this->groupsOrig, $this->sortTriple(...));
		$this->groups = $this->groupsOrig;
	}

	/**
	 * Checks if the netgroup matches this entry.
	 *
	 * @param string $user netgroup user name
	 * @param string $host netgroup host name
	 * @param string $domain netgroup domain name
	 * @param string $uid username of this entry
	 * @return bool matching
	 */
	protected function isMatchingNetGroup($user, $host, $domain, $uid): bool {
		return $user == $uid;
	}

	/**
	 * Displays the group selection.
	 *
	 * @return htmlElement meta HTML code
	 */
	public function display_html_attributes() {
		$return = new htmlTable();
		$return->addElement(new htmlOutputText(_('Group')));
		$return->addElement(new htmlOutputText(_('Host name')));
		$return->addElement(new htmlOutputText(_('Domain name')), true);
		for ($i = 0; $i < count($this->groups); $i++) {
			$group = $this->groups[$i];
			$return->addElement(new htmlOutputText($group['name']));
			$return->addElement(new htmlInputField('host_' . $i, $group['host']));
			$return->addElement(new htmlInputField('domain_' . $i, $group['domain']));
			$delButton = new htmlButton('del_' . $i, 'del.svg', true);
			$delButton->setTitle(_('Delete'));
			$return->addElement($delButton, true);
		}
		$return->addVerticalSpace('40px');

		// new entry
		$groupList = [];
		$groupData = $this->findGroups();
		if (count($groupData) > 0) {
			$filterGroup = new htmlGroup();
			$filterGroup->addElement(new htmlOutputText(_('Filter') . ' '));
			$filter = new htmlInputField('group_filter');
			$filter->setFieldSize(5);
			$filter->filterSelectBox('group_add');
			$filterGroup->addElement($filter);
			$return->addElement($filterGroup, true);

			foreach ($groupData as $group) {
				$groupList[$group['cn'][0]] = $group['cn'][0] . '#+#' . $group['dn'];
			}
			$groupSelect = new htmlSelect('group_add', $groupList);
			$groupSelect->setHasDescriptiveElements(true);
			$return->addElement($groupSelect);
			$return->addElement(new htmlInputField('host_add'));
			$return->addElement(new htmlInputField('domain_add'));
			$addButton = new htmlButton('addGroup', 'add.svg', true);
			$addButton->setTitle(_('Add'));
			$return->addElement($addButton, true);
		}
		return $return;
	}

	/**
	 * {@inheritDoc}
	 */
	public function process_attributes(): array {
		$errors = [];
		// add new entry
		if (isset($_POST['addGroup'])) {
			$parts = explode('#+#', $_POST['group_add']);
			$this->groups[] = [
				'name' => $parts[0],
				'dn' => $parts[1],
				'host' => $_POST['host_add'],
				'user' => $this->uidOrig,
				'domain' => $_POST['domain_add']
			];
			if (!empty($_POST['host_add']) && !get_preg($_POST['host_add'], 'DNSname')) {
				$message = $this->messages['host'][0];
				$message[2] = $message[2] . '<br><br>' . $_POST['host_add'];
				$errors[] = $message;
			}
			if (!empty($_POST['domain_add']) && !get_preg($_POST['domain_add'], 'DNSname')) {
				$message = $this->messages['domain'][0];
				$message[2] = $message[2] . '<br><br>' . $_POST['domain_add'];
				$errors[] = $message;
			}
		}
		// check existing
		$counter = 0;
		while (isset($_POST['host_' . $counter])) {
			if (isset($_POST['del_' . $counter])) {
				unset($this->groups[$counter]);
			}
			else {
				$this->groups[$counter]['host'] = $_POST['host_' . $counter];
				if (!empty($_POST['host_' . $counter]) && !get_preg($_POST['host_' . $counter], 'DNSname')) {
					$message = $this->messages['host'][0];
					$message[2] = $message[2] . '<br><br>' . $_POST['host_' . $counter];
					$errors[] = $message;
				}
				$this->groups[$counter]['domain'] = $_POST['domain_' . $counter];
				if (!empty($_POST['domain_' . $counter]) && !get_preg($_POST['domain_' . $counter], 'DNSname')) {
					$message = $this->messages['domain'][0];
					$message[2] = $message[2] . '<br><br>' . $_POST['domain_' . $counter];
					$errors[] = $message;
				}
			}
			$counter++;
		}
		$this->groups = array_values($this->groups);
		usort($this->groups, $this->sortTriple(...));
		return $errors;
	}

	/**
	 * Returns the user ID for this user.
	 *
	 * @return string|null user ID
	 */
	protected function getUid(): ?string {
		if ($this->getAccountContainer()->getAccountModule('posixAccount') != null) {
			$moduleAttributes = $this->getAccountContainer()->getAccountModule('posixAccount')->getAttributes();
		}
		else {
			$moduleAttributes = $this->getAccountContainer()->getAccountModule('inetOrgPerson')->getAttributes();
		}
		if (empty($moduleAttributes['uid'][0])) {
			return null;
		}
		return $moduleAttributes['uid'][0];
	}

	/**
	 * {@inheritDoc}
	 */
	public function postModifyActions(bool $newAccount, array $attributes): array {
		$accountContainer = $this->getAccountContainer();
		if (empty($accountContainer)) {
			return [];
		}
		$uid = $this->getUid();
		if (empty($uid)) {
			return [];
		}
		$messages = [];
		// calculate differences
		$toRem = $this->groupsOrig;
		$toAdd = $this->groups;
		$counter = count($toRem);
		for ($i = 0; $i < $counter; $i++) {
			$group_orig = $toRem[$i];
			foreach ($toAdd as $k => $group) {
				if (($group_orig['dn'] == $group['dn'])
					&& ($group_orig['domain'] == $group['domain'])
					&& ($group_orig['user'] == $group['user'])
					&& ($group_orig['host'] == $group['host'])) {
					if (empty($this->uidOrig) || ($this->uidOrig === $uid)) {
						// do not touch existing memberships
						unset($toRem[$i]);
						unset($toAdd[$k]);
					}
					break;
				}
			}
		}
		// group by DN
		$changes = [];
		foreach ($toAdd as $add) {
			$changes[$add['dn']]['add'][] = $this->createNetGroupValue($add, $uid);
		}
		foreach ($toRem as $del) {
			$delUid = empty($this->uidOrig) ? $uid : $this->uidOrig;
			$changes[$del['dn']]['del'][] = $this->createNetGroupValue($del, $delUid);
		}
		// update groups
		foreach ($changes as $dn => $changeSet) {
			$current = ldapGetDN($dn, ['nisnetgrouptriple']);
			if (empty($current)) {
				$messages[] = ['ERROR', sprintf(_('Was unable to modify attributes of DN: %s.'), $dn)];
				continue;
			}
			$triples = empty($current['nisnetgrouptriple']) ? [] : $current['nisnetgrouptriple'];
			if (isset($changeSet['del'][0])) {
				$triples = array_delete($changeSet['del'], $triples);
			}
			if (isset($changeSet['add'][0])) {
				$triples = array_merge($changeSet['add'], $triples);
			}
			$triples = array_values(array_unique($triples));
			$attributes = [
				'nisnetgrouptriple' => $triples
			];
			logNewMessage(LOG_DEBUG, 'Updating ' . $dn . ' with ' . print_r($attributes, true));
			$success = @ldap_mod_replace($_SESSION['ldap']->server(), $dn, $attributes);
			if (!$success) {
				logNewMessage(LOG_ERR, 'Unable to modify attributes of DN: ' . $dn . ' (' . ldap_error($_SESSION['ldap']->server()) . ').');
				$messages[] = ['ERROR', sprintf(_('Was unable to modify attributes of DN: %s.'), $dn), getDefaultLDAPErrorString($_SESSION['ldap']->server())];
			}
		}
		return $messages;
	}

	/**
	 * Creates a netgroup triple from a group object.
	 *
	 * @param array<string, string> $group group object
	 * @param string $uid own uid
	 * @return string value
	 */
	protected function createNetGroupValue(array $group, string $uid): string {
		return '(' . $group['host'] . ',' . $uid . ',' . $group['domain'] . ')';
	}

	/**
	 * {@inheritDoc}
	 */
	public function delete_attributes(): array {
		$uid = $this->getUid();
		if (empty($uid)) {
			return [];
		}
		$return = [];
		// remove from NIS netgroups
		$changes = [];
		foreach ($this->groups as $group) {
			$changes[$group['dn']][] = $this->createNetGroupValue($group, $uid);
		}
		foreach ($changes as $dn => $changeSet) {
			$current = ldapGetDN($dn, ['nisnetgrouptriple']);
			if (empty($current)) {
				$return[$this->getAccountContainer()->dn_orig]['errors'][] = ['ERROR', sprintf(_('Was unable to modify attributes of DN: %s.'), $dn)];
				continue;
			}
			$triples = empty($current['nisnetgrouptriple']) ? [] : $current['nisnetgrouptriple'];
			$triples = array_delete($changeSet, $triples);
			$triples = array_values(array_unique($triples));
			$attributes = [
				'nisnetgrouptriple' => $triples
			];
			$success = @ldap_mod_replace($_SESSION['ldap']->server(), $dn, $attributes);
			if (!$success) {
				logNewMessage(LOG_ERR, 'Unable to modify attributes of DN: ' . $dn . ' (' . ldap_error($_SESSION['ldap']->server()) . ').');
				$return[$this->getAccountContainer()->dn_orig]['errors'][] = ['ERROR', sprintf(_('Was unable to modify attributes of DN: %s.'), $dn), getDefaultLDAPErrorString($_SESSION['ldap']->server())];
			}
		}
		return $return;
	}

	/**
	 * {@inheritDoc}
	 */
	function get_profileOptions($typeId) {
		$groups = $this->findGroups();
		$groupOptions = ['' => ''];
		foreach ($groups as $group) {
			$groupOptions[$group['cn'][0]] = $group['cn'][0] . '#+#' . $group['dn'];
		}
		$return = new htmlResponsiveRow();
		$labels = [_('Group'), _('Host name'), _('Domain name')];
		$data = [];
		for ($i = 0; $i < 5; $i++) {
			$select = new htmlSelect('nisNetGroupUser_group' . $i, $groupOptions, ['']);
			$select->setHasDescriptiveElements(true);
			$data[$i][] = $select;
			$data[$i][] = new htmlInputField('nisNetGroupUser_host' . $i);
			$data[$i][] = new htmlInputField('nisNetGroupUser_domain' . $i);
		}
		$return->add(new htmlResponsiveTable($labels, $data));
		return $return;
	}

	/**
	 * {@inheritDoc}
	 */
	public function load_profile(array $profile): void {
		for ($i = 0; $i < 5; $i++) {
			if (!empty($profile['nisNetGroupUser_group' . $i][0])) {
				$parts = explode('#+#', $profile['nisNetGroupUser_group' . $i][0]);
				$this->groups[] = [
					'name' => $parts[0],
					'dn' => $parts[1],
					'host' => $profile['nisNetGroupUser_host' . $i][0],
					'user' => $this->uidOrig,
					'domain' => $profile['nisNetGroupUser_domain' . $i][0],
				];
			}
		}
		usort($this->groups, $this->sortTriple(...));
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_pdfEntries(array $pdfKeys, string $typeId): array {
		$return = [];
		$pdfTable = new PDFTable();
		$pdfRow = new PDFTableRow();
		$pdfRow->cells[] = new PDFTableCell(_('Group'), '25%', null, true);
		$pdfRow->cells[] = new PDFTableCell(_('Host name'), '25%', null, true);
		$pdfRow->cells[] = new PDFTableCell(_('Domain name'), '25%', null, true);
		$pdfTable->rows[] = $pdfRow;
		foreach ($this->groups as $group) {
			$pdfRow = new PDFTableRow();
			$pdfRow->cells[] = new PDFTableCell($group['name'], '25%');
			$pdfRow->cells[] = new PDFTableCell($group['host'], '25%');
			$pdfRow->cells[] = new PDFTableCell($group['domain'], '25%');
			$pdfTable->rows[] = $pdfRow;
		}
		$this->addPDFTable($return, 'memberships', $pdfTable);
		return $return;
	}

	/**
	 * {@inheritDoc}
	 */
	public function build_uploadAccounts(array $rawAccounts, array $ids, array &$partialAccounts, array $selectedModules, ConfiguredType &$type): array {
		$errors = [];
		// get list of existing group of names
		$groups = $this->findGroups();
		$groupNames = [];
		foreach ($groups as $group) {
			$groupNames[] = $group['cn'][0];
		}
		// check input
		for ($i = 0; $i < count($rawAccounts); $i++) {
			// group names
			if (!empty($rawAccounts[$i][$ids['nisNetGroup_memberships']])) {
				$triples = preg_split('/,[ ]*/', $rawAccounts[$i][$ids['nisNetGroup_memberships']]);
				foreach ($triples as $triple) {
					$parts = explode('#', $triple);
					if (!in_array($parts[0], $groupNames)) {
						$errors[] = ['ERROR', _('Unable to find group in LDAP.'), $parts[0]];
					}
				}
			}
		}
		return $errors;
	}

	/**
	 * {@inheritDoc}
	 */
	public function doUploadPostActions(array &$data, array $ids, array $failed, array &$temp, array &$accounts,
										array $selectedModules, ConfiguredType $type): array {
		if (!checkIfWriteAccessIsAllowed($this->get_scope())) {
			die();
		}
		// on first call generate list of LDAP operations
		if (!isset($temp['counter'])) {
			$temp['groups'] = [];
			$temp['counter'] = 0;
			// get list of existing groups
			$groupList = $this->findGroups();
			$groupMap = [];
			foreach ($groupList as $group) {
				$groupMap[$group['cn'][0]] = $group['dn'];
			}
			for ($i = 0; $i < count($data); $i++) {
				if (in_array($i, $failed)) {
					continue;
				} // ignore failed accounts
				if (empty($accounts[$i]['uid'])) {
					continue;
				}
				$uid = $accounts[$i]['uid'];
				if (!empty($data[$i][$ids['nisNetGroup_memberships']])) {
					$triples = preg_split('/,[ ]*/', $data[$i][$ids['nisNetGroup_memberships']]);
					foreach ($triples as $triple) {
						$parts = explode('#', $triple);
						$group = $parts[0];
						$temp['groups'][$groupMap[$group]][] = $this->buildNetGroupTripleFromUploadValue($parts, $uid);
					}
				}
			}
			$temp['groupDNs'] = array_keys($temp['groups']);
			return [
				'status' => 'inProgress',
				'progress' => 0,
				'errors' => []
			];
		}
		// add users to groups
		elseif ($temp['counter'] < count($temp['groupDNs'])) {
			$errors = [];
			$dn = $temp['groupDNs'][$temp['counter']];
			$current = ldapGetDN($dn, ['nisnetgrouptriple']);
			if (empty($current)) {
				$errors[] = ['ERROR', sprintf(_('Was unable to modify attributes of DN: %s.'), $dn)];
				$temp['counter']++;
				return [
					'status' => 'inProgress',
					'progress' => ($temp['counter'] * 100) / count($temp['groupDNs']),
					'errors' => $errors
				];
			}
			$triples = empty($current['nisnetgrouptriple']) ? [] : $current['nisnetgrouptriple'];
			$triples = array_merge($temp['groups'][$dn], $triples);
			$triples = array_values(array_unique($triples));
			$attributes = [
				'nisnetgrouptriple' => $triples
			];
			$success = @ldap_mod_replace($_SESSION['ldap']->server(), $dn, $attributes);
			if (!$success) {
				$errors[] = [
					"ERROR",
					_("LAM was unable to modify group memberships for group: %s"),
					getDefaultLDAPErrorString($_SESSION['ldap']->server()),
					[$dn]
				];
			}
			$temp['counter']++;
			return [
				'status' => 'inProgress',
				'progress' => ($temp['counter'] * 100) / count($temp['groupDNs']),
				'errors' => $errors
			];
		}
		// all modifications are done
		else {
			return [
				'status' => 'finished',
				'progress' => 100,
				'errors' => []
			];
		}
	}

	/**
	 * Creates a netgroup triple from the input value of file upload.
	 *
	 * @param string[] $value upload value (e.g. array(group1, host, domain))
	 * @param string $uid own uid
	 * @return string netgroup triple
	 */
	protected function buildNetGroupTripleFromUploadValue($value, $uid): string {
		$host = empty($value[1]) ? '' : $value[1];
		$domain = empty($value[2]) ? '' : $value[2];
		return '(' . $host . ',' . $uid . ',' . $domain . ')';
	}

	/**
	 * Finds all existing LDAP NIS net groups.
	 *
	 * @return array<string, string[]|string>[] groups array(array(cn => [], dn => '', nisnetgrouptriple => []))
	 */
	protected function findGroups() {
		if ($this->groupCache !== null) {
			return $this->groupCache;
		}
		$return = [];
		$typeManager = new TypeManager();
		$types = $typeManager->getConfiguredTypesForScope('netgroup');
		foreach ($types as $type) {
			$filter = '(objectClass=nisNetgroup)';
			$typeFilter = $type->getAdditionalLdapFilter();
			if (!empty($typeFilter)) {
				if (!str_starts_with($typeFilter, '(')) {
					$typeFilter = '(' . $typeFilter . ')';
				}
				$filter = '(&' . $filter . $typeFilter . ')';
			}
			$results = searchLDAP($type->getSuffix(), $filter, ['cn', 'dn', 'nisnetgrouptriple']);
			for ($i = 0; $i < count($results); $i++) {
				if (isset($results[$i]['cn'][0]) && isset($results[$i]['dn'])) {
					$return[] = $results[$i];
				}
			}
		}
		$this->groupCache = $return;
		return $return;
	}

	/**
	 * Sorts NIS netgroup triples by group, host, user and domain.
	 *
	 * @param array<string, string> $first first array
	 * @param array<string, string> $second second array
	 * @return int comparison result
	 */
	protected function sortTriple(array $first, array $second): int {
		if ($first['name'] != $second['name']) {
			return strnatcasecmp($first['name'], $second['name']);
		}
		elseif ($first['host'] != $second['host']) {
			return strnatcasecmp($first['host'], $second['host']);
		}
		elseif ($first['user'] != $second['user']) {
			return strnatcasecmp($first['user'], $second['user']);
		}
		return strnatcasecmp($first['domain'], $second['domain']);
	}

}
