<?php

namespace AuthStack\UserDirectory\Model;

use AuthStack\UserDirectory\Exceptions\AccountException;
use AuthStack\UserDirectory\Exceptions\PolicyException;
use AuthStack\UserDirectory\Http\Requests\Account\Auth;
use Illuminate\Database\Eloquent\Model;

class Account extends Model
{
    protected $table = 'accounts';

    protected $fillable = [
        'organization_id', 'status_id', 'policy_id', 'is_manager', 'password',
        'username_readable', 'first_name', 'last_name', 'email', 'additional', 'history', 'attempts', 'messages',
        'created_at', 'updated_at', 'last_login_at', 'password_changed_at', 'locked_at', 'unlocked_at'
    ];

    public $timestamps = true;

    protected $policy;

    protected $casts = [
        'additional'    => 'array',
        'history'       => 'array',
        'attempts'      => 'array',
        'messages'      => 'array'
    ];

    public function authenticate(Auth $request): array
    {
        // Steps:

        // 1. Find the record by username. If failed return 404 not found, because no such record
        // 2. Check if account status is ACTIVE. Unless its active, there's no way we can authenticate this account
        // 3. Check if there's a password policy assigned to the account. If yes, apply the policy. This takes care of failed auth attempts
        // 4. If policy passes, obtain the password, password_verify it.
        // 5. If failed, update `attempts` field and return 401 back, invalid password

        $account = $this->account($request->username());

        // Check if account should be unlocked (automatically), if it was locked. This changes account state
        $account->unlockConditionally();

        // If account isn't active an AccountException is thrown with appropriate message
        $account->checkIfActive();

        // Check number of attempts. If exceeded, lock the account and throw the appropriate message
        $account->checkAttempts();

        // Check if password is out of date. If yes, notify the user that password should be changed and don't authenticate them.
        // This triggers account status change if outdated
        $account->isPasswordOutdated();

        // Verifies the password. Checks and updates the number of unsuccessful attempts.
        $account->verifyPassword($request->password());

        // Authentication was successful, clear unsuccessful attempts
        $this->attempts = [];

        // Store the last authentication time
        $this->last_login_at = now();

        // Save
        $this->save();

        return array_except($this->toArray(), ['password', 'username']);

    }

    /**
     * @param $username
     * @return Account
     * @throws AccountException
     */
    public function account($username): Account
    {
        try
        {
            $username = hash('sha256', $username);

            /** @var $account Account */
            return $this->where('username', '=', \DB::raw("UNHEX($username)"));
        }
        catch(\Exception $e)
        {
            throw new AccountException("Can't find account with username: ". $username, 404, $e);
        }
    }

    /**
     * Checks if account should be unlocked and unlocks it if true.
     */
    public function unlockConditionally(): void
    {
        if((int)$this->getStatusID() === AccountStatus::S_LOCKED_OUT)
        {
            // Check if we have a value for locked out. If yes, check the interval
            if(!is_null($this->locked_at))
            {
                $policy = $this->policy($this->policy_id);

                // If lockout expired, set the account status back to active
                if($policy->lockoutExpired($this->locked_at))
                {
                    $this->status_id = AccountStatus::S_ACTIVE;
                    $this->locked_at = null;
                    $this->unlocked_at = now();
                }
            }
        }
    }

    /**
     * @return bool
     * @throws AccountException
     */
    public function checkIfActive(): bool
    {
        if(!AccountStatus::isActive($this->getStatusID()))
        {
            throw new AccountException(AccountStatus::getMessage($this->getStatusID()), 403);
        }

        return true;
    }

    /**
     * @throws AccountException
     */
    public function checkAttempts()
    {
        if(!$this->hasPasswordPolicy()) return;

        $policy = $this->policy($this->policy_id);

        if($policy->exceedsNumberOfAttempts($this->getNumberOfAttempts()))
        {
            $this->lockAccount(AccountStatus::S_LOCKED_OUT, 'Too many bad attempts');

            throw new AccountException("Amount of allowed authentication attempts exceeded. Account is locked out.");
        }
    }

    /**
     * @return int
     */
    public function getStatusID(): int
    {
        return (int)$this->status_id;
    }

    /**
     * @return bool
     */
    public function hasPasswordPolicy(): bool
    {
        return !is_null($this->policy_id);
    }

    /**
     * @return array
     */
    public function getAttempts(): array
    {
        return is_array($this->attempts) ? $this->attempts : [];
    }

    /**
     * @return int
     */
    public function getNumberOfAttempts(): int
    {
        return sizeof($this->getAttempts());
    }

    /**
     * @return bool
     * @throws AccountException
     */
    public function isPasswordOutdated(): bool
    {
        if(!$this->hasPasswordPolicy()) return false;

        $policy = $this->policy($this->policy_id);

        if($policy->isPasswordOutdated($this))
        {
            // Lock the account
            $this->lockAccount(AccountStatus::S_PASSWORD_EXPIRED, 'Password is outdated. Please change your password.');

            throw new AccountException("Account password has expired and account has been locked", 403);
        }

        return false;
    }

    /**
     * @param int $status_id
     * @param string $because
     * @return bool
     */
    public function lockAccount(int $status_id, string $because): bool
    {
        $this->status_id = $status_id;

        $this->msg($because);

        $this->locked_at = now();

        return $this->save();
    }

    public function verifyPassword(string $password): bool
    {
        $result = password_verify($password, $this->password);

        if(false === $result)
        {
            $this->addFailedAttempt();

            throw new AccountException("Incorrect password", 422);
        }

        return true;
    }

    /**
     * @return int
     */
    public function getDaysSinceLastPasswordChange(): int
    {
        if(is_null($this->password_changed_at)) return 0;

        $now = new \DateTime("now", new \DateTimeZone("UTC"));

        $diff = $now->diff($this->password_changed_at, true);

        return (int)$diff->format("%a");
    }

    public function msg(string $message): void
    {
        $this->messages = array_merge($this->messages, ['message' => $message, 'when' => now()]);
    }

    public function addFailedAttempt(): void
    {
        $this->attempts = array_merge($this->attempts, ['message' => 'Password entered is invalid', 'when' => now()]);

        $this->save();
    }

    /**
     * @param $id
     * @return Policy
     * @throws PolicyException
     */
    public function policy($id): Policy
    {
        if($this->policy instanceof Policy)
        {
            return $this->policy;
        }

        try
        {
            $this->policy = (new Policy())->findOrFail($id);

            return $this->policy;
        }
        catch(\Exception $e)
        {
            throw new PolicyException("Unable to find password policy with ID: ". $id, 404, $e);
        }
    }
}