<?php
class User_AlreadyFavoritedError extends Exception{}
class User_NoInvites extends Exception{}
class User_HasNegativeRecord extends Exception{}

class User extends Rails\ActiveRecord\Base
{
    static private $_current;
    
    public $current_email;
    
    public $country;
    
    public $passwordConfirmation;
    
    /**
     * Set in ApplicationController.
     */
    public $ip_addr;
    
    /**
     * iTODO: Temp property while tag subs aren't enabled.
     */
    public $tag_subscriptions;
    
    protected $post_count;
    
    static public function set_current_user(User $user)
    {
        self::$_current = $user;
    }
    
    static public function current()
    {
        return self::$_current;
    }
    
    # Defines various convenience methods for finding out the user's level
    public function __call($method, $params)
    {
        if (strpos($method, 'is_post_')) {
            return $this->_parse_is_post_level_or($method);
        } elseif (strpos($method, 'is_') === 0) {
            if (is_int(strpos($method, '_or_')))
                return $this->parse_is_level_or($method);
            else
                return $this->parse_is_level($method);
        }
        parent::__call($method, $params);
    }
    
    public function log($ip)
    {
        # iTODO: UserLog doesn't exist yet.
        return;
        
        # iTODO: hash key
        return Rails::cache()->fetch('type.user_logs;id.'.$this->id.';ip.'.$ip, ['expires_in' => '10 minutes'], function() use ($ip) {
            # iTODO: hash key
            Rails::cache()->fetch('type.user_logs;id.all', ['expires_in' => '1 day'], function() {
                return UserLog::where('created_at < ?', date('Y-m-d 0:0:0', strtotime('-3 days')))->deleteAll();
            });
            
            $log_entry = UserLog::where(['ip_addr' => $ip])->firstOrInitialize();
            $log_entry->created_at = date('Y-m-d H:i:s');
            return $log_entry->save();
        });
    }

    # UserBlacklistMethods {
    # TODO: I don't see the advantage of normalizing these. Since commas are illegal
    # characters in tags, they can be used to separate lines (with whitespace separating
    # tags). Denormalizing this into a field in users would save a SQL query.
    public $blacklisted_tags;
    // protected function setBlacklistedTags($blacklists)
    // {
        // $this->('blacklisted_tags', $blacklists);
    // }

    public function blacklisted_tags()
    {
        return implode("\n", $this->blacklisted_tags_array()) . "\n";
    }

    public function blacklisted_tags_array()
    {
        if ($this->user_blacklisted_tag)
            return preg_split("/(\r\n|\r|\n)/", trim($this->user_blacklisted_tag->tags));
        else
            return [];
    }

    protected function _commit_blacklists() 
    {
        if ($this->user_blacklisted_tag && isset($this->blacklisted_tags))
            $this->user_blacklisted_tag->updateAttribute('tags', $this->blacklisted_tags);
    }

    protected function _set_default_blacklisted_tags()
    {
        UserBlacklistedTag::create(array('user_id' => $this->id, 'tags' => implode("\r\n", CONFIG()->default_blacklists)));
    }
    
    # } UserAuthenticationMethods {
    
    static public function authenticate($name, $pass)
    {
        return self::authenticate_hash($name, self::sha1($pass));
    }

    static public function authenticate_hash($name, $pass)
    {
        $user = parent::where("lower(name) = lower(?) AND password_hash = ?", $name, $pass)->first();
        return $user;
    }

    static public function sha1($pass)
    {
        return sha1(CONFIG()->user_password_salt . '--' . $pass . '--');
    }
    
    # } UserPasswordMethods {
    
    public $password, $current_password;
    
    protected function validate_current_password()
    {
        # First test to see if it's creating new user (no password_hash)
        # or updating user. The second is to see if the action involves
        # updating password (which requires this validation).
        if ($this->password_hash and ($this->password or ($this->emailChanged() or $this->current_email))) {
            if (!$this->current_password)
                $this->errors()->add('current_password', 'blank');
            elseif (!User::authenticate($this->name, $this->current_password))
                $this->errors()->add('current_password', 'invalid');
        }
    }
    
    protected function _encrypt_password()
    {
        if ($this->password)
            $this->password_hash = self::sha1($this->password);
    }

    public function reset_password()
    {
        $consonants = "bcdfghjklmnpqrstvqxyz";
        $vowels = "aeiou";
        $pass = "";

        foreach (range(1, 4) as $i) {
            $pass .= substr($consonants, rand(0, 20), 1);
            $pass .= substr($vowels, rand(0, 4), 1);
        }

        $pass .= rand(0, 100);
        self::connection()->executeSql("UPDATE users SET password_hash = ? WHERE id = ?", self::sha1($pass), $this->id);
        return $pass;
    }
    
    public function setPasswordConfirmation($value)
    {
        $this->passwordConfirmation = $value;
    }
    
    # } UserCountMethods {
    
    # TODO: This isn't used anymore. Should be safe to delete.
    static public function fast_count()
    {
        return self::connection()->selectValue("SELECT row_count FROM table_data WHERE name = 'users'");
    }

    protected function _increment_count()
    {
        self::connection()->executeSql("UPDATE table_data set row_count = row_count + 1 where name = 'users'");
    }
    
    protected function _decrement_count()
    {
        self::connection()->executeSql("UPDATE table_data set row_count = row_count - 1 where name = 'users'");
    }
    
    # } UserNameMethods {
    
    static private function _find_name_helper($user_id)
    {
        if (!$user_id)
          return CONFIG()->default_guest_name;

        $user = self::where('id = ?', $user_id)->first();

        if ($user) {
          return $user->name;
        } else {
          return CONFIG()->default_guest_name;
        }
    }

    static public function find_name($user_id)
    {
        return Rails::cache()->fetch('user_name:' . $user_id, function() use ($user_id) {
            try {
                return self::find($user_id)->name;
            } catch (Rails\ActiveRecord\Exception\RecordNotFoundException $e) {
                return CONFIG()->default_guest_name;
            }
        });
    }

    static public function find_by_name($name)
    {
        return self::where("lower(name) = lower(?)", $name)->first();
    }
    
    public function pretty_name()
    {
        return str_replace('_', ' ', $this->name);
    }
    
    // public function setPrettyName($value)
    // {
        
    // }

    protected function update_cached_name()
    {
        Rails::cache()->write("user_name:".$this->id, $this->name);
    }
    # }

    # UserApiMethods {
    # iTODO:
    public function toXml(array $options = array())
    {
        // !isset($options['indent']) && $options['indent'] = 2;
        // if (isset($options['builder']))
            // $xml = $options['builder'];
        // else
            // $xml = Builder::XmlMarkup.new('indent' => options[:indent])
        
        // xml.post('name' => name, 'id' => id) do
            // blacklisted_tags_array.each do |t|
                // xml.blacklisted_tag('tag' => t)
            // end

            // yield options[:builder] if block_given?
        // end
    }

    public function asJson(array $args = array())
    {
        return ['name' => $this->name, 'blacklisted_tags' => $this->blacklisted_tags_array(), 'id' => $this->id];
    }

    public function user_info_cookie()
    {
        return implode(';', [$this->id, $this->level, ($this->use_browser ? "1":"0")]);
    }
    # }

    public function find_by_name_nocase($name)
    {
        return User::where("lower(name) = lower(?)", $name)->first();
    }

    # UserTagMethods {
    # iTODO:
    public function uploaded_tags(array $options = array())
    {
        $type = !empty($options['type']) ? $options['type'] : null;
        
        $uploaded_tags = Rails::cache()->read("uploaded_tags/". $this->id . "/" . $type);
        if ($uploaded_tags) {
            return $uploaded_tags;
        }

        if (Rails::env() == "test") {
            # disable filtering in test mode to simplify tests
            $popular_tags = "";
        } else {
            $popular_tags = implode(', ', self::connection()->selectValues("SELECT id FROM tags WHERE tag_type = " . CONFIG()->tag_types['General'] . " ORDER BY post_count DESC LIMIT 8"));
            if ($popular_tags)
                $popular_tags = "AND pt.tag_id NOT IN (${popular_tags})";
        }

        if ($type) {
            $type = (int)$type;
            $sql = "SELECT 
                (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, COUNT(*) AS count
                FROM posts_tags pt, tags t, posts p
                WHERE p.user_id = {$this->id}
                AND p.id = pt.post_id
                AND pt.tag_id = t.id
                {$popular_tags}
                AND t.tag_type = {$type}
                GROUP BY pt.tag_id
                ORDER BY count DESC
                LIMIT 6
            ";
        } else {
            $sql = "SELECT 
                (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, COUNT(*) AS count
                FROM posts_tags pt, posts p
                WHERE p.user_id = {$this->id}
                AND p.id = pt.post_id
                ${popular_tags}
                GROUP BY pt.tag_id
                ORDER BY count DESC
                LIMIT 6
            ";
        }

        $uploaded_tags = self::connection()->select($sql);

        Rails::cache()->write("uploaded_tags/" . $this->id . "/" . $type, $uploaded_tags, ['expires_in' => '1 day']);

        return $uploaded_tags;
    }

    public function voted_tags(array $options = array())
    {
        $type = !empty($options['type']) ? $options['type'] : null;

        $favorite_tags = Rails::cache()->read("favorite_tags/".  $this->id . "/" . $type);
        if ($favorite_tags) {
            return $favorite_tags;
        }

        if (Rails::env() == "test") {
            # disable filtering in test mode to simplify tests
            $popular_tags = "";
        } else {
            $popular_tags = implode(', ', self::connection()->selectValues("SELECT id FROM tags WHERE tag_type = " . CONFIG()->tag_types['General'] . " ORDER BY post_count DESC LIMIT 8"));
            if ($popular_tags)
                $popular_tags = "AND pt.tag_id NOT IN (${popular_tags})";
        }

        if ($type) {
            $type = (int)$type;
            $sql = "SELECT
                (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, SUM(v.score) AS sum
                FROM posts_tags pt, tags t, post_votes v
                WHERE v.user_id = {$this->id}
                AND v.post_id = pt.post_id
                AND pt.tag_id = t.id
                {$popular_tags}
                AND t.tag_type = {$type}
                GROUP BY pt.tag_id
                ORDER BY sum DESC
                LIMIT 6
            ";
        } else {
            $sql = "SELECT
                (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, SUM(v.score) AS sum
                FROM posts_tags pt, post_votes v
                WHERE v.user_id = {$this->id}
                AND v.post_id = pt.post_id
                ${popular_tags}
                GROUP BY pt.tag_id
                ORDER BY sum DESC
                LIMIT 6
            ";
        }

        $favorite_tags = self::connection()->select($sql);

        Rails::cache()->write("favorite_tags/" . $this->id . "/" . $type, $favorite_tags, ['expires_in' => '1 day']);

        return $favorite_tags;
    }
    # }

    # UserPostMethods {
    public function recent_uploaded_posts()
    {
        $posts = Post::findBySql("SELECT p.* FROM posts p WHERE p.user_id = {$this->id} AND p.status <> 'deleted' ORDER BY p.id DESC LIMIT 6");
        return $posts ?: new Rails\ActiveRecord\Collection();
    }

    public function recent_favorite_posts()
    {
        return Post::findBySql('SELECT p.* FROM posts p JOIN post_votes pv ON p.id = pv.post_id WHERE pv.user_id = ' . $this->id . ' AND pv.score = 3 ORDER BY pv.updated_at DESC LIMIT 6');
    }

    public function favorite_post_count($options = array())
    {
        return self::connection()->selectValue("SELECT COUNT(*) FROM post_votes v WHERE v.user_id = {$this->id} AND v.score = 3");
    }

    public function post_count()
    {
        if (!$this->post_count)
            $this->post_count = Post::where("user_id = ? AND status = 'active'", $this->id)->count();
        return $this->post_count;
    }

    public function held_post_count()
    {
        $version = (int)Rails::cache()->read('$cache_version');
        $key = 'held-post-count/v=' . $version . '/u=' . $this->id;
        
        return Rails::cache()->fetch($key, function() {
            return Post::where(['user_id' => $this->id, 'is_held' => true])->where('status <> ?', 'deleted')->count();
        });
    }
    # }

    # UserLevelMethods {
    public function pretty_level()
    {
        return array_search($this->level, CONFIG()->user_levels);
    }

    protected function _set_role()
    {
        if (CONFIG()->enable_account_email_activation)
            $this->level = CONFIG()->user_levels["Unactivated"];
        else
            $this->level = CONFIG()->starting_level;

        $this->last_logged_in_at = date('Y-m-d H:i:s');
    }

    public function has_permission(Rails\ActiveRecord\Base $record, $foreign_key = 'user_id') 
    {
        return ($this->is_mod_or_higher() || $record->$foreign_key == $this->id);
    }

    # Return true if this user can change the specified attribute.
    #
    # If record is an ActiveRecord object, return;s true if the change is allowed to complete.
    #
    # If record is an ActiveRecord class (eg. Pool rather than an actual pool), return;s
    # false if the user would never be allowed to make this change for any instance of the
    # object, and so the option should not be presented.
    #
    # For example, can_change(Pool, :description) return;s true (unless the user level
    # is too low to change any pools), but can_change(Pool.find(1), :description) return;s
    # false if that specific pool is locked.
    #
    # attribute usually corresponds with an actual attribute in the class, but any value
    # can be used.
    public function can_change(Rails\ActiveRecord\Base $record, $attribute)
    {
        $method = "can_change_" . $attribute;
        if ($this->is_mod_or_higher())
            return true;
        elseif (method_exists($record, $method))
            return $record->$method($this);
        elseif (method_exists($record, 'can_change'))
            $record->can_change($this, $attribute);
        else
            return true;
    }

    static public function get_user_level($level)
    {
        static $user_level = [];
        
        if (!$user_level) {
            foreach (CONFIG()->user_levels as $name => $value) {
                $normalized_name = strtolower(str_replace(' ', '_', $name));
                $user_level[$normalized_name] = $value;
            }
        }
        
        return $user_level[$level];
    }
    
    # Created to statically get level name for level id.
    static public function level_name($level_id)
    {
        return array_search($level_id, CONFIG()->user_levels);
    }
    
    public function can_see_posts()
    {
        return !CONFIG()->user_min_level_can_see_posts || $this->level >= CONFIG()->user_min_level_can_see_posts;
    }
    # }

    # module UserInviteMethods {
    public function invite($name, $level)
    {
        if ($this->invite_count <= 0) {
            throw new User_NoInvites();
        }

        if ((int)$level >= CONFIG()->user_levels["Contributor"])
            $level = CONFIG()->user_levels["Contributor"];

        $invitee = User::where(['name' => $name])->first();

        if (!$invitee) {
            throw new Rails\ActiveRecord\Exception\RecordNotFoundException();
        }
        
        // self::load_model('UserRecord');
        
        if (UserRecord::where("user_id = ? AND is_positive = false AND reported_by IN (SELECT id FROM users WHERE level >= ?)", $invitee->id, CONFIG()->user_levels["Mod"])->exists() && !$this->is_admin()) {
            throw new User_HasNegativeRecord();
        }

        // transaction do
        if ($level == CONFIG()->user_levels["Contributor"]) {
            Post::where("user_id = ? AND status = 'pending'", $this->id)->take()->each(function($post) {
                $post->approve($id);
            });
        }
        $invitee->level = $level;
        $invitee->invited_by = $this->id;
        $invitee->save();
        # iTODO: add support for decrement!
        // decrement! :invite_count
        self::connection()->executeSql("UPDATE users SET invite_count = invite_count - 1 WHERE id = ".$this->id);
        $this->invite_count--;
        // end
    }
    # }

    # UserAvatarMethods {
    # post_id is being destroyed.  Clear avatar_post_ids for this post, so we won't use
    # avatars from this post.  We don't need to actually delete the image.
    static public function clear_avatars($post_id)
    {
        self::connection()->executeSql("UPDATE users SET avatar_post_id = NULL WHERE avatar_post_id = ?", $post_id);
    }

    public function avatar_url()
    {
        return CONFIG()->url_base . "/data/avatars/".$this->id.".jpg";
    }

    public function has_avatar()
    {
        return (bool)$this->avatar_post_id;
    }

    public function avatar_path()
    {
        return Rails::root() . "/public/data/avatars/" . $this->id . ".jpg";
    }

    public function set_avatar($params)
    {
        $post = Post::find($params['id']);
        if (!$post->can_be_seen_by($this)) {
            $this->errors()->add('access', "denied");
            return false;
        }
        
        if ($params['top'] < 0 or $params['top'] > 1 or
            $params['bottom'] < 0 or $params['bottom'] > 1 or
            $params['left'] < 0 or $params['left'] > 1 or
            $params['right'] < 0 or $params['right'] > 1 or
            $params['top'] >= $params['bottom'] or
            $params['left'] >= $params['right'])
        {
            $this->errors()->add('parameter', "error");
            return false;
        }

        $tempfile_path = Rails::root() . "/public/data/" . $this->id . ".avatar.jpg";
        
        $use_sample = $post->has_sample();
        if ($use_sample) {
            $image_path = $post->sample_path();
            $image_ext = "jpg";
            $size = $this->_reduce_and_crop($post->sample_width, $post->sample_height, $params);

            # If we're cropping from a very small region in the sample, use the full
            # image instead, to get a higher quality image.
            if (($size['crop_bottom'] - $size['crop_top'] < CONFIG()->avatar_max_height) or
                ($size['crop_right'] - $size['crop_left'] < CONFIG()->avatar_max_width))
                $use_sample = false;
        }

        if (!$use_sample) {
            $image_path = $post->file_path();
            $image_ext = $post->file_ext;
            $size = $this->_reduce_and_crop($post->width, $post->height, $params);
        }
        
        try {
            Moebooru\Resizer::resize($image_ext, $image_path, $tempfile_path, $size, 95);
        } catch (Moebooru\Exception\ResizeErrorException $x) {
            if (file_exists($tempfile_path))
                unlink($tempfile_path);

            $this->errors()->add("avatar", "couldn't be generated (" . $x->getMessage() . ")");
            return false;
        }
        
        rename($tempfile_path, $this->avatar_path());
        chmod($this->avatar_path(), 0775);
        
        $this->updateAttributes(array(
            'avatar_post_id' => $params['post_id'],
            'avatar_top' => $params['top'],
            'avatar_bottom' => $params['bottom'],
            'avatar_left' => $params['left'],
            'avatar_right' => $params['right'],
            'avatar_width' => $size['width'],
            'avatar_height' => $size['height'],
            'avatar_timestamp' => date('Y-m-d H:i:s')
        ));
        
        return true;
    }
    
    private function _reduce_and_crop($image_width, $image_height, $params)
    {
        $cropped_image_width = $image_width * ($params['right'] - $params['left']);
        $cropped_image_height = $image_height * ($params['bottom'] - $params['top']);

        $size = Moebooru\Resizer::reduce_to(
            ['width' => $cropped_image_width, 'height' => $cropped_image_height],
            ['width' => CONFIG()->avatar_max_width, 'height' => CONFIG()->avatar_max_height],
            1, true);
        $size['crop_top'] = $image_height * $params['top'];
        $size['crop_bottom'] = $image_height * $params['bottom'];
        $size['crop_left'] = $image_width * $params['left'];
        $size['crop_right'] = $image_width * $params['right'];
        return $size;
    }
    # }


    # UserTagSubscriptionMethods {
    // protected function tag_subscriptions_text_setter($text)
    // {
      // User.transaction do
        // tag_subscriptions.clear

        // text.scan(/\S+/).each do |new_tag_subscription|
          // tag_subscriptions.create('tag_query' => new_tag_subscription)        }
      // end
    // }

    // def tag_subscriptions_text
      // tag_subscriptions_text.map(&:tag_query).sort.join(" ")
    // end

    // def tag_subscription_posts(limit, name)
      // TagSubscription.find_posts(id, name, limit)
    // end
    # }

    # UserLanguageMethods {
    protected function setSecondaryLanguageArray($langs)
    {
        $this->secondary_languages = $langs;
    }

    public function secondary_language_array()
    {
        if (!is_array($this->secondary_languages))
            $this->secondary_languages = explode(",", $this->secondary_languages);
        return $this->secondary_languages;
    }

    protected function _commit_secondary_languages()
    {
      if (!$this->secondary_languages)
        return;

      if (in_array("none", $this->secondary_languages))
        $this->secondary_languages = "";
      else
        $this->secondary_languages = implode(",", $this->secondary_languages);
    }
    # }

  // $this->salt = CONFIG()->password_salt

  // class << self
    // attr_accessor :salt
  // end

    # For compatibility with AnonymousUser class
    public function is_anonymous()
    {
        return !$this->level;
    }

    public function invited_by_name()
    {
        return self::find_name($this->invited_by);
    }

    public function similar_users()
    {
        # This uses a naive cosine distance formula that is very expensive to calculate.
        # TODO: look into alternatives, like SVD.
        $sql = "
            SELECT
                f0.user_id as user_id,
                COUNT(*) / (SELECT sqrt((SELECT COUNT(*) FROM post_votes WHERE user_id = f0.user_id) * (SELECT COUNT(*) FROM post_votes WHERE user_id = {$this->id}))) AS similarity
            FROM
                vote v0,
                vote v1,
                users u
            WHERE
                v0.post_id = v1.post_id
                AND v1.user_id = {$this->id}
                AND v0.user_id <> {$this->id}
                AND u.id = v0.user_id
            GROUP BY v0.user_id
            ORDER BY similarity DESC
            LIMIT 6
        ";

        return self::connection()->select($sql);
    }

    public function set_show_samples()
    {
        $this->show_samples = true;
    }

    static public function generate_sql($params)
    {
        $query = self::where('true');
        if (isset($params['name']) && (string)$params['name'] !== '') {
            $query->where("name LIKE ? ", "%" . str_replace(" ", "_", $params['name']) . "%");
        }

        if (!empty($params['level']) && $params['level'] != "any") {
            $query->where("level = ?", $params['level']);
        }
        
        if (!empty($params['id'])) {
            $query->where("id = ?", $params['id']);
        }
        
        !isset($params['order']) && $params['order'] = false;
        
        switch ($params['order']) {
            case "name":
                $query->order("lower(name)");
                break;
            case "posts":
                $query->order("(SELECT count(*) FROM posts WHERE user_id = users.id) DESC");
                break;
            case "favorites":
                $query->order("(SELECT count(*) FROM favorites WHERE user_id = users.id) DESC");
                break;
            case "notes":
                $query->order("(SELECT count(*) FROM note_versions WHERE user_id = users.id) DESC");
                break;
            default:
                $query->order("id DESC");
                break;       
        }
        
        return $query;
    }

    protected function associations()
    {
        return array(
            'has_one' => array(
                'ban' => array('foreign_key' => 'user_id'),
                'user_blacklisted_tag' => ['class_name' => 'UserBlacklistedTag']
            ),
            'belongs_to' => array(
                'avatar_post' => array('class_name' => "Post", 'foreign_key' => 'avatar_post_id')
            ),
            'has_many' => array(
                'post_votes' => ['class_name' => 'PostVote'],
                'user_logs' => ['class_name' => 'UserLog'],
                // 'user_blacklisted_tags' => array('dependent' => 'delete_all'),
                'tag_subscriptions' => array(function() { $this->order('name'); }, 'dependent' => 'delete_all', 'class_name' => 'TagSubscription')
            )
        );
    }
    
    protected function _can_signup()
    {
        if (!CONFIG()->enable_signups) {
            $this->errors()->add('signups', 'are disabled');
            return false;
        }
    }
    
    protected function callbacks()
    {
        $before_create = array('_set_role');
        if (CONFIG()->show_samples)
            $before_create[] = '_set_show_samples';
        
        return array(
            'before_validation_on_create' => ['_can_signup'],
            'before_create'     => $before_create,
            'before_save'       => array('_encrypt_password'),
            'before_validation' => array('_commit_secondary_languages'),
            'after_save'        => array('_commit_blacklists', 'update_cached_name'),
            'after_create'      => array('_set_default_blacklisted_tags', '_increment_count'),
            'after_destroy'     => array('_decrement_count')
        );
    }
    
    protected function validations()
    {
        $validations = array(
            'name' => array(
                'length'     => ['in' => [2, 20], 'on' => 'create'],
                'format'     => array('with' => '/\A[^\s;,]+\Z/', 'on' => 'create', 'message' => 'cannot have whitespace, commas, or semicolons'),
                'uniqueness' => array(true, 'on' => 'create')
            ),
            'password' => array(
                'length'       => array('minimum' => 5, 'if' => array('property_exists' => 'password')),
                'confirmation' => true
            ),
            'language' => array(
                'format' => ['with' => '/^([a-z\-]+)|$/']
            ),
            'secondary_languages' => array(
                'format' => ['with' => '/^([a-z\-]+(,[a-z\0]+)*)?$/']
            ),
            
            # Changing password requires current password.
            'validate_current_password'
        );
        
        if (CONFIG()->enable_account_email_activation) {
            $validations['email'] = array(
                'presence' => array(true, 'on' => 'create', 'if' => array('property_exists' => 'email'))
            );
        }
        return $validations;
    }
    
    protected function attrProtected()
    {
        return ['level', 'invite_count'];
    }
    
    private function parse_is_level_or($method)
    {
        list($name, $operator) = explode('_or_', substr($method, 3));
        $name = ucfirst($name);
        $levels = CONFIG()->user_levels;
        
        if (!isset($levels[$name]))
            throw new InvalidArgumentException("User level name not found for " . $method);
        $level = $levels[$name];
        
        if ($operator == 'higher') {
            return $this->level >= $level;
        } elseif ($operator == 'lower') {
            return $this->level <= $level;
        } else {
            throw new InvalidArgumentException("Invalid user level operator " . $operator);
        }
    }
    
    private function parse_is_level($method)
    {
        $level_name = ucfirst(substr($method, 3));
        $levels = CONFIG()->user_levels;
        if (!isset($levels[$level_name])) {
            throw new InvalidArgumentException("User level name not found for " . $method);
        }
        return $this->level == $levels[$level_name];
    }
}