<?php
/**
 * Tam Rank™ automatic alt / title templates on upload
 *
 * Fills the alt text and the media title of a NEW attachment from a pattern,
 * so images stop landing in the library without any alt text at all.
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * THE ONE HARD RULE: never overwrite something a human wrote.
 *
 * The alt text is only written when `_wp_attachment_image_alt` is empty. The
 * media title is only written when it is still the value WordPress derived from
 * the uploaded file, so a title that someone actually typed — or that an
 * importer supplied — is left exactly as it is. This is the behaviour we
 * position against: Rank Math's fix-site-seo does overwrite.
 *
 * One value may be rewritten a second time: the one WE wrote into an image that
 * was uploaded into a post that had never been saved. See "deferred fill"
 * below. That rewrite happens only while the stored value is still byte-for-byte
 * what we left there — the moment a human touches it, we let go for good.
 * ─────────────────────────────────────────────────────────────────────────────
 *
 * Off by default.
 *
 * @package TamRank
 */

if (!defined('ABSPATH')) {
    exit;
}

class TamRank_Alt_Templates
{
    /** Option name holding the settings. */
    const OPTION = 'tam_rank_alt_template_settings';

    /**
     * Markers holding the exact value we wrote (or deliberately left in place)
     * on an attachment whose parent post was still an unsaved auto-draft.
     *
     * They are the "is this still ours?" proof for the deferred fill, and they
     * are the only reason we ever write to a field twice. Cleared by
     * uninstall.php along with every other `_tamrank_*` post meta key.
     */
    const META_ALT_AUTOFILLED   = '_tamrank_alt_autofilled';
    const META_TITLE_AUTOFILLED = '_tamrank_title_autofilled';

    /**
     * The verdicts plan_alt_fill() can return.
     *
     * They exist because a second caller — the bulk action over media that is
     * already in the library — has to be able to ask "what WOULD land in this
     * field, and am I allowed to put it there?" without writing anything. One
     * decision function, two callers. See plan_alt_fill().
     */
    const PLAN_FILL         = 'fill';
    const PLAN_NO_TEMPLATE  = 'no_template';
    const PLAN_NOT_AN_IMAGE = 'not_an_image';
    const PLAN_OCCUPIED     = 'occupied';
    const PLAN_EMPTY_RESULT = 'empty_result';

    /** Re-entrancy guard for the wp_update_post() call on the media title. */
    private static $updating = false;

    public function __construct()
    {
        add_action('add_attachment', array($this, 'maybe_fill_on_upload'));
        add_action('transition_post_status', array($this, 'maybe_refill_on_first_save'), 10, 3);
    }

    /**
     * Default settings.
     *
     * @return array
     */
    public static function get_defaults()
    {
        return array(
            'enabled'        => false,
            'alt_template'   => '{filename}',
            'title_template' => '',
            // Filling media that is ALREADY in the library is a separate
            // decision from filling new uploads, and it is off until somebody
            // asks for it. Neither switch may ever drag the other along.
            'bulk_enabled'   => false,
        );
    }

    /**
     * Stored settings merged over the defaults.
     *
     * @return array
     */
    public static function get_settings()
    {
        $stored = function_exists('tam_rank_cached_option')
            ? tam_rank_cached_option(self::OPTION, array())
            : get_option(self::OPTION, array());

        if (!is_array($stored)) {
            $stored = array();
        }

        return wp_parse_args($stored, self::get_defaults());
    }

    /**
     * Sanitize this option — the ONE definition, for every path that writes it.
     *
     * Two paths write this option now: the settings form through
     * register_setting(), and the AJAX route behind the "also fill existing
     * media" switch, which saves itself the moment it is flipped. They have to
     * produce a byte-identical array for the same input, so there is exactly one
     * function that can produce it. A second copy living next to the form would
     * drift the first time either half is edited.
     *
     * Strict whitelist: never returns raw input. Alt text and media titles are
     * plain text, so both templates go through sanitize_text_field().
     *
     * @param mixed $input Raw input from the settings form or the AJAX route.
     * @return array
     */
    public static function sanitize_settings($input)
    {
        $input = (array) $input;

        return array(
            'enabled'        => ! empty( $input['enabled'] ),
            'alt_template'   => isset( $input['alt_template'] ) ? sanitize_text_field( trim( (string) $input['alt_template'] ) ) : '',
            'title_template' => isset( $input['title_template'] ) ? sanitize_text_field( trim( (string) $input['title_template'] ) ) : '',
            // Two switches, two keys, two checkboxes, each with its own hidden
            // "0" field. Neither is ever derived from the other, in either
            // direction: filling new uploads and filling media that is already
            // there are separate decisions.
            'bulk_enabled'   => ! empty( $input['bulk_enabled'] ),
        );
    }

    /**
     * The placeholders a user may use in the templates.
     *
     * @return string[]
     */
    public static function get_placeholders()
    {
        return array('{filename}', '{post_title}', '{site_name}', '{category}');
    }

    /**
     * Fill alt and/or title for a freshly created attachment.
     *
     * @param int $attachment_id
     * @return void
     */
    public function maybe_fill_on_upload($attachment_id)
    {
        if (self::$updating) {
            return;
        }

        $settings = self::get_settings();
        if (empty($settings['enabled'])) {
            return;
        }

        $attachment = get_post($attachment_id);
        if (!$attachment instanceof WP_Post || $attachment->post_type !== 'attachment') {
            return;
        }

        // Only images have alt text. A PDF or a zip gets nothing.
        if (strpos((string) $attachment->post_mime_type, 'image/') !== 0) {
            return;
        }

        // Dragged into a post that has never been saved? Then {post_title} and
        // {category} render empty right now but will resolve the moment that
        // post gets its real title, so record what we leave behind and come
        // back for it in maybe_refill_on_first_save().
        $defer = self::parent_is_unsaved($attachment);

        $this->maybe_fill_alt($attachment, $settings['alt_template'], $defer);
        $this->maybe_fill_title($attachment, $settings['title_template'], $defer);
    }

    /**
     * What would happen to this attachment's alt text — without touching it.
     *
     * The ONLY place where "may we write here?" and "what would we write?" are
     * decided. It reads; it never writes, never calls update_post_meta(), never
     * hits an action. The bulk action's preview depends on that literally: it
     * renders this function's answer on screen and writes nothing at all.
     *
     * The "is this field free?" test is a byte comparison against the empty
     * string — deliberately NOT empty() and no longer trim(). Two traps:
     *
     *   empty("0")      === true  → an alt text of "0" would look free.
     *   trim("   ")     === ""    → an alt of three spaces would look free.
     *
     * Both are somebody's field, not ours. A single space is how people mark a
     * decorative image; the string "0" is a perfectly good alt for a picture of
     * a zero. Only a field that holds nothing at all is free.
     *
     * @param WP_Post|int $attachment
     * @param string      $template
     * @return array {
     *     @type string $status         One of the PLAN_* constants.
     *     @type string $value          What would be written (PLAN_FILL only).
     *     @type string $current        What is in the field right now.
     *     @type bool   $unsaved_parent Parent post is still an auto-draft shell.
     *     @type int    $id             Attachment ID, 0 when it could not be read.
     * }
     */
    public static function plan_alt_fill($attachment, $template)
    {
        if (!$attachment instanceof WP_Post) {
            $attachment = get_post((int) $attachment);
        }

        $out = array(
            'status'         => self::PLAN_NOT_AN_IMAGE,
            'value'          => '',
            'current'        => '',
            'unsaved_parent' => false,
            'id'             => 0,
        );

        if (!$attachment instanceof WP_Post || $attachment->post_type !== 'attachment') {
            return $out;
        }

        $out['id'] = (int) $attachment->ID;

        if (strpos((string) $attachment->post_mime_type, 'image/') !== 0) {
            return $out;
        }

        $out['current']        = (string) get_post_meta($attachment->ID, '_wp_attachment_image_alt', true);
        $out['unsaved_parent'] = self::parent_is_unsaved($attachment);

        $template = trim((string) $template);
        if ($template === '') {
            $out['status'] = self::PLAN_NO_TEMPLATE;
            return $out;
        }

        // NEVER overwrite: anything already in the field wins, always.
        if ($out['current'] !== '') {
            $out['status'] = self::PLAN_OCCUPIED;
            return $out;
        }

        $value         = self::render_template($template, $attachment);
        $out['value']  = $value;
        $out['status'] = $value === '' ? self::PLAN_EMPTY_RESULT : self::PLAN_FILL;

        return $out;
    }

    /**
     * Write the alt text — only when there is none.
     *
     * @param WP_Post $attachment
     * @param string  $template
     * @param bool    $defer Parent is an unsaved auto-draft: leave a marker.
     * @return void
     */
    private function maybe_fill_alt($attachment, $template, $defer = false)
    {
        $plan = self::plan_alt_fill($attachment, $template);

        // Nothing configured, not an image, or somebody else's field: stop
        // before the marker, exactly as this did before the decision moved out.
        if ($plan['status'] === self::PLAN_NO_TEMPLATE
            || $plan['status'] === self::PLAN_NOT_AN_IMAGE
            || $plan['status'] === self::PLAN_OCCUPIED
        ) {
            return;
        }

        if ($plan['status'] === self::PLAN_FILL) {
            update_post_meta($attachment->ID, '_wp_attachment_image_alt', $plan['value']);
        }

        // The field is ours: we either just wrote it, or it is still the empty
        // field we were allowed to write into. Store what is actually in the
        // database now (read back, so filters on the way in cannot make the
        // marker disagree with the value it is supposed to prove).
        if ($defer && self::template_depends_on_parent($template)) {
            update_post_meta(
                $attachment->ID,
                self::META_ALT_AUTOFILLED,
                (string) get_post_meta($attachment->ID, '_wp_attachment_image_alt', true)
            );
        }
    }

    /**
     * Write the media title — only when WordPress itself put it there.
     *
     * Core always gives an upload a title, so "empty" is not the right test: it
     * would never be empty and the setting would be dead. Instead we only write
     * while the title is still the one core derived from the uploaded file.
     *
     * @param WP_Post $attachment
     * @param string  $template
     * @param bool    $defer Parent is an unsaved auto-draft: leave a marker.
     * @return void
     */
    private function maybe_fill_title($attachment, $template, $defer = false)
    {
        $template = trim((string) $template);
        if ($template === '') {
            return;
        }

        if (!self::title_is_core_derived($attachment)) {
            // Someone typed (or imported) a real title. Leave it alone.
            return;
        }

        $current = trim((string) $attachment->post_title);
        $value   = self::render_template($template, $attachment);

        if ($value !== '' && $value !== $current) {
            self::$updating = true;
            wp_update_post(array(
                'ID'         => $attachment->ID,
                'post_title' => $value,
            ));
            self::$updating = false;
        }

        if ($defer && self::template_depends_on_parent($template)) {
            update_post_meta(
                $attachment->ID,
                self::META_TITLE_AUTOFILLED,
                (string) get_post_field('post_title', $attachment->ID)
            );
        }
    }

    // -----------------------------------------------------------------------
    // Deferred fill: the parent post leaves auto-draft
    // -----------------------------------------------------------------------

    /**
     * The parent post got its real title: finish what the upload could not.
     *
     * An image dropped into a brand new post is parented to the `auto-draft`
     * shell the editor creates, which has no title and no category yet. We fill
     * what we can at that moment and mark it; here we render the pattern again
     * now that there IS a title, and update — but only while the stored value
     * is still byte-for-byte the marker. Anything else means a human edited it
     * in the meantime, and then we drop the marker and never come back.
     *
     * @param string  $new_status
     * @param string  $old_status
     * @param WP_Post $post
     * @return void
     */
    public function maybe_refill_on_first_save($new_status, $old_status, $post)
    {
        // Only the one transition that matters, checked before anything else:
        // this hook fires on every status change on the whole site.
        if ($old_status !== 'auto-draft' || $new_status === 'auto-draft') {
            return;
        }

        if (self::$updating || !$post instanceof WP_Post) {
            return;
        }

        $settings = self::get_settings();
        if (empty($settings['enabled'])) {
            return;
        }

        $has_alt_tpl   = self::template_depends_on_parent($settings['alt_template']);
        $has_title_tpl = self::template_depends_on_parent($settings['title_template']);
        if (!$has_alt_tpl && !$has_title_tpl) {
            return;
        }

        // One query, once in a post's life: the moment it stops being a shell.
        $attachment_ids = get_posts(array(
            'post_type'              => 'attachment',
            'post_status'            => 'inherit',
            'post_parent'            => $post->ID,
            'posts_per_page'         => -1,
            'fields'                 => 'ids',
            'no_found_rows'          => true,
            'ignore_sticky_posts'    => true,
            'update_post_term_cache' => false,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- keyed on our own indexed meta_key; runs once per post, only for posts leaving auto-draft.
            'meta_query'             => array(
                'relation' => 'OR',
                array('key' => self::META_ALT_AUTOFILLED, 'compare' => 'EXISTS'),
                array('key' => self::META_TITLE_AUTOFILLED, 'compare' => 'EXISTS'),
            ),
        ));

        if (empty($attachment_ids)) {
            return;
        }

        foreach ($attachment_ids as $attachment_id) {
            $this->refill_attachment((int) $attachment_id, $settings, $post);
        }
    }

    /**
     * Re-render one marked attachment against the now-real parent post.
     *
     * @param int     $attachment_id
     * @param array   $settings
     * @param WP_Post $parent The post as it is right now, straight from the
     *                        transition — never a possibly stale cache read.
     * @return void
     */
    private function refill_attachment($attachment_id, $settings, $parent)
    {
        $attachment = get_post($attachment_id);
        if (!$attachment instanceof WP_Post) {
            return;
        }

        // -- alt ------------------------------------------------------------
        if (metadata_exists('post', $attachment_id, self::META_ALT_AUTOFILLED)) {
            $marker  = (string) get_post_meta($attachment_id, self::META_ALT_AUTOFILLED, true);
            $current = (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true);

            if ($current !== $marker) {
                // A human changed it after the upload. Hands off, for good.
                delete_post_meta($attachment_id, self::META_ALT_AUTOFILLED);
            } else {
                $value = self::render_template($settings['alt_template'], $attachment, $parent);
                if ($value !== '' && $value !== $current) {
                    update_post_meta($attachment_id, '_wp_attachment_image_alt', $value);
                    // Refresh the marker to whatever actually landed, so a
                    // later transition keeps exactly the same safe behaviour.
                    update_post_meta(
                        $attachment_id,
                        self::META_ALT_AUTOFILLED,
                        (string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true)
                    );
                }
            }
        }

        // -- media title -----------------------------------------------------
        if (metadata_exists('post', $attachment_id, self::META_TITLE_AUTOFILLED)) {
            $marker  = (string) get_post_meta($attachment_id, self::META_TITLE_AUTOFILLED, true);
            $current = (string) $attachment->post_title;

            if ($current !== $marker) {
                delete_post_meta($attachment_id, self::META_TITLE_AUTOFILLED);
            } else {
                $value = self::render_template($settings['title_template'], $attachment, $parent);
                if ($value !== '' && $value !== $current) {
                    self::$updating = true;
                    wp_update_post(array(
                        'ID'         => $attachment_id,
                        'post_title' => $value,
                    ));
                    self::$updating = false;

                    update_post_meta(
                        $attachment_id,
                        self::META_TITLE_AUTOFILLED,
                        (string) get_post_field('post_title', $attachment_id)
                    );
                }
            }
        }
    }

    /**
     * Can this template still change once the parent post is real?
     *
     * Only {post_title} and {category} read the parent, so a pattern without
     * them has nothing to gain from a second pass — and then we leave no marker
     * at all rather than write post meta on every upload.
     *
     * @param string $template
     * @return bool
     */
    private static function template_depends_on_parent($template)
    {
        $template = (string) $template;

        return strpos($template, '{post_title}') !== false
            || strpos($template, '{category}') !== false;
    }

    /**
     * Is this attachment parented to a post that has never been saved?
     *
     * @param WP_Post $attachment
     * @return bool
     */
    private static function parent_is_unsaved($attachment)
    {
        $parent_id = (int) $attachment->post_parent;
        if ($parent_id <= 0) {
            return false;
        }

        return self::is_unsaved_parent(get_post($parent_id));
    }

    // -----------------------------------------------------------------------
    // "Did WordPress write this title, or did a person?"
    // -----------------------------------------------------------------------

    /**
     * Is the current media title still the one core derived from the upload?
     *
     * Comparing against the file on disk alone is wrong, and that is the bug
     * this replaces. Both upload routes take post_title from the name the
     * BROWSER sent, not from the sanitized name that ended up on disk:
     *
     *   WP_REST_Attachments_Controller::create_item() reads $files['file']['name']
     *   media_handle_upload()                          reads $_FILES[$id]['name']
     *
     * `sanitize_file_name()` then rewrites that name on its way to disk — spaces
     * become dashes, accents are folded, punctuation is dropped — and
     * `wp_unique_filename()` appends -1/-2 when the name is taken. So uploading
     * "TEAK V4.png" leaves post_title 'TEAK V4' next to 'TEAK-V4.png' on disk.
     * The old guard read that difference as "a human typed this" and silently
     * skipped, which is exactly the common case rather than an edge case.
     *
     * So: collect every name core could legitimately have derived the title
     * from, compare byte-exactly first, and only then fall back to a normalized
     * form in which the separator/case/accent rewrites cancel out.
     *
     * The residual risk of that last step is a title that a human typed which
     * happens to normalize to the file's own name ("Zwarte koffie" on
     * zwarte-koffie.jpg). It carries nothing the pattern would destroy, and at
     * `add_attachment` there is no window in which a person could have typed
     * anything — the only writers at that instant are core, an importer and the
     * image's own EXIF title.
     *
     * @param WP_Post $attachment
     * @return bool
     */
    private static function title_is_core_derived($attachment)
    {
        $current = trim((string) $attachment->post_title);
        if ($current === '') {
            return true;
        }

        $candidates = self::core_title_candidates($attachment);
        if (empty($candidates)) {
            // Nothing to compare against — an attachment row without a file.
            // Fail towards leaving the title alone.
            return false;
        }

        foreach ($candidates as $candidate) {
            if ($current === $candidate) {
                return true;
            }
        }

        $needle = self::comparison_key($current);
        if ($needle === '') {
            return false;
        }

        foreach ($candidates as $candidate) {
            if ($needle === self::comparison_key($candidate)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Every title core could have derived from this upload.
     *
     * @param WP_Post $attachment
     * @return string[]
     */
    private static function core_title_candidates($attachment)
    {
        $names = self::uploaded_file_names();

        $file = get_post_meta($attachment->ID, '_wp_attached_file', true);
        if (is_string($file) && $file !== '') {
            $names[] = wp_basename($file);
        }

        $candidates = array();
        foreach ($names as $name) {
            $base = self::strip_extension($name);
            if ($base === '') {
                continue;
            }

            $candidates[] = $base;
            $candidates[] = sanitize_text_field($base);

            // wp_unique_filename() appends -1, -2, … when the name is taken and
            // wp_create_image_subsizes() appends -scaled to a big image. Neither
            // is part of the name the user actually uploaded. (-scaled does not
            // exist yet while add_attachment runs — core adds it afterwards, in
            // wp_generate_attachment_metadata() — but stripping it keeps this
            // right for anything that reaches us later.)
            $trimmed = preg_replace('/(?:-scaled)?(?:-\d+)?$/', '', $base);
            if (is_string($trimmed) && $trimmed !== '' && $trimmed !== $base) {
                $candidates[] = $trimmed;
                $candidates[] = sanitize_text_field($trimmed);
            }
        }

        return array_values(array_unique(array_filter($candidates, 'strlen')));
    }

    /**
     * The original file names of the request that is running right now.
     *
     * Read-only: nothing here is stored, it is only ever compared against a
     * title core itself derived from the very same value.
     *
     * @return string[]
     */
    private static function uploaded_file_names()
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only comparison against a value core derived from this same upload; no state is changed by reading it.
        if (empty($_FILES) || !is_array($_FILES)) {
            return array();
        }

        $names = array();
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- see above.
        foreach ($_FILES as $field) {
            if (!is_array($field) || !isset($field['name'])) {
                continue;
            }

            // A multi-file input gives an array of names.
            $raw = is_array($field['name']) ? $field['name'] : array($field['name']);
            foreach ($raw as $one) {
                if (!is_string($one) || $one === '') {
                    continue;
                }
                $names[] = sanitize_text_field(wp_unslash($one));
            }
        }

        return $names;
    }

    /**
     * Reduce a name to the form in which the sanitize_file_name() rewrites
     * cancel out: accents folded and case ignored.
     *
     * That function does two different things and both have to be mirrored, or
     * the two sides still fail to line up:
     *   - whitespace, hyphens, underscores and dots turn into a separator;
     *   - ? [ ] / \ = < > : ; , ' " & $ # * ( ) | ~ ` ! { } % + are dropped
     *     without leaving anything behind.
     *
     * So "TEAK V4" and "TEAK-V4" both become "teak v4", and "foto's, deel #1"
     * and "fotos-deel-1" both become "fotos deel 1".
     *
     * @param string $value
     * @return string
     */
    private static function comparison_key($value)
    {
        $value = remove_accents((string) $value);
        $value = function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value);

        $value = preg_replace('/[\s\-_.]+/u', ' ', $value);
        $value = preg_replace('/[^\p{L}\p{N} ]+/u', '', (string) $value);
        $value = preg_replace('/\s+/u', ' ', (string) $value);

        return trim((string) $value);
    }

    /**
     * Drop the file extension from a base name.
     *
     * @param string $name
     * @return string
     */
    private static function strip_extension($name)
    {
        $name = wp_basename((string) $name);
        if (preg_match('/^(.+)\.[a-zA-Z0-9]+$/', $name, $m)) {
            $name = $m[1];
        }

        return trim($name);
    }

    /**
     * Is this parent post the editor's empty shell rather than a real post?
     *
     * WordPress creates an `auto-draft` row the moment the "new post" screen is
     * opened, with post_title set to its own "Auto Draft" placeholder. An image
     * uploaded before the first save is parented to that shell, so anything we
     * read from it is a placeholder, not content the user wrote.
     *
     * A regular `draft` is NOT this: that one has a title the user typed and is
     * handled like any other parent.
     *
     * @param WP_Post $parent
     * @return bool
     */
    private static function is_unsaved_parent($parent)
    {
        return $parent instanceof WP_Post && $parent->post_status === 'auto-draft';
    }

    /**
     * Turn a file base name into readable words.
     *
     * "zwarte-koffie_02.jpg" → "zwarte koffie 02"
     *
     * @param string $file Relative or absolute file path.
     * @return string
     */
    public static function humanize_filename($file)
    {
        if (!is_string($file) || $file === '') {
            return '';
        }

        $name = self::strip_extension($file);
        $name = str_replace(array('-', '_', '+', '.'), ' ', $name);
        $name = preg_replace('/\s+/u', ' ', $name);

        return trim((string) $name);
    }

    /**
     * Resolve the placeholders for one attachment.
     *
     * Returns plain text: alt attributes and media titles are not HTML, so the
     * result is run through sanitize_text_field().
     *
     * @param string       $template
     * @param WP_Post      $attachment
     * @param WP_Post|null $parent Optional parent post to render against. The
     *                             deferred fill passes the post it just got from
     *                             transition_post_status() so it never depends
     *                             on a cache read landing in the right order.
     * @return string Sanitized text, or '' when nothing usable came out.
     */
    public static function render_template($template, $attachment, $parent = null)
    {
        $template = trim((string) $template);
        if ($template === '' || !$attachment instanceof WP_Post) {
            return '';
        }

        $filename   = self::humanize_filename(get_post_meta($attachment->ID, '_wp_attached_file', true));
        $post_title = '';
        $category   = '';

        $parent_id = (int) $attachment->post_parent;
        if ($parent_id > 0) {
            if (!$parent instanceof WP_Post || (int) $parent->ID !== $parent_id) {
                $parent = get_post($parent_id);
            }

            // An auto-draft is the empty shell the editor creates the moment
            // someone opens "new post": its post_title is still WordPress's own
            // "Auto Draft" placeholder and it has no category yet. Dragging an
            // image into a brand new post is normal working practice, so treat
            // that parent as if there were none — and come back for it in
            // maybe_refill_on_first_save() once the post is real.
            // Match on the status, never on the literal string: "Auto Draft" is
            // translatable and differs per locale.
            if ($parent instanceof WP_Post && !self::is_unsaved_parent($parent)) {
                $post_title = get_the_title($parent);

                // A loose media-library upload has no parent, so it has no
                // category either. Leave it empty rather than guessing.
                $terms = get_the_terms($parent_id, 'category');
                if (is_array($terms) && !empty($terms)) {
                    $first    = reset($terms);
                    $category = isset($first->name) ? (string) $first->name : '';
                }
            }
        }

        $rendered = strtr($template, array(
            '{filename}'   => $filename,
            '{post_title}' => $post_title,
            '{site_name}'  => (string) get_bloginfo('name'),
            '{category}'   => $category,
        ));

        // An unresolved placeholder leaves double spaces, a doubled separator
        // ("a -  - b") or a dangling one behind; tidy those up instead of
        // shipping them. The user's own separator character is kept.
        $rendered = preg_replace('/\s+/u', ' ', $rendered);
        $rendered = preg_replace('/\s*([-–—|,:;])\s*(?:[-–—|,:;]\s*)+/u', ' $1 ', (string) $rendered);
        $rendered = trim((string) $rendered, " \t\n\r\0\x0B-–—|,:;");

        return sanitize_text_field(trim($rendered));
    }
}
