<?php
    /**
     * Class: Post
     * The Post model.
     *
     * See Also:
     *     <Model>
     */
    class Post extends Model {
        const STATUS_PUBLIC    = "public";
        const STATUS_DRAFT     = "draft";
        const STATUS_REG_ONLY  = "registered_only";
        const STATUS_PRIVATE   = "private";
        const STATUS_SCHEDULED = "scheduled";

        public $belongs_to = "user";

        # Variable: $prev
        # The previous post (the post made after this one).
        private $prev;

        # Variable: $next
        # The next post (the post made before this one).
        private $next;

        # Array: $url_attrs
        # The translation array of the post URL setting to regular expressions.
        public static $url_attrs = array(
            '(year)'     => '([0-9]{4})',
            '(month)'    => '([0-9]{1,2})',
            '(day)'      => '([0-9]{1,2})',
            '(hour)'     => '([0-9]{1,2})',
            '(minute)'   => '([0-9]{1,2})',
            '(second)'   => '([0-9]{1,2})',
            '(id)'       => '([0-9]+)',
            '(author)'   => '([^\/]+)',
            '(clean)'    => '([^\/]+)',
            '(url)'      => '([^\/]+)',
            '(feather)'  => '([^\/]+)',
            '(feathers)' => '([^\/]+)'
        );

        /**
         * Function: __construct
         * See Also:
         *     <Model::grab>
         */
        public function __construct($post_id = null, $options = array()) {
            if (!isset($post_id) and empty($options))
                return;

            if (isset($options["where"]) and !is_array($options["where"]))
                $options["where"] = array($options["where"]);
            elseif (!isset($options["where"]))
                $options["where"] = array();

            $has_status = false;
            $skip_where = (
                isset($options["skip_where"]) and $options["skip_where"]
            );

            foreach ($options["where"] as $key => $val) {
                if (
                    (is_int($key) and substr_count($val, "status")) or
                    $key == "status"
                )
                    $has_status = true;
            }

            # Construct SQL query "chunks" for enabled feathers and user privileges.
            if (!$skip_where) {
                $options["where"][] = self::feathers();

                if (!$has_status) {
                    $visitor = Visitor::current();
                    $private = (
                        isset($options["drafts"]) and
                        $options["drafts"] and
                        $visitor->group->can("view_draft")
                    ) ?
                        self::statuses(array(self::STATUS_DRAFT)) :
                        self::statuses() ;

                    if (
                        isset($options["drafts"]) and
                        $options["drafts"] and
                        $visitor->group->can("view_own_draft")
                    ) {
                        $private.= " OR (status = '".
                                   self::STATUS_DRAFT.
                                   "' AND user_id = :visitor_id)";

                        $options["params"][":visitor_id"] = $visitor->id;
                    }

                    $options["where"][] = $private;
                }
            }

            $options["left_join"][] = array(
                "table" => "post_attributes",
                "where" => "post_id = posts.id"
            );
            $options["select"] = array_merge(
                array(
                    "posts.*",
                    "post_attributes.name AS attribute_names",
                    "post_attributes.value AS attribute_values"
                ),
                fallback($options["select"], array())
            );
            $options["ignore_dupes"] = array("attribute_names", "attribute_values");

            parent::grab($this, $post_id, $options);

            if ($this->no_results)
                return;

            $this->slug = $this->url;
            $this->filtered = (!isset($options["filter"]) or $options["filter"]);
            $this->attribute_values = (array) $this->attribute_values;
            $this->attribute_names  = (array) $this->attribute_names;

            $this->attributes = ($this->attribute_names) ?
                array_combine(
                    $this->attribute_names,
                    $this->attribute_values
                ) :
                array() ;

            foreach($this->attributes as $key => $val) {
                if (!empty($key))
                    $this->$key = $val;
            }

            Trigger::current()->filter($this, "post");

            if ($this->filtered)
                $this->filter();
        }

        /**
         * Function: find
         * See Also:
         *     <Model::search>
         */
        public static function find(
            $options = array(),
            $options_for_object = array()
        ): array {
            if (isset($options["where"]) and !is_array($options["where"]))
                $options["where"] = array($options["where"]);
            elseif (!isset($options["where"]))
                $options["where"] = array();

            $has_status = false;
            $skip_where = (isset($options["skip_where"]) and $options["skip_where"]);

            foreach ($options["where"] as $key => $val) {
                if (
                    (is_int($key) and substr_count($val, "status")) or
                    $key === "status"
                )
                    $has_status = true;
            }

            # Construct SQL query "chunks" for enabled feathers and user privileges.
            if (!$skip_where) {
                $options["where"][] = self::feathers();

                if (!$has_status) {
                    $visitor = Visitor::current();
                    $private = (
                        isset($options["drafts"]) and
                        $options["drafts"] and
                        $visitor->group->can("view_draft")
                    ) ?
                        self::statuses(array(self::STATUS_DRAFT)) :
                        self::statuses() ;

                    if (
                        isset($options["drafts"]) and
                        $options["drafts"] and
                        $visitor->group->can("view_own_draft")
                    ) {
                        $private.= " OR (status = '".
                                   self::STATUS_DRAFT.
                                   "' AND user_id = :visitor_id)";

                        $options["params"][":visitor_id"] = $visitor->id;
                    }

                    $options["where"][] = $private;
                }
            }

            $options["left_join"][] = array(
                "table" => "post_attributes",
                "where" => "post_id = posts.id"
            );
            $options["select"] = array_merge(
                array(
                    "posts.*",
                    "post_attributes.name AS attribute_names",
                    "post_attributes.value AS attribute_values"
                ),
                fallback($options["select"], array())
            );
            $options["ignore_dupes"] = array("attribute_names", "attribute_values");

            fallback($options["order"], "pinned DESC, created_at DESC, id DESC");

            return parent::search(
                self::class,
                $options,
                $options_for_object
            );
        }

        /**
         * Function: add
         * Adds a post to the database.
         *
         * Parameters:
         *     $values - The data to insert.
         *     $clean - The slug for this post.
         *     $url - The unique sanitised URL (created from $clean by default).
         *     $feather - The feather to post as.
         *     $user - <User> to set as the post's author.
         *     $pinned - Pin the post?
         *     $status - Post status
         *     $created_at - New @created_at@ timestamp for the post.
         *     $updated_at - New @updated_at@ timestamp for the post.
         *     $pingbacks - Send pingbacks?
         *     $options - Options for the post.
         *
         * Returns:
         *     The newly created <Post>.
         *
         * Notes:
         *     The caller is responsible for validating all supplied values.
         *
         * See Also:
         *     <update>
         */
        public static function add(
            $values     = array(),
            $clean      = "",
            $url        = "",
            $feather    = null,
            $user       = null,
            $pinned     = null,
            $status     = "",
            $created_at = null,
            $updated_at = null,
            $pingbacks  = true,
            $options    = array()
        ): self {
            $user_id = ($user instanceof User) ? $user->id : $user ;

            fallback($clean,        slug(8));
            fallback($url,          self::check_url($clean));
            fallback($feather,      "undefined");
            fallback($user_id,      Visitor::current()->id);
            fallback($pinned,       false);
            fallback($status,       self::STATUS_DRAFT);
            fallback($created_at,   datetime());
            fallback($updated_at,   SQL_DATETIME_ZERO); # Model->updated will check this.
            fallback($options,      array());

            $sql = SQL::current();
            $config = Config::current();
            $trigger = Trigger::current();

            $new_values = array(
                "feather"    => $feather,
                "user_id"    => $user_id,
                "pinned"     => $pinned,
                "status"     => $status,
                "clean"      => $clean,
                "url"        => $url,
                "created_at" => $created_at,
                "updated_at" => $updated_at
            );

            $trigger->filter($new_values, "before_add_post");
            $sql->insert(table:"posts", data:$new_values);
            $id = $sql->latest("posts");
            $attributes = array_merge($values, $options);
            $trigger->filter($attributes, "before_add_post_attributes");

            $attribute_values = array_values($attributes);
            $attribute_names = array_keys($attributes);

            # Insert the post attributes.
            foreach ($attributes as $name => $value)
                $sql->insert(
                    table:"post_attributes",
                    data:array(
                        "post_id" => $id,
                        "name"    => $name,
                        "value"   => $value
                    )
                );

            $post = new self($id, array("skip_where" => true));

            # Notify URLs discovered in the structured feed content.
            if (
                $config->send_pingbacks and
                $pingbacks and
                $post->status == self::STATUS_PUBLIC and
                feather_enabled($post->feather)
            )
                webmention_send($post->feed_content(), $post);

            $trigger->call("add_post", $post, $options);
            return $post;
        }

        /**
         * Function: update
         * Updates a post with the given attributes.
         *
         * Parameters:
         *     $values - An array of data to set for the post.
         *     $user - <User> to set as the post's author.
         *     $pinned - Pin the post?
         *     $status - Post status
         *     $clean - A new slug for the post.
         *     $url - A new unique URL for the post.
         *     $created_at - New @created_at@ timestamp for the post.
         *     $updated_at - New @updated_at@ timestamp for the post.
         *     $options - Options for the post.
         *     $pingbacks - Send pingbacks?
         *
         * Returns:
         *     The updated <Post>.
         *
         * Notes:
         *     The caller is responsible for validating all supplied values.
         *
         * See Also:
         *     <add>
         */
        public function update(
            $values     = null,
            $user       = null,
            $pinned     = null,
            $status     = null,
            $clean      = null,
            $url        = null,
            $created_at = null,
            $updated_at = null,
            $options    = null,
            $pingbacks  = true
        ): self|false {
            if ($this->no_results)
                return false;

            $user_id = ($user instanceof User) ? $user->id : $user ;

            fallback($values,       $this->attributes);
            fallback($user_id,      $this->user_id);
            fallback($pinned,       $this->pinned);
            fallback($status,       $this->status);
            fallback($clean,        $this->clean);
            fallback($url,
                ($clean != $this->clean) ?
                                    self::check_url($clean) :
                                    $this->url
            );
            fallback($created_at,   $this->created_at);
            fallback($updated_at,   datetime());
            fallback($options,      array());

            $sql = SQL::current();
            $config = Config::current();
            $trigger = Trigger::current();

            $new_values = array(
                "user_id"    => $user_id,
                "pinned"     => $pinned,
                "status"     => $status,
                "clean"      => $clean,
                "url"        => $url,
                "created_at" => $created_at,
                "updated_at" => $updated_at
            );

            $trigger->filter($new_values, "before_update_post");

            $sql->update(
                table:"posts",
                conds:array("id" => $this->id),
                data:$new_values
            );

            $attributes = array_merge($values, $options);
            $trigger->filter($attributes, "before_update_post_attributes");

            $attribute_values = array_values($attributes);
            $attribute_names = array_keys($attributes);

            # Replace the post attributes.
            foreach ($attributes as $name => $value)
                $sql->replace(
                    table:"post_attributes",
                    keys:array("post_id", "name"),
                    data:array(
                        "post_id" => $this->id,
                        "name" => $name,
                        "value" => $value
                    )
                );

            $post = new self(
                null,
                array(
                    "read_from" => array_merge(
                        $new_values,
                        array(
                            "id" => $this->id,
                            "feather" => $this->feather,
                            "attribute_names" => $attribute_names,
                            "attribute_values" => $attribute_values
                        )
                    )
                )
            );

            # Notify URLs discovered in the structured feed content.
            if (
                $config->send_pingbacks and
                $pingbacks and
                $post->status == self::STATUS_PUBLIC and
                feather_enabled($post->feather)
            )
                webmention_send($post->feed_content(), $post);

            if (
                $this->status == self::STATUS_SCHEDULED and
                $post->status == self::STATUS_PUBLIC
            ) {
                $trigger->call("publish_post", $post, $this, $options);
            } else {
                $trigger->call("update_post", $post, $this, $options);
            }

            return $post;
        }

        /**
         * Function: delete
         * Deletes a post from the database.
         *
         * See Also:
         *     <Model::destroy>
         */
        public static function delete($id): void {
            parent::destroy(
                self::class,
                $id,
                array("skip_where" => true)
            );

            SQL::current()->delete(
                table:"post_attributes",
                conds:array("post_id" => $id)
            );
        }

        /**
         * Function: deletable
         * Checks if the <User> can delete the post.
         */
        public function deletable($user = null): bool {
            if ($this->no_results)
                return false;

            fallback($user, Visitor::current());

            if ($user->group->can("delete_post"))
                return true;

            return (
                (
                    $this->status == self::STATUS_DRAFT and
                    $user->group->can("delete_draft")
                ) or
                (
                    $user->group->can("delete_own_post") and
                    $this->user_id == $user->id
                ) or
                (
                    $user->group->can("delete_own_draft") and
                    $this->status == self::STATUS_DRAFT and
                    $this->user_id == $user->id
                )
            );
        }

        /**
         * Function: editable
         * Checks if the <User> can edit the post.
         */
        public function editable($user = null): bool {
            if ($this->no_results)
                return false;

            fallback($user, Visitor::current());

            if ($user->group->can("edit_post"))
                return true;

            return (
                (
                    $this->status == self::STATUS_DRAFT and
                    $user->group->can("edit_draft")
                ) or
                (
                    $user->group->can("edit_own_post") and
                    $this->user_id == $user->id
                ) or
                (
                    $user->group->can("edit_own_draft") and
                    $this->status == self::STATUS_DRAFT and
                    $this->user_id == $user->id
                )
            );
        }

        /**
         * Function: any_editable
         * Checks if the <Visitor> can edit any posts.
         */
        public static function any_editable(): bool {
            $visitor = Visitor::current();
            $sql = SQL::current();

            # Can they edit posts?
            if ($visitor->group->can("edit_post"))
                return true;

            # Can they edit drafts?
            if ($visitor->group->can("edit_draft") and
                $sql->count(
                    tables:"posts",
                    conds:array("status" => self::STATUS_DRAFT)
                )
            )
                return true;

            # Can they edit their own posts, and do they have any?
            if ($visitor->group->can("edit_own_post") and
                $sql->count(
                    tables:"posts",
                    conds:array("user_id" => $visitor->id)
                )
            )
                return true;

            # Can they edit their own drafts, and do they have any?
            if ($visitor->group->can("edit_own_draft") and
                $sql->count(
                    tables:"posts",
                    conds:array(
                        "status" => self::STATUS_DRAFT,
                        "user_id" => $visitor->id
                    )
                )
            )
                return true;

            return false;
        }

        /**
         * Function: any_deletable
         * Checks if the <Visitor> can delete any posts.
         */
        public static function any_deletable(): bool {
            $visitor = Visitor::current();
            $sql = SQL::current();

            # Can they delete posts?
            if ($visitor->group->can("delete_post"))
                return true;

            # Can they delete drafts?
            if ($visitor->group->can("delete_draft") and
                $sql->count(
                    tables:"posts",
                    conds:array("status" => self::STATUS_DRAFT)
                )
            )
                return true;

            # Can they delete their own posts, and do they have any?
            if ($visitor->group->can("delete_own_post") and
                $sql->count(
                    tables:"posts",
                    conds:array("user_id" => $visitor->id)
                )
            )
                return true;

            # Can they delete their own drafts, and do they have any?
            if ($visitor->group->can("delete_own_draft") and
                $sql->count(
                    tables:"posts",
                    conds:array(
                        "status" => self::STATUS_DRAFT,
                        "user_id" => $visitor->id
                    )
                )
            )
                return true;

            return false;
        }

        /**
         * Function: exists
         * Checks if a post exists.
         *
         * Parameters:
         *     $post_id - The post ID to check
         *
         * Returns:
         *     true - if a post with that ID is in the database.
         */
        public static function exists($post_id): bool {
            return SQL::current()->count(
                tables:"posts",
                conds:array("id" => $post_id)
            ) == 1;
        }

        /**
         * Function: check_url
         * Checks if a given URL value is already being used as another post's URL.
         *
         * Parameters:
         *     $url - The URL to check.
         *
         * Returns:
         *     The unique version of the URL value.
         *     If unused, it's the same as $url. If used, a number is appended to it.
         */
        public static function check_url($url): string {
            if (empty($url))
                return $url;

            $count = 1;
            $unique = substr($url, 0, 128);

            while (
                SQL::current()->count(
                    tables:"posts",
                    conds:array("url" => $unique)
                )
            ) {
                $count++;
                $unique = substr($url, 0, (127 - strlen($count)))."-".$count;
            }

            return $unique;
        }

        /**
         * Function: url
         * Returns a post's URL.
         */
        public function url(): string|false {
            if ($this->no_results)
                return false;

            $config = Config::current();
            $visitor = Visitor::current();

            if (!$config->clean_urls)
                return fix(
                    $config->url."/?action=view&url=".urlencode($this->url),
                    true
                );

            $login = (strpos($config->post_url, "(author)") !== false) ?
                $this->user->login :
                "" ;

            $vals = array(
                when("Y", $this->created_at),
                when("m", $this->created_at),
                when("d", $this->created_at),
                when("H", $this->created_at),
                when("i", $this->created_at),
                when("s", $this->created_at),
                $this->id,
                urlencode($login),
                urlencode($this->clean),
                urlencode($this->url),
                urlencode($this->feather),
                urlencode(pluralize($this->feather))
            );

            $post_url = str_replace(
                array_keys(self::$url_attrs),
                $vals,
                $config->post_url
            );

            return fix($config->url."/".$post_url, true);
        }

        /**
         * Function: title_from_excerpt
         * Generates an acceptable title from the post's excerpt.
         *
         * Returns:
         *     filtered -> first line -> ftags stripped ->
         *     truncated to 75 characters -> normalized.
         */
        public function title_from_excerpt(): string|false {
            if ($this->no_results)
                return false;

            # The text is likely to have some sort of markup module applied;
            # if the current instantiation is not filtered, make one that is.
            $post = ($this->filtered) ?
                $this :
                new self($this->id, array("skip_where" => true)) ;

            $excerpt = $post->excerpt();
            Trigger::current()->filter($excerpt, "title_from_excerpt");

            $stripped = strip_tags($excerpt);
            $truncated = truncate($stripped, 75);
            $normalized = normalize($truncated);

            return ($normalized == "") ? $post->url : $normalized ;
        }

        /**
         * Function: title
         * Returns the given post's title, provided by its Feather.
         */
        public function title(): string|false {
            if ($this->no_results)
                return false;

            # The text is likely to have some sort of markup module applied;
            # if the current instantiation is not filtered, make one that is.
            $post = ($this->filtered) ?
                $this :
                new self($this->id, array("skip_where" => true)) ;

            $title = Feathers::$instances[$this->feather]->title($post);
            return Trigger::current()->filter($title, "title", $post);
        }

        /**
         * Function: excerpt
         * Returns the given post's excerpt, provided by its Feather.
         */
        public function excerpt(): string|false {
            if ($this->no_results)
                return false;

            # The text is likely to have some sort of markup module applied;
            # if the current instantiation is not filtered, make one that is.
            $post = ($this->filtered) ?
                $this :
                new self($this->id, array("skip_where" => true)) ;

            $excerpt = Feathers::$instances[$this->feather]->excerpt($post);
            return Trigger::current()->filter($excerpt, "excerpt", $post);
        }

        /**
         * Function: feed_content
         * Returns the given post's feed content, provided by its Feather.
         */
        public function feed_content(): string|false {
            if ($this->no_results)
                return false;

            # The text is likely to have some sort of markup module applied;
            # if the current instantiation is not filtered, make one that is.
            $post = ($this->filtered) ?
                $this :
                new self($this->id, array("skip_where" => true)) ;

            $feed_content = Feathers::$instances[$this->feather]->feed_content($post);
            return Trigger::current()->filter($feed_content, "feed_content", $post);
        }

        /**
         * Function: next
         *
         * Returns:
         *     The next post (the post made before this one).
         */
        public function next(): self|false {
            if ($this->no_results)
                return false;

            if (!isset($this->next)) {
                $this->next = new self(
                    null,
                    array(
                        "where" => array(
                            "created_at <" => $this->created_at,
                            (
                                $this->status == self::STATUS_DRAFT ?
                                    self::statuses(array(self::STATUS_DRAFT)) :
                                    self::statuses()
                            )
                        ),
                        "order" => "created_at DESC, id DESC"
                    )
                );
            }

            return $this->next;
        }

        /**
         * Function: prev
         *
         * Returns:
         *     The previous post (the post made after this one).
         */
        public function prev(): self|false {
            if ($this->no_results)
                return false;

            if (!isset($this->prev)) {
                $this->prev = new self(
                    null,
                    array(
                        "where" => array(
                            "created_at >" => $this->created_at,
                            (
                                $this->status == self::STATUS_DRAFT ?
                                    self::statuses(array(self::STATUS_DRAFT)) :
                                    self::statuses()
                            )
                        ),
                        "order" => "created_at ASC, id ASC"
                    )
                );
            }

            return $this->prev;
        }

        /**
         * Function: theme_exists
         * Checks if the current post's feather theme file exists.
         */
        public function theme_exists(): bool {
            return (
                !$this->no_results and
                Theme::current()->file_exists("feathers".DIR.$this->feather)
            );
        }

        /**
         * Function: filter
         * Filters the post attributes through filter_post and any Feather filters.
         */
        private function filter(): void {
            $class = camelize($this->feather);
            $touched = array();

            $trigger = Trigger::current();
            $trigger->filter($this, "filter_post");

            # Custom filters.
            if (isset(Feathers::$custom_filters[$class]))
                foreach (Feathers::$custom_filters[$class] as $custom_filter) {
                    $field = $custom_filter["field"];
                    $field_unfiltered = $field."_unfiltered";

                    if (!in_array($field_unfiltered, $touched)) {
                        $this->$field_unfiltered = isset($this->$field) ?
                            $this->$field :
                            null ;

                        $touched[] = $field_unfiltered;
                    }

                    $this->$field = call_user_func_array(
                        array(
                            Feathers::$instances[$this->feather],
                            $custom_filter["name"]
                        ),
                        array($this->$field, $this)
                    );
                }

            # Trigger filters.
            if (isset(Feathers::$filters[$class]))
                foreach (Feathers::$filters[$class] as $filter) {
                    $field = $filter["field"];
                    $field_unfiltered = $field."_unfiltered";

                    if (!in_array($field_unfiltered, $touched)) {
                        $this->$field_unfiltered = isset($this->$field) ?
                            $this->$field :
                            null ;

                        $touched[] = $field_unfiltered;
                    }

                    if (isset($this->$field) and !empty($this->$field))
                        $trigger->filter($this->$field, $filter["name"], $this);
                }
        }

        /**
         * Function: from_url
         * Attempts to grab a post from its clean or dirty URL.
         *
         * Parameters:
         *     $request - The request URI to parse.
         *     $route - The route to respond to, or null to return a Post.
         *     $options - Additional options for the Post object (optional).
         */
        public static function from_url(
            $request,
            $route = null,
            $options = array()
        ): self|array|false {
            $config = Config::current();

            # Dirty URL?
            if (preg_match("/(\?|&)url=([^&#]+)/", $request, $slug)) {
                $post = new self(array("url" => $slug[2]), $options);

                return isset($route) ?
                    $route->try["view"] = array($post) :
                    $post ;
            }

            $regex = "";      # Request validity is tested with this.
            $attrs = array(); # Post attributes present in post_url.
            $found = array(); # Post attributes found in the request.
            $parts = preg_split(
                "~(\([^)]+\))~",
                rtrim($config->post_url, "/"),
                0,
                PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
            );

            # Differentiate between post attributes and junk in post_url.
            foreach ($parts as $part)
                if (isset(self::$url_attrs[$part])) {
                    $regex .= self::$url_attrs[$part];
                    $attrs[] = trim($part, "()");
                } else {
                    $regex .= preg_quote($part, "~");
                }

            # Test the request and return false if it isn't valid.
            if (
                !preg_match(
                    "~^$regex(/|$)~",
                    ltrim(str_replace($config->url, "/", $request), "/"),
                    $matches
                )
            )
                return false;

            # Populate $found using the array of sub-pattern matches.
            for ($i = 0; $i < count($attrs); $i++)
                $found[$attrs[$i]] = urldecode($matches[$i + 1]);

            $where = array();
            $dates = array("year", "month", "day", "hour", "minute", "second");

            $created_at = array(
                "year"   => "____",
                "month"  => "__",
                "day"    => "__",
                "hour"   => "__",
                "minute" => "__",
                "second" => "__"
            );

            # Conversions of some attributes.
            foreach ($found as $part => $value)
                if (in_array($part, $dates)) {
                    # Filter by date/time of creation.
                    $created_at[$part] = $value;
                    $where["created_at LIKE"] = (
                        $created_at["year"]."-".
                        $created_at["month"]."-".
                        $created_at["day"]." ".
                        $created_at["hour"].":".
                        $created_at["minute"].":".
                        $created_at["second"]."%"
                    );
                } elseif ($part == "author") {
                    # Filter by "author" (login).
                    $user = new User(array("login" => $value));

                    $where["user_id"] = ($user->no_results) ?
                        0 :
                        $user->id ;
                } elseif ($part == "feathers") {
                    # Filter by feather.
                    $where["feather"] = depluralize($value);
                } else {
                    # Key => Val expression.
                    $where[$part] = $value;
                }

            $post = new self(
                null,
                array_merge($options, array("where" => $where))
            );

            return isset($route) ?
                $route->try["view"] = array($post) :
                $post ;
        }

        /**
         * Function: statuses
         * Returns a SQL query "chunk" for the "status" column permissions of the current user.
         *
         * Parameters:
         *     $start - An array of additional statuses to allow;
         *              "registered_only", "private" and "scheduled" are added deterministically.
         */
        public static function statuses($start = array()): string {
            $visitor = Visitor::current();

            $statuses = array_merge(array(self::STATUS_PUBLIC), $start);

            if (logged_in())
                $statuses[] = self::STATUS_REG_ONLY;

            if ($visitor->group->can("view_private"))
                $statuses[] = self::STATUS_PRIVATE;

            if ($visitor->group->can("view_scheduled"))
                $statuses[] = self::STATUS_SCHEDULED;

            return "(posts.status IN ('".implode("', '", $statuses)."')".
                   " OR posts.status LIKE '%{".$visitor->group->id."}%')".
                   " OR (posts.status LIKE '%{%' AND posts.user_id = ".$visitor->id.")";
        }

        /**
         * Function: feathers
         * Returns a SQL query "chunk" for the "feather" column so that it matches enabled feathers.
         */
        public static function feathers(): string {
            $feathers = array();

            foreach (Config::current()->enabled_feathers as $feather)
                if (feather_enabled($feather))
                    $feathers[] = $feather;

            return "posts.feather IN ('".implode("', '", $feathers)."')";
        }

        /**
         * Function: author
         * Returns a post's author. Example: $post->author->name
         */
        public function author(): object|false {
            if ($this->no_results)
                return false;

            $author = (!$this->user->no_results) ?
                array(
                    "id"        => $this->user->id,
                    "name"      => oneof($this->user->full_name, $this->user->login),
                    "website"   => $this->user->website,
                    "email"     => $this->user->email,
                    "joined"    => $this->user->joined_at
                )
                :
                array(
                    "id"      => 0,
                    "name"    => __("[Guest]"),
                    "website" => "",
                    "email"   => "",
                    "joined"  => $this->created_at
                )
                ;

            return (object) $author;
        }

        /**
         * Function: groups
         * Returns the IDs of any groups given viewing permission in the post's status.
         */
        public function groups(): array|false {
            if ($this->no_results)
                return false;

            preg_match_all(
                "/\{([0-9]+)\}/",
                $this->status,
                $groups,
                PREG_PATTERN_ORDER
            );

            return empty($groups[1]) ? false : $groups[1] ;
        }

        /**
         * Function: publish_scheduled
         * Searches for and publishes scheduled posts.
         *
         * Calls the @publish_post@ trigger with the updated <Post>.
         */
        public static function publish_scheduled($pingbacks = false): void {
            $sql = SQL::current();
            $ids = $sql->select(
                tables:"posts",
                fields:"id",
                conds:array(
                    "created_at <=" => datetime(),
                    "status" => self::STATUS_SCHEDULED
                )
            )->fetchAll();

            foreach ($ids as $id) {
                $post = new self(
                    $id,
                    array(
                        "skip_where" => true,
                        "filter" => false
                    )
                );

                $post->update(
                    status:self::STATUS_PUBLIC,
                    pingbacks:$pingbacks
                );
            }
        }
    }