<?php
/**
 * AuthenticationException class for authentication failures.
 *
 * @package    UltimateSecurity
 * @subpackage Support\Exceptions
 * @since      1.0.0
 */

namespace WPUltimateSecurity\UltimateSecurity\Support\Exceptions;

use Exception;
use WPUltimateSecurity\UltimateSecurity\Support\Result;

/**
 * Exception thrown when authentication fails.
 *
 * Provides specific error codes and context for different authentication failures.
 *
 * Usage:
 *   throw AuthenticationException::invalidCredentials();
 *   throw AuthenticationException::sessionExpired();
 *   throw AuthenticationException::twoFactorRequired();
 *
 * @since 1.0.0
 */
class AuthenticationException extends Exception {

    /**
     * Error code for programmatic handling.
     *
     * @since 1.0.0
     * @var string
     */
    private string $error_code;

    /**
     * HTTP status code for this exception.
     *
     * @since 1.0.0
     * @var int
     */
    private int $status_code;

    /**
     * Additional context data.
     *
     * @since 1.0.0
     * @var array<string, mixed>
     */
    private array $context;

    /**
     * User ID if applicable.
     *
     * @since 1.0.0
     * @var int|null
     */
    private ?int $user_id;

    /**
     * Common error codes.
     */
    public const CODE_INVALID_CREDENTIALS    = 'invalid_credentials';
    public const CODE_ACCOUNT_LOCKED         = 'account_locked';
    public const CODE_ACCOUNT_DISABLED       = 'account_disabled';
    public const CODE_SESSION_EXPIRED        = 'session_expired';
    public const CODE_SESSION_INVALID        = 'session_invalid';
    public const CODE_TWO_FACTOR_REQUIRED    = 'two_factor_required';
    public const CODE_TWO_FACTOR_INVALID     = 'two_factor_invalid';
    public const CODE_TWO_FACTOR_EXPIRED     = 'two_factor_expired';
    public const CODE_PERMISSION_DENIED      = 'permission_denied';
    public const CODE_RATE_LIMITED           = 'rate_limited';
    public const CODE_IP_BLOCKED             = 'ip_blocked';
    public const CODE_TOKEN_INVALID          = 'token_invalid';
    public const CODE_TOKEN_EXPIRED          = 'token_expired';
    public const CODE_NONCE_INVALID          = 'nonce_invalid';

    /**
     * Create a new AuthenticationException.
     *
     * @since 1.0.0
     *
     * @param string              $message     Error message.
     * @param string              $error_code  Error code for programmatic handling.
     * @param int                 $status_code HTTP status code (default 401).
     * @param array<string,mixed> $context     Additional context data.
     * @param int|null            $user_id     User ID if applicable.
     * @param Exception|null      $previous    Previous exception.
     */
    public function __construct(
        string $message,
        string $error_code = 'authentication_failed',
        int $status_code = 401,
        array $context = [],
        ?int $user_id = null,
        ?Exception $previous = null
    ) {
        $this->error_code  = $error_code;
        $this->status_code = $status_code;
        $this->context     = $context;
        $this->user_id     = $user_id;

        parent::__construct( $message, 0, $previous );
    }

    /**
     * Create exception for invalid credentials.
     *
     * @since 1.0.0
     *
     * @param string|null $username The username attempted (for logging).
     * @return self
     */
    public static function invalidCredentials( ?string $username = null ): self {
        $context = [];
        if ( $username ) {
            $context['username'] = $username;
        }

        return new self(
            __( 'Invalid username or password.', 'ultimate-security' ),
            self::CODE_INVALID_CREDENTIALS,
            401,
            $context
        );
    }

    /**
     * Create exception for locked account.
     *
     * @since 1.0.0
     *
     * @param int         $user_id         The user ID.
     * @param int|null    $unlock_time     Unix timestamp when account will be unlocked.
     * @param string|null $lockout_reason  Reason for the lockout.
     * @return self
     */
    public static function accountLocked(
        int $user_id,
        ?int $unlock_time = null,
        ?string $lockout_reason = null
    ): self {
        $context = [];
        if ( $unlock_time ) {
            $context['unlock_time']     = $unlock_time;
            $context['unlock_in']       = $unlock_time - time();
            $context['unlock_time_human'] = human_time_diff( time(), $unlock_time );
        }
        if ( $lockout_reason ) {
            $context['reason'] = $lockout_reason;
        }

        $message = $unlock_time
            ? sprintf(
                /* translators: %s: human-readable time until unlock */
                __( 'Your account has been locked. Please try again in %s.', 'ultimate-security' ),
                human_time_diff( time(), $unlock_time )
            )
            : __( 'Your account has been locked. Please contact an administrator.', 'ultimate-security' );

        return new self(
            $message,
            self::CODE_ACCOUNT_LOCKED,
            423, // Locked status code.
            $context,
            $user_id
        );
    }

    /**
     * Create exception for disabled account.
     *
     * @since 1.0.0
     *
     * @param int         $user_id The user ID.
     * @param string|null $reason  Reason for the disabling.
     * @return self
     */
    public static function accountDisabled( int $user_id, ?string $reason = null ): self {
        $context = [];
        if ( $reason ) {
            $context['reason'] = $reason;
        }

        return new self(
            __( 'Your account has been disabled. Please contact an administrator.', 'ultimate-security' ),
            self::CODE_ACCOUNT_DISABLED,
            403,
            $context,
            $user_id
        );
    }

    /**
     * Create exception for expired session.
     *
     * @since 1.0.0
     *
     * @param int|null $user_id The user ID.
     * @return self
     */
    public static function sessionExpired( ?int $user_id = null ): self {
        return new self(
            __( 'Your session has expired. Please log in again.', 'ultimate-security' ),
            self::CODE_SESSION_EXPIRED,
            401,
            [],
            $user_id
        );
    }

    /**
     * Create exception for invalid session.
     *
     * @since 1.0.0
     *
     * @param int|null $user_id The user ID.
     * @return self
     */
    public static function sessionInvalid( ?int $user_id = null ): self {
        return new self(
            __( 'Your session is invalid. Please log in again.', 'ultimate-security' ),
            self::CODE_SESSION_INVALID,
            401,
            [],
            $user_id
        );
    }

    /**
     * Create exception when two-factor authentication is required.
     *
     * @since 1.0.0
     *
     * @param int           $user_id  The user ID.
     * @param array<string> $methods  Available 2FA methods.
     * @param string|null   $token    Temporary auth token for 2FA flow.
     * @return self
     */
    public static function twoFactorRequired(
        int $user_id,
        array $methods = [],
        ?string $token = null
    ): self {
        $context = [
            'methods' => $methods,
        ];
        if ( $token ) {
            $context['token'] = $token;
        }

        return new self(
            __( 'Two-factor authentication is required.', 'ultimate-security' ),
            self::CODE_TWO_FACTOR_REQUIRED,
            401,
            $context,
            $user_id
        );
    }

    /**
     * Create exception for invalid two-factor code.
     *
     * @since 1.0.0
     *
     * @param int      $user_id          The user ID.
     * @param int|null $attempts_remaining Remaining attempts before lockout.
     * @return self
     */
    public static function twoFactorInvalid( int $user_id, ?int $attempts_remaining = null ): self {
        $context = [];
        if ( null !== $attempts_remaining ) {
            $context['attempts_remaining'] = $attempts_remaining;
        }

        $message = null !== $attempts_remaining && $attempts_remaining > 0
            ? sprintf(
                /* translators: %d: number of attempts remaining */
                _n(
                    'Invalid verification code. %d attempt remaining.',
                    'Invalid verification code. %d attempts remaining.',
                    $attempts_remaining,
                    'ultimate-security'
                ),
                $attempts_remaining
            )
            : __( 'Invalid verification code.', 'ultimate-security' );

        return new self(
            $message,
            self::CODE_TWO_FACTOR_INVALID,
            401,
            $context,
            $user_id
        );
    }

    /**
     * Create exception for expired two-factor code.
     *
     * @since 1.0.0
     *
     * @param int $user_id The user ID.
     * @return self
     */
    public static function twoFactorExpired( int $user_id ): self {
        return new self(
            __( 'The verification code has expired. Please request a new one.', 'ultimate-security' ),
            self::CODE_TWO_FACTOR_EXPIRED,
            401,
            [],
            $user_id
        );
    }

    /**
     * Create exception for permission denied.
     *
     * @since 1.0.0
     *
     * @param string   $capability Required capability.
     * @param int|null $user_id    The user ID.
     * @return self
     */
    public static function permissionDenied( string $capability = '', ?int $user_id = null ): self {
        $context = [];
        if ( $capability ) {
            $context['required_capability'] = $capability;
        }

        return new self(
            __( 'You do not have permission to perform this action.', 'ultimate-security' ),
            self::CODE_PERMISSION_DENIED,
            403,
            $context,
            $user_id
        );
    }

    /**
     * Create exception for rate limiting.
     *
     * @since 1.0.0
     *
     * @param int         $retry_after Seconds until rate limit resets.
     * @param string|null $ip_address  The rate-limited IP address.
     * @return self
     */
    public static function rateLimited( int $retry_after, ?string $ip_address = null ): self {
        $context = [
            'retry_after' => $retry_after,
        ];
        if ( $ip_address ) {
            $context['ip_address'] = $ip_address;
        }

        return new self(
            sprintf(
                /* translators: %d: number of seconds to wait */
                __( 'Too many attempts. Please try again in %d seconds.', 'ultimate-security' ),
                $retry_after
            ),
            self::CODE_RATE_LIMITED,
            429,
            $context
        );
    }

    /**
     * Create exception for blocked IP.
     *
     * @since 1.0.0
     *
     * @param string      $ip_address   The blocked IP address.
     * @param string|null $block_reason Reason for the block.
     * @return self
     */
    public static function ipBlocked( string $ip_address, ?string $block_reason = null ): self {
        $context = [
            'ip_address' => $ip_address,
        ];
        if ( $block_reason ) {
            $context['reason'] = $block_reason;
        }

        return new self(
            __( 'Access denied. Your IP address has been blocked.', 'ultimate-security' ),
            self::CODE_IP_BLOCKED,
            403,
            $context
        );
    }

    /**
     * Create exception for invalid token.
     *
     * @since 1.0.0
     *
     * @param string $token_type Type of token (e.g., 'reset', 'verify').
     * @return self
     */
    public static function tokenInvalid( string $token_type = 'authentication' ): self {
        return new self(
            sprintf(
                /* translators: %s: token type */
                __( 'Invalid %s token.', 'ultimate-security' ),
                $token_type
            ),
            self::CODE_TOKEN_INVALID,
            401,
            [ 'token_type' => $token_type ]
        );
    }

    /**
     * Create exception for expired token.
     *
     * @since 1.0.0
     *
     * @param string $token_type Type of token (e.g., 'reset', 'verify').
     * @return self
     */
    public static function tokenExpired( string $token_type = 'authentication' ): self {
        return new self(
            sprintf(
                /* translators: %s: token type */
                __( 'The %s token has expired.', 'ultimate-security' ),
                $token_type
            ),
            self::CODE_TOKEN_EXPIRED,
            401,
            [ 'token_type' => $token_type ]
        );
    }

    /**
     * Create exception for invalid nonce.
     *
     * @since 1.0.0
     *
     * @return self
     */
    public static function nonceInvalid(): self {
        return new self(
            __( 'Security verification failed. Please refresh the page and try again.', 'ultimate-security' ),
            self::CODE_NONCE_INVALID,
            403
        );
    }

    /**
     * Get the error code.
     *
     * @since 1.0.0
     *
     * @return string
     */
    public function getErrorCode(): string {
        return $this->error_code;
    }

    /**
     * Get the HTTP status code.
     *
     * @since 1.0.0
     *
     * @return int
     */
    public function getStatusCode(): int {
        return $this->status_code;
    }

    /**
     * Get the context data.
     *
     * @since 1.0.0
     *
     * @return array<string, mixed>
     */
    public function getContext(): array {
        return $this->context;
    }

    /**
     * Get a specific context value.
     *
     * @since 1.0.0
     *
     * @param string $key     The context key.
     * @param mixed  $default Default value if not found.
     * @return mixed
     */
    public function getContextValue( string $key, $default = null ) {
        return $this->context[ $key ] ?? $default;
    }

    /**
     * Get the user ID.
     *
     * @since 1.0.0
     *
     * @return int|null
     */
    public function getUserId(): ?int {
        return $this->user_id;
    }

    /**
     * Convert to a Result object.
     *
     * @since 1.0.0
     *
     * @return Result
     */
    public function toResult(): Result {
        $result = Result::failure( $this->getMessage(), $this->error_code );

        foreach ( $this->context as $key => $value ) {
            $result = $result->withMetadata( $key, $value );
        }

        if ( $this->user_id ) {
            $result = $result->withMetadata( 'user_id', $this->user_id );
        }

        return $result;
    }

    /**
     * Convert to array format suitable for REST API responses.
     *
     * @since 1.0.0
     *
     * @return array{success: bool, error: string, code: string, context?: array}
     */
    public function toArray(): array {
        $response = [
            'success' => false,
            'error'   => $this->getMessage(),
            'code'    => $this->error_code,
        ];

        // Only include safe context fields in response.
        $safe_context = array_intersect_key(
            $this->context,
            array_flip( [
                'retry_after',
                'unlock_time',
                'unlock_in',
                'unlock_time_human',
                'methods',
                'attempts_remaining',
                'token_type',
            ] )
        );

        if ( ! empty( $safe_context ) ) {
            $response['context'] = $safe_context;
        }

        return $response;
    }

    /**
     * Convert to a WP_Error object.
     *
     * @since 1.0.0
     *
     * @return \WP_Error
     */
    public function toWpError(): \WP_Error {
        return new \WP_Error(
            $this->error_code,
            $this->getMessage(),
            [
                'status'  => $this->status_code,
                'context' => $this->context,
                'user_id' => $this->user_id,
            ]
        );
    }

    /**
     * Convert to a REST response.
     *
     * @since 1.0.0
     *
     * @return \WP_REST_Response
     */
    public function toRestResponse(): \WP_REST_Response {
        $response = new \WP_REST_Response( $this->toArray(), $this->status_code );

        // Add Retry-After header for rate limiting.
        if ( self::CODE_RATE_LIMITED === $this->error_code && isset( $this->context['retry_after'] ) ) {
            $response->header( 'Retry-After', (string) $this->context['retry_after'] );
        }

        return $response;
    }

    /**
     * Check if this is a specific error type.
     *
     * @since 1.0.0
     *
     * @param string $code Error code to check.
     * @return bool
     */
    public function is( string $code ): bool {
        return $this->error_code === $code;
    }

    /**
     * Check if this error requires user notification.
     *
     * @since 1.0.0
     *
     * @return bool
     */
    public function requiresNotification(): bool {
        return in_array(
            $this->error_code,
            [
                self::CODE_ACCOUNT_LOCKED,
                self::CODE_ACCOUNT_DISABLED,
                self::CODE_IP_BLOCKED,
            ],
            true
        );
    }

    /**
     * Check if this error should be logged as a security event.
     *
     * @since 1.0.0
     *
     * @return bool
     */
    public function isSecurityEvent(): bool {
        return in_array(
            $this->error_code,
            [
                self::CODE_INVALID_CREDENTIALS,
                self::CODE_ACCOUNT_LOCKED,
                self::CODE_TWO_FACTOR_INVALID,
                self::CODE_RATE_LIMITED,
                self::CODE_IP_BLOCKED,
                self::CODE_TOKEN_INVALID,
                self::CODE_NONCE_INVALID,
            ],
            true
        );
    }
}
