<?php

/**
 * Implements hook_form_FORM_ID_alter() for user_form().
 */
function password_policy_form_user_form_alter(&$form, &$form_state) {

  //hide password reset field if no access
  $account = \Drupal::currentUser();
  if (!$account->hasPermission('manage password reset')) {
    $form['field_last_password_reset']['#access'] = FALSE;
    $form['field_password_expiration']['#access'] = FALSE;
  }

  //TODO - Password editing of existing account is broken, AJAX reloads current password and password multiple times

  //user interface changes
  //TODO - Consider hiding Password Strength indicator and Password Recommendations
  $form['account']['roles']['#weight'] = '0';
  $form['account']['mail']['#weight'] = '1';
  $form['account']['name']['#weight'] = '2';
  $form['account']['status']['#weight'] = '5';
  $form['account']['notify']['#weight'] = '6';
  $form['account']['pass']['#weight'] = '3';

  // Check for specific conditions.
  $show_password_policy_status = _password_policy_show_policy();

  // Load form if relevant.
  if ($show_password_policy_status) {
    $form['account']['password_policy_status'] = array(
      '#title' => 'Password policies',
      '#type' => 'table',
      '#header' => array(t('Policy'), t('Status'), t('Constraint')),
      '#empty' => t('There are no constraints for the selected user roles'),
      '#weight' => '4',
      '#prefix' => '<div id="password-policy-status">',
      '#suffix' => '</div>',
      '#rows' => _password_policy_constraints_table($form, $form_state),
    );

    //set ajax changes
    $form['account']['roles']['#ajax'] = array(
      'event' => 'change',
      'callback' => '_password_policy_check_constraints',
      'method' => 'replace',
      'wrapper' => 'password-policy-status',
    );

    $form['#validate'][] = '_password_policy_user_profile_form_validate';
  }

  $form['actions']['submit']['#submit'][] = '_password_policy_user_profile_form_submit';
}

function password_policy_element_info_alter(array &$types) {
  if (isset($types['password_confirm'])) {
    $types['password_confirm']['#process'][] = 'password_policy_check_constraints_password_confirm_process';
  }
}

/**
 * Determine if the password policy should be shown.
 *
 * @return bool
 *   Result of whether the password policy should be shown.
 */
function _password_policy_show_policy() {
  $account = \Drupal::currentUser();
  $config = \Drupal::config('user.settings');
  $show_password_policy_status = TRUE;

  if ($account->hasPermission('bypass password policies')) {
    $show_password_policy_status = FALSE;
  }

  if ($account->isAnonymous() and $config->get('verify_mail')) {
    $show_password_policy_status = FALSE;
  }
  return $show_password_policy_status;
}

function password_policy_check_constraints_password_confirm_process($element){
  if (_password_policy_show_policy()) {
    $element['pass1']['#ajax'] = array(
      'event' => 'change',
      'callback' => '_password_policy_check_constraints',
      'method' => 'replace',
      'wrapper' => 'password-policy-status',
    );
  }

  return $element;
}

/**
 *  Check if password policies failed
 * @param $form
 * @param $form_state
 */
function _password_policy_user_profile_form_validate(&$form, &$form_state) {

  return _password_policy_constraints_validate($form, $form_state);
}

/**
 *  Validate password policy constraints and generate table if required.
 * @param $form
 * @param $form_state
 * @param $generate_policy_table - set this to true if you want to generate policy table on user add/edit page
 * @param $policies_table_rows - variable which holds applicable policies in array format.
 */
function _password_policy_constraints_validate(&$form, &$form_state, $generate_policy_table = FALSE, &$policies_table_rows = array()) {
  $expiration = $form_state->getValue('field_password_expiration');
  if (!is_null($expiration) && $expiration['value'] === FALSE) {
    $form_state->setValue('field_password_expiration', array('value' => 0));
  }
  
  $roles = $form_state->getValue('roles');
  if (empty($roles)) {
    //get if from $form; form state is always empty the first time.
    $roles = $form['account']['roles']['#default_value'];
  }
  $roles = array_combine($roles, $roles);

  $orignal_roles = $form['account']['roles']['#default_value'];
  $orignal_roles = array_combine($orignal_roles, $orignal_roles);
  
  //add user doesn't automatically register authenticated, so lets add it
  if (empty($roles)) {
    $roles = array('authenticated' => 'authenticated');
  }

  //run validation
  $applicable_policies = array();
  $ids = array();
  foreach ($roles as $role_key => $role_enabled) {
    if ($role_enabled) {
      $role_map = array('roles.'.$role_enabled => $role_enabled);
      $role_policies = \Drupal::entityTypeManager()->getStorage('password_policy')->loadByProperties($role_map);
      /** @var $policy \Drupal\password_policy\Entity\PasswordPolicy */
      foreach ($role_policies as $policy) {
        if (!in_array($policy->id(), $ids)) {
          $applicable_policies[] = $policy;
          $ids[] = $policy->id();
        }
      }
    }
  }

  //Force failure
  $force_failure = FALSE;

  if ($roles != $orignal_roles && $form_state->getValue('pass') == '' && !empty($applicable_policies)) {
    //new role has been added and applicable policies are available.
    $force_failure = TRUE;
  }
  //run validation
  $failed = FALSE;

  //process user context
  //TODO - Turn this into configuration
  $user_context_fields = array('mail', 'name', 'uid');
  $user_context_values = array();
  foreach ($user_context_fields as $user_context_field) {
    $user_context_values[$user_context_field] = $form_state->getValue($user_context_field);

    if ($user_context_field == 'uid') {
      $user_context_values[$user_context_field] = \Drupal::routeMatch()->getRawParameter('user');
    }
    //check default value
    if (empty($user_context_values[$user_context_field]) and !empty($form['account'][$user_context_field]['#default_value'])) {
      $user_context_values[$user_context_field] = $form['account'][$user_context_field]['#default_value'];
    }
  }
  /** @var $policy \Drupal\password_policy\Entity\PasswordPolicy */
  foreach ($applicable_policies as $policy_id => $policy) {
    $policy_constraints = $policy->getConstraints();

    foreach ($policy_constraints as $constraint_id => $constraint) {

      $plugin_inst = \Drupal::service('plugin.manager.password_policy.password_constraint');
      $plugin_object = $plugin_inst->createInstance($constraint['id'], $constraint);

      //execute validation
      $validation = $plugin_object->validate($form_state->getValue('pass'), $user_context_values);
      $status = "";
      if ($generate_policy_table) {
        if ($validation->isValid() && !$force_failure) {
          $status = 'Pass';
        }
        else {
          $message = $validation->getErrorMessage();
          if (empty($message)) {
            $message = t('New role was added or existing password policy changed. Please update your password.');
          }
          $status = 'Fail - ' . $message;
          //throw error to ensure form will not submit
          if (!$failed and $form_state->getValue('pass') != '') {
            //set storage value since you cant throw errors here
            $storage = $form_state->getStorage();
            $storage['password_fails_policies'] = TRUE;
            $form_state->setStorage($storage);
            $failed = TRUE;
          }
        }
        $table_row = array(
          'policy' => $policy->label(),
          'status' => $status,
          'constraint' => $plugin_object->getSummary(),
        );
        $policies_table_rows[] = $table_row;
      }
      else {
        if (!$validation->isValid() and !$failed and $form_state->getValue('pass') != '') {
          //throw error to ensure form will not submit
          $failed = TRUE;
        }
        else if ($force_failure){
          $failed = TRUE; 
        }
      }
    }
  }
  if ($failed && !$generate_policy_table) {
    $form_state->setErrorByName('pass', 'The password does not satisfy the password policies');
  }
  if ($generate_policy_table) {
    return $policies_table_rows;
  }
  return $failed;

}

/**
 * Custom callback on user form to check for password reset action
 * @param $form
 * @param $form_state
 */
function _password_policy_user_profile_form_submit(&$form, $form_state){
  $current_pass = $form_state->getValue('current_pass');
  $new_pass = $form_state->getValue('pass');
  $uid = $form_state->getValue('uid');

  if($uid and $current_pass and $new_pass and $current_pass!=$new_pass){
    $date = date('Y-m-d\TH:i:s');
    /** @var $user \Drupal\user\Entity\User */
    $user = \Drupal\user\Entity\User::load($uid);
    $user->set('field_last_password_reset', $date);
    $user->set('field_password_expiration', '0');
    $user->save();
  }
}

/**
 * {@inheritdoc}
 */
function password_policy_user_presave(Drupal\Core\Entity\EntityInterface $entity) {
  
  if (!$entity->id()) {
    $date = date('Y-m-d\TH:i:s');
    $entity->set('field_last_password_reset', $date);
    $entity->set('field_password_expiration', '0');
  }
}

/**
 * AJAX callback for user form
 */
function _password_policy_check_constraints($form, $form_state) {
  return $form['account']['password_policy_status'];
}

/**
 * AJAX callback for user form
 */
function _password_policy_constraints_table($form, $form_state) {

  $policies_table_rows = array();
  _password_policy_constraints_validate($form, $form_state, TRUE, $policies_table_rows);
  
  return $policies_table_rows;

}

/**
 * Implements hook_cron().
 *
 * Looks for expired passwords and updates the expiration based on the policy assigned
 */
function password_policy_cron() {
  //load each policy
  $policies = \Drupal::entityTypeManager()->getStorage('password_policy')->loadMultiple();
  $users = \Drupal::entityTypeManager()->getStorage('user')->loadMultiple();
  //check each policy
  /** @var $policy \Drupal\password_policy\Entity\PasswordPolicy */
  foreach($policies as $policy){
    //check each user's roles
    /** @var $user \Drupal\user\Entity\User */
    foreach($users as $user){
      //calculate expiration date
      $user_last_reset = $user->get('field_last_password_reset')->getValue();
      $time = strtotime($user_last_reset[0]['value']);
      $expire_date = strtotime("+".$policy->getPasswordReset()." days", $time);

      //get existing user expiration
      $user_expired = $user->get('field_password_expiration')->getValue();
      $user_expired = $user_expired[0]['value'];

      //set expiration conditionally
      //note: there is an explicit check here so the anonymous user never expires
      if($expire_date<=time() and $user_expired=='0' and $user->id() > 0){
        $user->set('field_password_expiration', '1');
        $user->save();
      }
    }
  }
}

/**
 * Menu argument loader. Returns a password policy entity
 *
 * @param $id
 * @return \Drupal\Core\Entity\EntityInterface|static
 */
function password_policy_load($id) {
  return \Drupal\password_policy\Entity\PasswordPolicy::load($id);
}
