<?php

namespace Barn2\Plugin\Password_Protected_Categories;

/**
 * This class represents a term (WP_Term instance) with various functions to test for its visibility.
 *
 * It's constructed from the WP_Term object rather than extending it.
 *
 * @package   Barn2\password-protected-categories
 * @author    Barn2 Plugins <support@barn2.co.uk>
 * @license   GPL-3.0
 * @copyright Barn2 Media Ltd
 */
class Term_Visibility {

	public $term;
	public $visibility = 'public';
	private $passwords = [];
	private $roles     = [];
	private $users     = [];
	private $ancestors = null;

	public function __construct( \WP_Term $term ) {
		$this->term       = $term;
		$this->visibility = PPC_Util::get_visibility( $term->term_id );

		if ( ! $this->visibility ) {
			$this->visibility = 'public';
		}

		if ( 'password' === $this->visibility ) {
			$this->visibility = 'protected';
		}

		if ( 'protected' === $this->visibility ) {
			$passwords = $this->_get_passwords();

			// Back-compat - for passwords stored as separate meta items
			if ( $passwords && ! empty( $passwords[0] ) && is_array( $passwords[0] ) ) {
				$passwords = $passwords[0];
			}

			$this->passwords = $passwords ? $passwords : [];

			$roles       = \get_term_meta( $this->term->term_id, 'user_roles', true );
			$this->roles = $roles ? (array) $roles : [];

			$users       = \get_term_meta( $this->term->term_id, 'users', true );
			$this->users = $users ? (array) $users : [];
		}
	}

	/**
	 * Retrieve the ancestor terms for this term. Lazy loaded so it only hits the
	 * database once.
	 *
	 * @return array An array of Term_Visibility objects (one for each ancestor),
	 *      or an empty array if there are no ancestors
	 */
	public function ancestors() {
		if ( null === $this->ancestors ) {
			$ancestor_ids    = \get_ancestors( $this->term->term_id, $this->term->taxonomy, 'taxonomy' );
			$this->ancestors = \array_filter( PPC_Util::to_term_visibilities( \array_map( [ $this, 'to_term' ], $ancestor_ids ) ) );
		}

		return $this->ancestors;
	}

	/**
	 * Is the password valid for this term?
	 *
	 * @param string $password The password to check
	 * @param boolea $include_ancestors Whether to check the password against ancestor terms
	 * @return boolean|WP_Term The term the password is valid for, or false if not a valid password
	 */
	public function check_password( $password, $include_ancestors = true ) {
		if ( ! $password ) {
			return false;
		}

		$valid_for_term = \in_array( $password, $this->get_passwords() ) ? $this->term : false;

		if ( ! $valid_for_term && $include_ancestors ) {
			foreach ( $this->ancestors() as $ancestor ) {
				if ( $ancestor->check_password( $password, false ) ) {
					$valid_for_term = $ancestor->term;
					break;
				}
			}
		}

		return $valid_for_term;
	}

	/**
	 * Is this a public? If $include_ancestors is set, ancestor terms
	 * will also be checked and it will return true if all ancestors are also public.
	 *
	 * @param boolean $include_ancestors Whether to check the ancestor terms as well
	 * @return boolean true if the term is public, false if not
	 */
	public function is_public( $include_ancestors = true ) {
		$public = 'public' === $this->visibility;

		// Only check ancestors if flag set and this category is public
		if ( $public && $include_ancestors ) {
			foreach ( $this->ancestors() as $ancestor ) {
				if ( ! $ancestor->is_public( false ) ) {
					$public = false;
					break;
				}
			}
		}

		return $public;
	}

	/**
	 * Does this category have password, role or user protection?
	 *
	 * @return boolean true if protected
	 */
	public function has_protection() {
		return $this->has_password_protection() || $this->has_role_protection() || $this->has_user_protection();
	}

	/**
	 * Does this category have password protection?
	 *
	 * @return boolean true if password protected
	 */
	public function has_password_protection() {
		return 'protected' === $this->visibility && ! empty( $this->passwords );
	}

	/**
	 * Does this category have role protection?
	 *
	 * @return boolean true if role protected
	 */
	public function has_role_protection() {
		return 'protected' === $this->visibility && ! empty( $this->roles );
	}

	/**
	 * Does this category have user protection?
	 *
	 * @return boolean true if user protected
	 */
	public function has_user_protection() {
		return 'protected' === $this->visibility && ! empty( $this->users );
	}

	/**
	 * Is this a password protected term? If $include_ancestors is set, ancestor terms
	 * will also be checked and it will return true if any ancestor is password protected.
	 *
	 * @param boolean $include_ancestors Whether to check the ancestor terms as well
	 * @return boolean true if password protected, false if not
	 */
	public function is_password_protected( $include_ancestors = true ) {
		$protected = ( 'password' === $this->visibility || 'protected' === $this->visibility ) && $this->get_passwords();

		// Not password protected if this category has been unlocked
		if ( $protected && $this->is_unlocked() ) {
			return false;
		}

		// Only check ancestors if flag set and this category itself is not protected
		if ( ! $protected && $include_ancestors ) {
			foreach ( $this->ancestors() as $ancestor ) {
				if ( $ancestor->is_password_protected( false ) ) {
					return true;
				}
			}
		}
		return $protected;
	}

	/**
	 * Does this category have private protection?
	 *
	 * @return boolean true if private protection set
	 */
	public function has_private_protection() {
		return 'private' === $this->visibility;
	}

	/**
	 * Is this category unlocked by password, role, user, or for private access?
	 *
	 * @return boolean true if unlocked.
	 */
	public function is_unlocked() {
		return $this->is_unlocked_by_password() || $this->is_unlocked_by_role() || $this->is_unlocked_by_user() || $this->is_unlocked_for_private_access();
	}

	/**
	 * Is this category unlocked by role?
	 *
	 * @return boolean true if role protected and the current user has one of the required roles.
	 */
	public function is_unlocked_by_role() {
		return $this->has_role_protection() && $this->current_user_allowed_by_role();
	}

	private function current_user_allowed_by_role() {
		// If there are no roles, then it can't be unlocked by role.
		if ( ! $this->roles ) {
			return false;
		}

		$allowed = false;

		if ( is_user_logged_in() ) {
			$user = wp_get_current_user();
			// If there's a role overlap, then the current user has at least one of the required roles, so category is unlocked.
			$allowed = count( array_intersect( $user->roles, $this->roles ) ) > 0;
		}

		return apply_filters( 'ppc_current_user_allowed_by_role', $allowed, $this->term->term_id );
	}

	/**
	 * Is this category unlocked by the current user?
	 *
	 * @return boolean true if user protected and the current user is allowed access.
	 */
	public function is_unlocked_by_user() {
		return $this->has_user_protection() && $this->current_user_allowed_by_id();
	}

	private function current_user_allowed_by_id() {
		// If there are no users, then it can't be unlocked by user.
		if ( ! $this->users ) {
			return false;
		}

		$allowed = false;

		if ( is_user_logged_in() ) {
			$user    = wp_get_current_user();
			$allowed = in_array( $user->ID, $this->users );
		}

		return apply_filters( 'ppc_current_user_allowed_by_id', $allowed, $this->term->term_id );
	}

	/**
	 * Is this category private and unlocked by the current user?
	 *
	 * @return boolean true if unlocked for private access.
	 */
	public function is_unlocked_for_private_access() {
		return $this->has_private_protection() && $this->current_user_allowed_private_access();
	}

	/**
	 * Is this a private term? If $include_ancestors is set, ancestor terms will
	 * also be checked and it will return true if any ancestor is private.
	 *
	 * @param boolean $include_ancestors Whether to check the ancestor terms as well
	 * @return boolean true if private, false if not
	 */
	public function is_private( $include_ancestors = true ) {
		$private = $this->has_private_protection();

		if ( $private && $this->current_user_allowed_private_access() ) {
			return false;
		}

		// Only check ancestors if flag set and this category itself is not private
		if ( ! $private && $include_ancestors ) {
			foreach ( $this->ancestors() as $ancestor ) {
				if ( $ancestor->is_private( false ) ) {
					return true;
				}
			}
		}

		return $private;
	}

	public function is_hidden( $include_ancestors = true ) {
		$show_protected = (bool) PPC_Util::get_option( 'show_protected' );
		return $this->is_private( $include_ancestors ) ||
			( ! $show_protected && $this->is_password_protected( $include_ancestors ) ) ||
			( ! $show_protected && $this->is_role_protected( $include_ancestors ) ) ||
			( ! $show_protected && $this->is_user_protected( $include_ancestors ) );
	}

	/**
	 * Is this category protected? 'Protected' can mean a password is required, or the category
	 * is locked to specific roles or users.
	 *
	 * This function will return false for private categories, and there is a separate function
	 * (is_private) to check for private level access.
	 *
	 * If $check_ancestors is true, and this category is not protected, its ancestor categories will
	 * also be checked. The function halts once the first protected ancestor is found.
	 * *
	 *
	 * @param boolean $check_ancestors Whether to check the ancestor categories as well (if any).
	 * @return boolean true if this category is protected.
	 */
	public function is_protected( $check_ancestors = false ) {
		$protected = $this->has_protection();

		// If this category itself is unlocked, then it's not protected (regardless of parent protection level).
		if ( $protected && $this->is_unlocked() ) {
			return false;
		}

		// Only check ancestors if flag set and this category itself is not protected.
		if ( ! $protected && $check_ancestors ) {
			foreach ( $this->ancestors() as $ancestor ) {
				if ( $ancestor->is_protected( false ) ) {
					return true;
				}
			}
		}

		return $protected;
	}

	/**
	 * Retrieve this term's passwords.
	 *
	 * @return array An array of passwords, or an empty array if none set
	 */
	private function _get_passwords() {
		return PPC_Util::get_term_passwords( $this->term->term_id, true );
	}

	/**
	 * Is this term unlocked?
	 *
	 * @return boolean true if unlocked, false otherwise
	 */
	private function is_unlocked_by_password() {
		return $this->has_password_protection() && $this->correct_password_entered();
	}

	/**
	 * Check the correct password has been entered.
	 *
	 * @return bool
	 */
	public function correct_password_entered() {
		// Can't be unlocked by password if it's not password protected.
		if ( ! $this->has_password_protection() ) {
			return false;
		}

		$unlocked = PPC_Util::get_unlocked_term();

		if ( ! $unlocked || $unlocked['term_id'] !== $this->term->term_id || $unlocked['taxonomy'] !== $this->term->taxonomy ) {
			return false;
		}

		require_once \ABSPATH . \WPINC . '/class-phpass.php';
		$hasher = new \PasswordHash( 8, true );

		$hash = \wp_unslash( $unlocked['password'] );
		if ( 0 !== \strpos( $hash, '$P$B' ) ) {
			return false;
		}

		if ( $passwords = $this->get_passwords() ) {
			foreach ( $passwords as $password ) {
				if ( $hasher->CheckPassword( $password, $hash ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Is this category protected by user role?
	 *
	 * @param boolean $check_ancestors Whether to check the ancestor categories as well (if any).
	 * @return boolean true if this category is role protected.
	 */
	public function is_role_protected( $check_ancestors = false ) {
		$role_protected = $this->has_role_protection();

		if ( $role_protected && $this->current_user_allowed_by_role() ) {
			return false;
		}

		// Only check ancestors if flag set and this category itself is not role protected
		if ( ! $role_protected && $check_ancestors ) {
			foreach ( $this->ancestors() as $ancestor ) {
				if ( $ancestor->is_role_protected( false ) ) {
					return true;
				}
			}
		}

		return $role_protected;
	}

	/**
	 * Is this category protected by user (i.e. only specific users have access)?
	 *
	 * @param boolean $check_ancestors Whether to check the ancestor categories as well (if any).
	 * @return boolean true if this category is user protected.
	 */
	public function is_user_protected( $check_ancestors = false ) {
		$user_protected = $this->has_user_protection();

		if ( $user_protected && $this->current_user_allowed_by_id() ) {
			return false;
		}

		// Only check ancestors if flag set and this category itself is not user protected
		if ( ! $user_protected && $check_ancestors ) {
			foreach ( $this->ancestors() as $ancestor ) {
				if ( $ancestor->is_user_protected( false ) ) {
					return true;
				}
			}
		}

		return $user_protected;
	}

	public function get_visibility() {
		return $this->visibility;
	}

	public function get_passwords() {
		return $this->passwords;
	}

	public function get_users() {
		return $this->users;
	}

	public function get_roles() {
		return $this->roles;
	}

	private function to_term( $term_id ) {
		$term = \get_term_by( 'id', $term_id, $this->term->taxonomy );
		return $term ? $term : false;
	}

	private function current_user_allowed_private_access() {
		return apply_filters( 'ppc_current_user_allowed_private_access', current_user_can( 'read_private_posts' ), $this->term->term_id );
	}

}

// class Term_Visibility
