<?php
/**
 * COmanage Registry Grouper Lite Widget Groups Model
 *
 * Portions licensed to the University Corporation for Advanced Internet
 * Development, Inc. ("UCAID") under one or more contributor license agreements.
 * See the NOTICE file distributed with this work for additional information
 * regarding copyright ownership.
 *
 * UCAID licenses this file to you under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with the
 * License. You may obtain a copy of the License at:
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * @link          http://www.internet2.edu/comanage COmanage Project
 * @package       registry-plugin
 * @since         COmanage Registry v3.2.0
 * @license       Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
 */

App::uses('GrouperApiAccess', 'GrouperLiteWidget.Lib/');
App::uses('GrouperAttribute', 'GrouperLiteWidget.Model/');

/***
 * Class GrouperGroup
 *
 * Model class that does most of the heavy lifting in Grouper Lite Widget
 */
class GrouperGroup extends GrouperLiteWidgetAppModel
{
  // XXX According to the documentation (https://spaces.at.internet2.edu/display/Grouper/UI+Terminology)
  //     the displayExtension is the Friendly Name of the Group.

  /** @var string $name used by CakePHP for locating model */
  public $name = 'GrouperGroup';

  /** @var GrouperApiAccess $grouperAPI */
  private $grouperAPI = null;

  private $wgStemsTopLevel = array(
    'ref:incommon-collab',
    'ref:internet2-collab'
  );

  // Current listing of groups that are associated to Working Groups
  private $wgStemsAllGroups = array(
    'app:jira',
    'app:confluence',
    'app:sympa:internet2',
    'app:sympa:incommon',
    'ref:incommon-collab',
    'ref:internet2-collab'
  );


  /**
   * Verifies if a user is an owner/admin of a group.
   * Session variable is reset on Group Creation and Group Deletion
   *
   * @param   string  $userId  Id of User
   * @param   string  $actorUserId
   * @param   array   $cfg
   *
   * @return bool T or F
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  public function isUserGroupOwner(string $userId, string $actorUserId, array $cfg): bool
  {
    $this->initApi($cfg);

    if(empty($userId) || empty($actorUserId)) {
      return false;
    }

    try {
      $resultsAdmin = $this->grouperAPI->getUserMemberships($userId, $actorUserId, GrouperGroupTypeEnum::ADMIN);
      $resultsUpdate = $this->grouperAPI->getUserMemberships($userId, $actorUserId, GrouperGroupTypeEnum::UPDATE);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    return count($resultsAdmin['WsGetMembershipsResults']['wsGroups'] ?? []) > 0
           || count($resultsUpdate['WsGetMembershipsResults']['wsGroups'] ?? []) > 0;
  }

  /**
   * Verifies if user can see the Grouper link button. Even if owner, may be not allowed to see it.
   *
   * @param string $userId Id of User
   * @return String T or F
   * @throws GrouperLiteWidgetException
   *
   * @since  COmanage Registry v4.4.0
   */
  public function isGrouperVisible(string $userId, array $cfg): string
  {
    $this->initApi($cfg);

    try {
      $isMember = $this->grouperAPI->isMemberOfGroup(GrouperSpecialGroups::GROUPER_VISIBLE_GROUP, $userId);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    return $isMember ? 'T' : 'F';
  }

  /**
   * Grouper API Instance Singleton
   *
   * @throws GrouperLiteWidgetException
   *
   * @since  COmanage Registry v4.4.0
   */
  private function initApi(array $cfg) {
    if ($this->grouperAPI === null) {
      $this->grouperAPI = new GrouperApiAccess($cfg);
    }
  }

  /**
   * Add a member to a specific Grouper Group
   *
   * @param   string  $actorUserId  Id of User
   * @param   string  $groupName
   * @param   string  $addUserId
   * @param   array   $cfg
   *
   * @return string success of Request
   * @throws GrouperLiteWidgetException Captured in Controller
   * @throws JsonException
   * @since  COmanage Registry v4.4.0
   */
  public function addGroupMember(string $actorUserId, string $groupName, string $addUserId, array $cfg)
  {
    $this->initApi($cfg);

    return $this->grouperAPI->addGroupMember($actorUserId, $groupName, $addUserId);
  }

  /**
   * Breakout Working Groups from AdHoc Groups.
   *
   * @param array $recordSet
   * @return array[]
   *
   */
  public function breakoutWrkFromAdHocGroups(array $recordSet): array
  {
    $wgData = [];
    $notWGData = [];
    //Parse out the Working Groups from the Ad-hoc groups
    foreach ($recordSet as $record) {
      if (isset($record['WGName'])) {
        $wgData[] = $record;
      } else {
        $notWGData[] = $record;
      }
    }

    return [
      'adhoc' => $notWGData,
      'working' => $wgData
    ];
  }

  /**
   * Create a new Grouper Group using the Template methodology in Grouper
   *
   * @param string $userId Id of User
   * @param array $groupData Data needed to create new Grouper Group via Template
   * @return array status and error message, if applicable
   * @throws GrouperLiteWidgetException
   *
   * @since  COmanage Registry v4.4.0
   */
  public function createGroupWithTemplate(string $userId, array $groupData, array $cfg)
  {
    $this->initApi($cfg);
    //Need to massage incoming data to meet Grouper Template requirements
    $fields = array(
      'gsh_input_isSympa',
      'gsh_input_isSympaModerated',
      'gsh_input_isOptin',
      'gsh_input_isConfluence',
      'gsh_input_isJira'
    );
    // Template does not except true/false, so convert to string and send that way
    foreach ($fields as $field) {
      ($groupData[$field] == '0') ? $groupData[$field] = 'false' : $groupData[$field] = 'true';
    }

    $args = array();
    $args['userId'] = $userId;
    $args['data'] = $groupData;
    try {
      return $this->grouperAPI->createGroupWithTemplate($args);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }
  }

  /**
   * Construct picker(like) response data based on mode and term
   *
   * @param   integer       $coId  CO ID
   * @param   string        $mode  Search mode to apply filters for
   * @param   array         $coPersonIds  List of PersonIds
   *
   * @return array          Array of CO Person records
   * @since  COmanage Registry v4.4.0
   */
  public function dataConstructForPicker(int $coId, string $mode, array $coPersonIds): array
  {
    if(empty($coPersonIds)) {
      return [];
    }

    $this->CoPerson = ClassRegistry::init('CoPerson');
    $this->Co = ClassRegistry::init('Co');

    $matches = [];

    if(count($coPersonIds) > 100) {
      // We don't return large sets to avoid slow performance

      $matches[] = array(
        'value' => -1,
        'label' => _txt('er.picker.toomany')
      );
    } else {
      $people = $this->CoPerson->filterPicker($coId, $coPersonIds, $mode);
      $pickerEmailType = $this->Co->CoSetting->getPersonPickerEmailType($coId);
      $pickerIdentifierType = $this->Co->CoSetting->getPersonPickerIdentifierType($coId);
      $pickerDisplayTypes = $this->Co->CoSetting->getPersonPickerDisplayTypes($coId);

      foreach($people as $p) {
        $label = generateCn($p['Name'][0]);
        $idArr = $p['Identifier'];
        $emailArr = $p['EmailAddress'];
        $email = '';
        $email_short = '';
        $emailLabel = '';
        $id = '';
        $id_short = '';
        $idLabel = '';

        // Iterate over the email array
        if(!empty($emailArr) && !empty($pickerEmailType)) {
          $emailLabel = !empty($pickerDisplayTypes) ? _txt('fd.extended_type.generic.label', array(_txt('fd.email_address.mail'), $pickerEmailType))
                                                    : _txt('fd.email_address.mail') . ': ';
          foreach($emailArr as $e) {
            if($e['type'] == $pickerEmailType) {
              $email = $e['mail'];
              $email_short = mb_strimwidth($e['mail'], 0, 30, '...');
              break;
            }
          }
        }

        // Set the identifier for display (and limit it to 30 characters max)
        if(!empty($idArr[0]['identifier']) && !empty($pickerIdentifierType)) {
          if(!empty($pickerDisplayTypes)) {
            $idLabel = _txt('fd.extended_type.generic.label', array(_txt('fd.identifier.identifier'), $pickerIdentifierType));
          }
          else {
            $idLabel = _txt('fd.identifier.identifier') . ': ';
          }
          foreach($idArr as $i) {
            if($i['type'] == $pickerIdentifierType) {
              $id_short = mb_strimwidth($i['identifier'], 0, 30, '...');
              $id = $i['identifier'];
              break;
            }
          }
        }

        // Make sure we don't already have an entry for this CO Person ID
        if(!Hash::check($matches, '{n}[value='.$p['CoPerson']['id'].']')) {
          $matches[] = array(
            'value' => $p['CoPerson']['id'],
            'label' => $label,
            'email' => $email,
            'emailShort' => $email_short,
            'emailLabel' => $emailLabel,
            'emailType' => $pickerEmailType,
            'identifier' => $id,
            'identifierShort' => $id_short,
            'identifierLabel' => $idLabel,
            'identifierType' => $pickerIdentifierType
          );
        }
      }
    }

    return $matches;
  }

  /**
   * Return all Groups that a User belongs to in Grouper.
   * Will also add OptOut Groups and flag them as joined so can display an Optout option in the UI.
   *
   * @param   string  $userId
   * @param   string  $actorUserId
   * @param   array   $cfg
   *
   * @return array Records of Groups from Grouper that the User belongs to
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  public function filteredMemberOfGroups(string $userId, string $actorUserId, array $cfg): array
  {
    $this->initApi($cfg);

    try {
      $memberOfGroups = $this->memberOfGroups($actorUserId, $userId, $cfg);
      // Determine which groups can be left by user, if wanted.
      $optOutGroups = $this->grouperAPI->getUserMemberships($userId, $actorUserId, GrouperGroupTypeEnum::OPTOUTS);
      $optOutGroupsNames = Hash::combine($optOutGroups, '{n}.name', '{n}.displayExtension');

      foreach ($memberOfGroups as &$memberOfGroup) {
        $memberOfGroup['optOut'] = isset($optOutGroupsNames[$memberOfGroup['name']]);
      }

      return $this->getFriendlyWorkingGroupName($memberOfGroups, 'member');

    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }
  }

  /**
   * Find People based on mode and term
   *
   * @param   integer       $coId  CO ID
   * @param   string        $mode  Search mode to apply filters for
   * @param   string|null   $term  Search block
   *
   * @return array          Array of CO Person records
   * @since  COmanage Registry v4.4.0
   */
  public function findForPicker(int $coId, string $mode, ?string $term): array
  {
    $coPersonIds = [];
    $this->CoPerson = ClassRegistry::init('CoPerson');

    // jquery Autocomplete sends the search as url?term=foo
    if(!empty($term)) {
      // Leverage-model-specific keyword search

      // Note EmailAddress and Identifier don't support substring search
      foreach(array('Name', 'EmailAddress', 'Identifier') as $m) {
        $hits = $this->CoPerson->$m->search($coId, $term, 25);

        $coPersonIds = array_merge($coPersonIds, Hash::extract($hits, '{n}.CoPerson.id'));
      }
    }

    $coPersonIds = array_unique($coPersonIds);

    // Look up additional information to provide hints as to which person is which.
    // We only do this when there are relatively small numbers of results to
    // avoid making a bunch of database queries early in the search.

    return $this->dataConstructForPicker($coId, $term,$coPersonIds);
  }

  /**
   * Return array of Working Groups for display on COManage site.
   * Logic is for each WG to have one key=>value of main WG name, then array of all associated
   * Groups.
   *
   * NOTE: This is a major hack due to Grouper not giving us the right logic for displaying, so have to run all
   * groups through a mapped listing of types of Groups in a WG to see if match and then parse and massage to display
   *
   * @param array $groups Listing of Groups
   * @return array Listing of Groups in WG format for display

   * @since  COmanage Registry v4.4.0
   */
  private function getFriendlyWorkingGroupName(array $groups, string $method) {
    $arrayIndex = 0;
    $workingGroups = array();

    //First need to loop through all groups and pull in all top levels
    $topLevelWG = array();
    foreach ($groups as $group) {
      foreach ($this->wgStemsTopLevel as $stem) {
        $len = strlen($stem);
        if (substr(strtolower($group['name']), 0, $len) === $stem) {
          $stemSections = explode(':', $group['name']);
          //Get a third section, since will always by after ref:something:here
          if (in_array($stemSections[2], $topLevelWG) === false) {
            $topLevelWG[] = $stemSections[2];
          }
        }
      }
    }

    //Loop through groups to see if possibly part of a Working Group
    foreach ($groups as &$group) {
      foreach ($this->wgStemsAllGroups as $stem) {
        $len = strlen($stem);
        // if match to name of group within WG mapping then start making a WG group array
        if (substr(strtolower($group['name']), 0, $len) === $stem) {
          $tempGroup = $group;
          if ($stem == 'ref:incommon-collab' || $stem == 'ref:internet2-collab') {
            $mainGroup = true;
          } else {
            $mainGroup = false;
          }
          $stemSections = explode(':', $group['name']);
          $displaySections = explode(':', $group['displayName']);
          //Get second to last stem section
          $sectionCount = 2;
          //If group not part of a top level WG, then do not show!
          if (in_array($stemSections[$sectionCount], $topLevelWG) === false) {
            break;
          }
          $tempGroup['WGName'] = $stemSections[$sectionCount];
          $tempGroup['WGShowName'] = $displaySections[$sectionCount];
          // Get user type, which is after the WG name
          if (isset($stemSections[$sectionCount + 1])) {
            $tempGroup['WGRole'] = $stemSections[$sectionCount + 1];
          } else {
            $tempGroup['WGRole'] = '';
          }
          $appCount = 0;
          $appName = '';
          foreach ($stemSections as $stemSection) {
            //Skip first entry
            if ($appCount > 0) {
              if ($appCount < $sectionCount) {
                if ($appCount == 1) {
                  $appName = $stemSection;
                } else {
                  $appName = $appName . " - " . $stemSection;
                }
              }
            }
            $appCount += 1;
          }
          //changed the way email list are displayed to actually show lists email address.
          if ($appName == 'sympa - internet2' || $appName == 'sympa - incommon') {
            if ($appName == 'sympa - internet2') {
              $appName = $tempGroup['WGName'] . '@lists.' . 'internet2.edu';
            } else {
              $appName = $tempGroup['WGName'] . '@lists.' . 'incommon.org';
            }

          }
          $tempGroup['WGApp'] = $appName;
          if ($method == 'member') {
            if(!$mainGroup) {
              $workingGroups[] = $tempGroup;
              unset($groups[$arrayIndex]);
            }
          } else {
            $workingGroups[] = $tempGroup;
            unset($groups[$arrayIndex]);
          }
        }
      }
      $arrayIndex += 1;
    }
    $finalWorkingGroups = array();

    foreach ($workingGroups as $workingGroup) {
      //Need to set first group in final Working Group array
      if (count($finalWorkingGroups) == 0) {
        $finalWorkingGroups[] = array(
          'WGName' => $workingGroup['WGName'],
          'WGShowName' => $workingGroup['WGShowName'],
          'Groups' => array($workingGroup)
        );
      } else {
        $foundMatch = false;
        foreach ($finalWorkingGroups as &$finalWorkingGroup) {
          if ($finalWorkingGroup['WGName'] == $workingGroup['WGName']) {
            $finalWorkingGroup['WGShowName'] = $workingGroup['WGShowName'];
            $finalWorkingGroup['Groups'][] = $workingGroup;
            $foundMatch = true;
          }
        }
        if (!$foundMatch) {
          $finalWorkingGroups[] = array(
            'WGName' => $workingGroup['WGName'],
            'WGShowName' => $workingGroup['WGShowName'],
            'Groups' => array($workingGroup)
          );
        }
      }
    }

    $friendlyGroups = array_values($groups);

    //Now need to add the groups back together for one set
    foreach ($friendlyGroups as $friendlyGroup) {
      $finalWorkingGroups[] = $friendlyGroup;
    }

    return $finalWorkingGroups;
  }

  /**
   * Retrieve the identifier for a CO Person
   *
   * @param   int     $co_person_id
   * @param   string  $ident_type
   *
   * @return string|null
   */
  public function getIdentifierFromPersonId(int $co_person_id, string $ident_type): ?string
  {
    if(empty($co_person_id) || empty($ident_type)) {
      return null;
    }

    // Find the identifier
    $args = array();
    $args['conditions']['Identifier.type'] = $ident_type;
    $args['conditions']['Identifier.status'] = SuspendableStatusEnum::Active;
    $args['conditions']['Identifier.co_person_id'] = $co_person_id;
    $args['contain'] = false;

    $this->Identifier = ClassRegistry::init('Identifier');

    $identifier = $this->Identifier->find('first', $args);
    if (!empty($identifier)) {
      return $identifier['Identifier']['identifier'];
    }

    return null;
  }

  /**
   * Get members associated to a specific Grouper Group
   * NOTE: This list only shows members, it does not pull in other groups that may be attached in Grouper as
   * members
   *
   * @param   string  $actorUserId  Id of User
   * @param   string  $groupName
   * @param   array   $cfg
   *
   * @return array Listing of members in requested Grouper Group
   * @throws GrouperLiteWidgetException Captured in Controller
   * @throws JsonException
   * @since  COmanage Registry v4.4.0
   */
  public function getGroupMembers(string $actorUserId, string $groupName, array $cfg): array
  {
    $this->initApi($cfg);

    try {
      $groupMembers = $this->grouperAPI->getGroupMembers($actorUserId, $groupName);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    if (count($groupMembers) < 1) {
      return $groupMembers;
    }

    $finalMembers = [];
    foreach ($groupMembers as $member) {
      if ($member['sourceId'] !== 'g:gsa') {
        $finalMembers[] = $member;
      }

    }
    return $finalMembers;
  }

  /**
   * Return all Grouper Groups that
   *  - the User(me) has a role of owner/admin
   *  - the User (member User) is a member
   *
   * @param   string  $managerId     User(me) has a role of owner/admin
   * @param   string  $userId        User (member User) is a member
   * @param   array   $cfg
   *
   * @return array
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  public function getManagedUsers(string $managerId, string $userId, array $cfg): array {
    if(empty($userId) || empty($managerId)) {
      return false;
    }

    $this->initApi($cfg);

    try {
      $resultsManagerAdmin = $this->grouperAPI->getUserMemberships($managerId,
                                                                   $managerId,
                                                                   GrouperGroupTypeEnum::ADMIN);
      $resultsManagerUpdate = $this->grouperAPI->getUserMemberships($managerId,
                                                                    $managerId,
                                                                    GrouperGroupTypeEnum::UPDATE);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    $managerGroupSet = $this->removeDuplicates($resultsManagerAdmin, $resultsManagerUpdate);

    try {
      // Groups the user is a member of
      $membersGroup = $this->grouperAPI->getUserGroups($managerId, $userId);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    // Extract the names of the Groups the member-user is a member of
    $memberGroupNames = Hash::extract($membersGroup, '{n}.name');
    // Return the groups the user can join and is not a member of
    return array_values( // Restart indexing from 0(zero) on the final array
      array_filter(      // Return the groups the member-user is a member
        $managerGroupSet,
        static fn($value) => in_array($value['name'], $memberGroupNames)
      )
    );
  }

  /**
   * Return all Grouper Groups that the User has a role of owner/admin
   *
   * @param   string  $userId
   * @param   string  $actorUserId
   * @param   array   $cfg
   *
   * @return array
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  public function getOwnedGroups(string $userId, string $actorUserId, array $cfg): array
  {
    if(empty($userId) || empty($actorUserId)) {
      return false;
    }

    $this->initApi($cfg);

    try {
      $resultsAdmin = $this->grouperAPI->getUserMemberships($userId,
                                                            $actorUserId,
                                                            GrouperGroupTypeEnum::ADMIN);
      $resultsUpdate = $this->grouperAPI->getUserMemberships($userId,
                                                             $actorUserId,
                                                             GrouperGroupTypeEnum::UPDATE);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    return $this->removeDuplicates($resultsAdmin, $resultsUpdate);
  }

  /**
   * Potential use was for creating an adhoc group by a user, not associated to WG.
   *
   * Gets all Stems/Folders where User is admin/owner
   *
   * @param   string  $userId
   * @param   string  $actorUserId
   *
   * @return array Array of Stems/Folders from Grouper
   * @throws GrouperLiteWidgetException
   */
  public function getOwnedStems(string $userId, string $actorUserId): array
  {
    try {
      return $this->grouperAPI->getUserMemberships($userId,
                                                   $actorUserId,
                                                   GrouperGroupTypeEnum::ADMIN);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }
  }

  /**
   * Search for Groups/Lists related to Search term.
   *
   * Will import all records the user can see and then do search in this code rather than call Grouper WS Search
   * functionality. This is because the grouperName is autogenerated and this app needs to search
   * attributes which the Grouper WS does not do.
   *
   * @param   string  $userId
   * @param   string  $searchCriteria
   * @param   string  $searchPage
   * @param   array   $cfg
   *
   * @return array Records that meet search criteria
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  public function getSearchedGroups(string $userId, string $actorUserId, string $searchCriteria, string $searchPage, array $cfg): array
  {
    $this->initApi($cfg);

    try {
      // Breakout page where search was called and forward to appropriate method for processing
      $pageResults = isset($searchPage) ? $this->$searchPage(userId: $userId,
                                                             actorUserId: $actorUserId,
                                                             cfg: $cfg) : [];

      $returnResults = [];

      foreach ($pageResults as $result) {
        $compare = $result;
        unset($compare['extension']);
        unset($compare['uuid']);
        unset($compare['enabled']);
        unset($compare['typeOfGroup']);
        unset($compare['idIndex']);
        $match = preg_grep("/$searchCriteria/i", $compare);
        if (!empty($match)) {
          $returnResults[] = $result;
        }
      }

      return $searchCriteria == 'getSearchedGroups' ? $this->getFriendlyWorkingGroupName($returnResults, 'member')
        : $returnResults;

    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }
  }

  /**
   * Determine if a User can use the Grouper Template to create a Working Group.
   *
   * @param   string  $userId      User ID
   * @param   string  $groupName   Group Name
   * @param   array   $cfg
   *
   * @return bool T for True and F for False
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  public function isGroupMember(string $userId, string $groupName, array $cfg): bool
  {
    $this->initApi($cfg);

    try {
      $isMember = $this->grouperAPI->isMemberOfGroup($groupName, $userId);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    return (bool)$isMember;
  }

  /**
   * Determine if a User can use the Grouper Template to create a Working Group.
   *
   * @param   string  $userId  User ID
   * @param   array   $cfg
   *
   * @return bool
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  public function isTemplateUser(string $userId, array $cfg): bool
  {
    $this->initApi($cfg);

    try {
      return $this->grouperAPI->isMemberOfGroup(GrouperSpecialGroups::TEMPLATE_CREATION_GROUP, $userId);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }
  }

  /**
   * Internal process used by other functions to fetch Groups the User is a member of
   *
   * @param   string  $actorUserId
   * @param   string  $userId
   * @param   array   $cfg
   *
   * @return array Records of Groups from Grouper that the User belongs to
   * @throws GrouperLiteWidgetException
   * @since  COmanage Registry v4.4.0
   */
  private function memberOfGroups(string $actorUserId, string $userId, array $cfg)
  {
    $this->initApi($cfg);

    try {
      return $this->grouperAPI->getUserGroups($actorUserId, $userId);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }
  }

  /**
   * Return all Groups the User can JOIN
   * Get all Groups with Optin attribute set and display ones User can join.
   * Will Match up with Groups User is already a member of to determine which Optin groups to not display
   *
   * @param   string  $userId
   * @param   string  $actorUserId
   * @param   array   $cfg
   *
   * @return array Listing of Optin groups available in Grouper
   * @throws GrouperLiteWidgetException Captured in Controller
   * @since  COmanage Registry v4.4.0
   */
  public function optinGroups(string $userId, string $actorUserId, array $cfg): array
  {
    $this->initApi($cfg);

    try {
      // Groups the user can join or leave
      $joinOrLeave = $this->grouperAPI->getUserMemberships($userId,
                                                           $actorUserId,
                                                           GrouperGroupTypeEnum::OPTINS);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    try {
      // Groups the user is a member of
      $userGroups = $this->grouperAPI->getUserGroups($actorUserId, $userId);
    } catch (Exception $e) {
      CakeLog::write('error', __METHOD__ . ': An error occurred');
      throw $e;
    }

    // I am currently not a member to any Group. Return everything
    if(empty($userGroups)) {
      return $joinOrLeave;
    }

    // Extract the names of the Groups the user is a member of
    $userGroupsNames = Hash::extract($userGroups, '{n}.name');
    // Return the groups the user can join and is not a member of
    return array_values( // Restart indexing from 0(zero) on the final array
      array_filter(      // Return the groups I am currently not a member
        $joinOrLeave,
        static fn($value) => !in_array($value['name'], $userGroupsNames)
      )
    );
  }

  /**
   * Remove a member from a specific Grouper Group
   *
   * @param   string  $actorUserId
   * @param   string  $groupName
   * @param   string  $removeUserId
   * @param   array   $cfg
   *
   * @return bool success of Request
   * @throws GrouperLiteWidgetException Captured in Controller
   * @throws JsonException Captured in Controller
   * @since  COmanage Registry v4.4.0
   */
  public function removeGroupMember(string $actorUserId,
                                    string $groupName,
                                    string $removeUserId,
                                    array $cfg): bool
  {
    $this->initApi($cfg);

    return $this->grouperAPI->removeGroupMember($actorUserId, $groupName, $removeUserId);
  }

  /**
   * Removes duplicates where the user is the owner and the updater of the group. Just one line instead of two.
   *
   * @param array $arrOne
   * @param array $arrTwo
   * @return array
   */
  public function removeDuplicates(array $arrOne, array $arrTwo)
  {
    // If one of the arrays is empty then return the other one as is
    if(empty($arrOne) && empty($arrTwo)) {
      return [];
    } else if(empty($arrOne)) {
      return $arrTwo;
    } else if(empty($arrTwo)) {
      return $arrOne;
    }

    $uniqueArr = Hash::combine($arrOne, '{n}.uuid', '{n}');
    foreach($arrTwo as $data) {
      if(!isset($uniqueArr[ $data['uuid'] ])) {
        $uniqueArr[ $data['uuid'] ] = $data;
      }
    }

    return array_values($uniqueArr);
  }
}