<?php

/**
 * AP Family Archive - Admin
 *
 * The 0.1.0 admin surface: choose the dataset (core tree) the archive
 * publishes, create the archive pages, copy the shortcodes, and control
 * uninstall data handling. Curation workflows (sources/citations editing),
 * GEDCOM import/export UI, the site hub and reports are subsequent work
 * packages and hang off this screen.
 *
 * Every field states what it does and its prerequisites; out-of-order use is
 * guarded (no dataset -> pages/shortcodes sections explain what to do first).
 *
 * @package    APFAAR_Family_Archive
 * @subpackage APFAAR_Family_Archive/admin
 * @since      0.1.0
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Admin screen and form handlers.
 *
 * @since 0.1.0
 */
class APFAAR_Admin {
    /**
     * Capability for SETTINGS surfaces (Dashboard, pages, Most Wanted, saved
     * reports, GEDCOM import/export). Data-entry surfaces use can_edit_data()
     * instead (P7 contributor workflow).
     *
     * @var string
     */
    const CAP = 'manage_options';

    /**
     * May the current user use the DATA tabs (people, families, sources,
     * media, branches, suggestions review)? Administrators always;
     * Archive Editors (the P7 tier below manage_options) only while the extension package is
     * active - the tiers are provided by the separate package and stay dormant without it.
     *
     * @return bool
     */
    public static function can_edit_data() {
        if ( current_user_can( self::CAP ) ) {
            return true;
        }
        // Extension seam: additional packages may grant further roles access
        // to the data tabs. By default only administrators pass.
        return (bool) apply_filters( 'apfaar_user_can_edit_data', false );
    }

    /**
     * Plugin slug.
     *
     * @var string
     */
    private $plugin_name;

    /**
     * Plugin version.
     *
     * @var string
     */
    private $version;

    /**
     * Menu page hook suffix, set on registration.
     *
     * @var string
     */
    private $page_hook = '';

    /**
     * Constructor.
     *
     * @param string $plugin_name Plugin slug.
     * @param string $version     Plugin version.
     */
    public function __construct( $plugin_name, $version ) {
        $this->plugin_name = $plugin_name;
        $this->version = $version;
    }

    /**
     * Register the top-level admin menu.
     *
     * @return void
     */
    public function register_menu() {
        // The official AncestorPress mark (white strokes for the dark sidebar) -
        // the same icon as AP Family Trees so the suite reads as one family.
        $svg_icon = 'data:image/svg+xml;base64,' . base64_encode( '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">' . '<line x1="128" y1="82" x2="128" y2="128" stroke="#fff" stroke-width="14" stroke-linecap="round"/>' . '<line x1="60" y1="128" x2="196" y2="128" stroke="#fff" stroke-width="14" stroke-linecap="round"/>' . '<line x1="60" y1="128" x2="60" y2="174" stroke="#fff" stroke-width="14" stroke-linecap="round"/>' . '<line x1="128" y1="128" x2="128" y2="174" stroke="#fff" stroke-width="14" stroke-linecap="round"/>' . '<line x1="196" y1="128" x2="196" y2="174" stroke="#fff" stroke-width="14" stroke-linecap="round"/>' . '<circle cx="128" cy="56" r="19" fill="none" stroke="#fff" stroke-width="14"/>' . '<circle cx="60" cy="200" r="19" fill="none" stroke="#fff" stroke-width="14"/>' . '<circle cx="128" cy="200" r="19" fill="none" stroke="#fff" stroke-width="14"/>' . '<circle cx="196" cy="200" r="19" fill="none" stroke="#fff" stroke-width="14"/>' . '</svg>' );
        // Position '30.2': directly under AP Family Trees (30) so the suite
        // bunches together in the sidebar. Decimal-string positions cannot
        // collide-and-overwrite the way duplicate integers do.
        // Menu capability: administrators; the filter seam lets additional
        // packages widen it (and self-heal administrator capabilities).
        $menu_cap = (string) apply_filters( 'apfaar_admin_menu_cap', self::CAP );
        $this->page_hook = add_menu_page(
            __( 'AP Family Archive', 'ap-family-archive' ),
            __( 'AP Family Archive', 'ap-family-archive' ),
            $menu_cap,
            'apfaar',
            array($this, 'render_page'),
            $svg_icon,
            '30.2'
        );
    }

    /**
     * Enqueue admin styles on AncestorPress Archive screens.
     *
     * Cache-busted with filemtime so same-version rebuilds never serve stale
     * assets (estate convention).
     *
     * @param  string $hook Current admin page hook.
     * @return void
     */
    public function enqueue_styles( $hook ) {
        $is_ours = $hook === $this->page_hook || false !== strpos( (string) $hook, 'apfaar' );
        if ( !$is_ours ) {
            return;
        }
        // AncestorPress brand system. No remote font request: the stylesheet's
        // font stacks use the brand faces when locally available and fall back
        // to system serif/sans - nothing is fetched from third parties.
        $brand = APFAAR_DIR . 'admin/css/apfa-admin-brand.css';
        wp_enqueue_style(
            'apfa-admin-brand',
            APFAAR_URL . 'admin/css/apfa-admin-brand.css',
            array(),
            ( file_exists( $brand ) ? (string) filemtime( $brand ) : $this->version )
        );
        $css = APFAAR_DIR . 'admin/css/apfa-admin.css';
        wp_enqueue_style(
            'apfa-admin',
            APFAAR_URL . 'admin/css/apfa-admin.css',
            array('apfa-admin-brand'),
            ( file_exists( $css ) ? (string) filemtime( $css ) : $this->version )
        );
    }

    // ── Screen ────────────────────────────────────────────────────────────────
    /**
     * The working tree for data-management tabs: explicit ?tree=, else the
     * published tree, else the first tree in the core.
     *
     * @return string Gedcom identifier ('' when no trees exist).
     */
    public static function current_tree() {
        $tree = '';
        $nonce = ( isset( $_GET['apfaar_tn'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_tn'] ) ) : '' );
        if ( '' !== $nonce && false !== wp_verify_nonce( $nonce, 'apfaar_switch_tree' ) && current_user_can( self::CAP ) ) {
            $tree = ( isset( $_GET['tree'] ) ? sanitize_text_field( wp_unslash( $_GET['tree'] ) ) : '' );
        }
        if ( '' !== $tree && class_exists( 'AncestorPress\\Core\\Trees_Table' ) && \AncestorPress\Core\Trees_Table::get_by_gedcom( $tree ) ) {
            return $tree;
        }
        $published = APFAAR_People::gedcom();
        if ( '' !== $published ) {
            return $published;
        }
        $trees = ( class_exists( 'AncestorPress\\Core\\Trees_Table' ) ? \AncestorPress\Core\Trees_Table::get_all() : array() );
        return ( !empty( $trees ) ? $trees[0]->gedcom : '' );
    }

    /**
     * Render the Family Archive screen (tab router).
     *
     * @return void
     */
    public function render_page() {
        $is_admin_user = current_user_can( self::CAP );
        if ( !$is_admin_user && !self::can_edit_data() ) {
            return;
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view routing.
        $tab = ( isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : (( $is_admin_user ? 'dashboard' : 'people' )) );
        $tabs = array(
            'dashboard' => __( 'Dashboard', 'ap-family-archive' ),
            'people'    => __( 'People', 'ap-family-archive' ),
            'families'  => __( 'Families', 'ap-family-archive' ),
            'sources'   => __( 'Sources', 'ap-family-archive' ),
            'media'     => __( 'Media', 'ap-family-archive' ),
            'branches'  => __( 'Branches', 'ap-family-archive' ),
            'data'      => __( 'Import / Export', 'ap-family-archive' ),
        );
        // Settings surfaces stay administrator-only; editors see data tabs.
        if ( !$is_admin_user ) {
            unset($tabs['dashboard'], $tabs['data']);
        }
        /**
         * Filter the admin screen's tab list.
         *
         * Runs after the administrator-only tabs have been removed, so a listener
         * adding an administrator-only tab must make its own capability check.
         * Pair each added key with an `apfaar_admin_tab_{key}` listener that renders
         * the body.
         *
         * @since 0.3.0
         *
         * @param array $tabs Tab key => translated label.
         */
        $tabs = (array) apply_filters( 'apfaar_admin_tabs', $tabs );
        if ( !isset( $tabs[$tab] ) ) {
            $tab = ( $is_admin_user ? 'dashboard' : 'people' );
        }
        echo '<div class="wrap apfa-admin">';
        require APFAAR_DIR . 'admin/partials/apfa-admin-page-header.php';
        echo '<h1>' . esc_html__( 'Family Archive', 'ap-family-archive' ) . '</h1>';
        echo '<nav class="nav-tab-wrapper">';
        foreach ( $tabs as $key => $label ) {
            $url = add_query_arg( array(
                'page' => 'apfaar',
                'tab'  => $key,
            ), admin_url( 'admin.php' ) );
            $class = ( $key === $tab ? ' nav-tab-active' : '' );
            echo '<a class="nav-tab' . esc_attr( $class ) . '" href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a>';
        }
        echo '</nav><div class="apfa-tab-content">';
        $this->render_notices();
        if ( 'people' === $tab || 'families' === $tab || 'sources' === $tab || 'media' === $tab || 'branches' === $tab || 'suggestions' === $tab ) {
            $working = self::current_tree();
            if ( '' === $working ) {
                echo '<div class="apfa-notice-amber">' . esc_html__( 'No trees exist yet. Import a GEDCOM on the Import / Export tab, or add a tree by importing in AP Family Trees - then manage its people here.', 'ap-family-archive' ) . '</div>';
            } else {
                $this->render_tree_switcher( $tab, $working );
                // Suggestions review (P7) - dispatched by the separate package
                // ($tab is only ever 'suggestions' when it is installed,
                // where the tab exists).
                $dispatched = false;
                if ( !$dispatched ) {
                    if ( 'people' === $tab ) {
                        APFAAR_Admin_People::render( $working );
                    } elseif ( 'sources' === $tab ) {
                        APFAAR_Admin_Sources::render( $working );
                    } elseif ( 'media' === $tab ) {
                        APFAAR_Admin_Media::render( $working );
                    } elseif ( 'branches' === $tab ) {
                        APFAAR_Admin_Branches::render( $working );
                    } else {
                        APFAAR_Admin_Families::render( $working );
                    }
                }
            }
            echo '</div></div>';
            return;
        }
        if ( 'data' === $tab ) {
            APFAAR_Admin_Data::render();
            echo '</div></div>';
            return;
        }
        // A tab contributed through apfaar_admin_tabs renders its own body. Checked
        // before the dashboard fallback so a contributed tab is not swallowed by it.
        if ( 'dashboard' !== $tab && has_action( 'apfaar_admin_tab_' . $tab ) ) {
            /**
             * Render the body of a tab added through the apfaar_admin_tabs filter.
             *
             * @since 0.3.0
             */
            do_action( 'apfaar_admin_tab_' . $tab );
            echo '</div></div>';
            return;
        }
        $this->render_dashboard();
        echo '</div></div>';
    }

    /**
     * Working-tree switcher for the data-management tabs.
     *
     * @param  string $tab     Current tab key.
     * @param  string $working Current working tree.
     * @return void
     */
    private function render_tree_switcher( $tab, $working ) {
        $trees = \AncestorPress\Core\Trees_Table::get_all();
        if ( count( $trees ) < 2 ) {
            return;
        }
        echo '<form method="get" class="apfa-filter-bar" style="margin-bottom:14px">';
        echo '<input type="hidden" name="page" value="apfaar" /><input type="hidden" name="tab" value="' . esc_attr( $tab ) . '" />';
        wp_nonce_field( 'apfaar_switch_tree', 'apfaar_tn', false );
        echo '<label>' . esc_html__( 'Working tree', 'ap-family-archive' ) . ' <select name="tree" onchange="this.form.submit()">';
        foreach ( $trees as $tree ) {
            $label = ( '' !== (string) $tree->treename ? $tree->treename : $tree->gedcom );
            echo '<option value="' . esc_attr( $tree->gedcom ) . '"' . selected( $working, $tree->gedcom, false ) . '>' . esc_html( $label ) . '</option>';
        }
        echo '</select></label> <noscript>';
        submit_button(
            __( 'Switch', 'ap-family-archive' ),
            'secondary',
            'submit',
            false
        );
        echo '</noscript>';
        echo '<span class="apfa-filter-count">' . esc_html__( 'Edits here change the shared data core - AP Family Trees sees them instantly.', 'ap-family-archive' ) . '</span>';
        echo '</form>';
    }

    /**
     * Render the Dashboard tab (settings, stats, pages, Most Wanted).
     *
     * @return void
     */
    private function render_dashboard() {
        $gedcom = APFAAR_People::gedcom();
        $trees = ( class_exists( 'AncestorPress\\Core\\Trees_Table' ) ? \AncestorPress\Core\Trees_Table::get_all() : array() );
        // ── Dataset ───────────────────────────────────────────────────────────
        echo '<div class="apfa-section-card">';
        echo '<h2 class="apfa-options-section-title">' . esc_html__( 'Dataset', 'ap-family-archive' ) . '</h2>';
        echo '<p class="description">' . esc_html__( 'The archive publishes ONE tree from the shared AncestorPress data core. Trees come from a GEDCOM import - use the Import / Export tab here, or AP Family Trees; both feed the same data core. Changing the tree changes every public page immediately.', 'ap-family-archive' ) . '</p>';
        if ( empty( $trees ) ) {
            echo '<div class="apfa-notice-amber">' . esc_html__( 'No trees exist in the data core yet. Import a GEDCOM first (AP Family Trees > Import, if installed) - the shared core means it appears here with no re-import.', 'ap-family-archive' ) . '</div>';
        } else {
            echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
            wp_nonce_field( 'apfaar_save_settings' );
            echo '<input type="hidden" name="action" value="apfaar_save_settings" />';
            echo '<div class="apfa-field-group"><label class="apfa-field-label" for="apfaar_gedcom">' . esc_html__( 'Tree to publish', 'ap-family-archive' ) . '</label>';
            echo '<select id="apfaar_gedcom" name="apfaar_gedcom" class="apfa-field-input">';
            echo '<option value="">' . esc_html__( '- none (archive offline) -', 'ap-family-archive' ) . '</option>';
            foreach ( $trees as $tree ) {
                $label = ( '' !== (string) $tree->treename ? $tree->treename : $tree->gedcom );
                echo '<option value="' . esc_attr( $tree->gedcom ) . '"' . selected( $gedcom, $tree->gedcom, false ) . '>' . esc_html( $label ) . ' (' . esc_html( $tree->gedcom ) . ')</option>';
            }
            echo '</select>';
            echo '<p class="apfa-field-desc">' . esc_html__( 'With no tree selected, every archive shortcode renders nothing for visitors (administrators see a hint instead).', 'ap-family-archive' ) . '</p></div>';
            // Public display style (the APON precedent).
            $style = APFAAR_Public::display_style();
            echo '<div class="apfa-field-group"><label class="apfa-field-label" for="apfaar_display_style">' . esc_html__( 'Public display style', 'ap-family-archive' ) . '</label>';
            echo '<select id="apfaar_display_style" name="apfaar_display_style" class="apfa-field-input">';
            echo '<option value="heritage"' . selected( $style, 'heritage', false ) . '>' . esc_html__( 'Heritage - serif headings, warm paper tones, matches AP Family Trees', 'ap-family-archive' ) . '</option>';
            echo '<option value="none"' . selected( $style, 'none', false ) . '>' . esc_html__( 'Theme default - minimal plugin styling; your theme takes over', 'ap-family-archive' ) . '</option>';
            echo '</select>';
            echo '<p class="apfa-field-desc">' . esc_html__( 'How every public archive page looks. Change it any time - it restyles all pages at once. "Theme default" keeps only the structure (layout, thumbnails, print rules) and leaves every visual decision to your theme. Further styles (Modern) arrive with the Pro tier. Printing any archive page automatically uses a clean, ink-friendly layout in both styles.', 'ap-family-archive' ) . '</p></div>';
            // Hub intro text.
            echo '<div class="apfa-field-group"><label class="apfa-field-label" for="apfaar_hub_intro">' . esc_html__( 'Hub page introduction', 'ap-family-archive' ) . '</label>';
            echo '<textarea id="apfaar_hub_intro" name="apfaar_hub_intro" class="apfa-field-input" rows="4">' . esc_textarea( (string) get_option( 'apfaar_hub_intro', '' ) ) . '</textarea>';
            echo '<p class="apfa-field-desc">' . esc_html__( 'Shown at the top of the [apfaar_hub] page - welcome your visitors and say whose history this is. Basic HTML allowed.', 'ap-family-archive' ) . '</p></div>';
            // What's-new window.
            echo '<div class="apfa-field-group"><label class="apfa-field-label">' . esc_html__( "What's New window", 'ap-family-archive' ) . '</label>';
            echo '<label>' . esc_html__( 'Consider items new for', 'ap-family-archive' ) . ' <input type="number" name="apfaar_whatsnew_days" min="1" max="365" value="' . esc_attr( (int) get_option( 'apfaar_whatsnew_days', 30 ) ) . '" /> ' . esc_html__( 'days, showing at most', 'ap-family-archive' ) . ' <input type="number" name="apfaar_whatsnew_max" min="1" max="100" value="' . esc_attr( (int) get_option( 'apfaar_whatsnew_max', 10 ) ) . '" /> ' . esc_html__( 'items per category.', 'ap-family-archive' ) . '</label>';
            echo '<p class="apfa-field-desc">' . esc_html__( 'Controls the [apfaar_whatsnew] page and the hub teaser. The page updates itself - no maintenance needed.', 'ap-family-archive' ) . '</p></div>';
            // Uninstall data option lives in the same form.
            $delete_on_uninstall = (bool) get_option( 'apfaar_delete_data_on_uninstall' );
            echo '<label class="apfa-check-label"><input type="checkbox" name="apfaar_delete_data_on_uninstall" value="1"' . checked( $delete_on_uninstall, true, false ) . ' /> ' . esc_html__( 'On uninstall, delete this plugin\'s own settings and pages mapping. Core genealogical data is NEVER deleted by this plugin - it is collectively owned by the AncestorPress suite.', 'ap-family-archive' ) . '</label>';
            submit_button( __( 'Save settings', 'ap-family-archive' ) );
            echo '</form>';
        }
        echo '</div>';
        // ── Stats ─────────────────────────────────────────────────────────────
        if ( '' !== $gedcom ) {
            $stats = APFAAR_People::stats( $gedcom );
            echo '<div class="apfa-section-card"><h2 class="apfa-options-section-title">' . esc_html__( 'Published archive', 'ap-family-archive' ) . '</h2>';
            echo '<div class="apfa-migration-stat-grid">';
            foreach ( array(
                'people'   => __( 'People', 'ap-family-archive' ),
                'families' => __( 'Families', 'ap-family-archive' ),
                'surnames' => __( 'Surnames', 'ap-family-archive' ),
            ) as $key => $label ) {
                echo '<div class="apfa-migration-stat"><span class="n">' . esc_html( number_format_i18n( $stats[$key] ) ) . '</span><span class="l">' . esc_html( $label ) . '</span></div>';
            }
            echo '</div>';
            echo '<p class="description">' . esc_html__( 'Living and private people are counted here but never shown to public visitors (the suite privacy floor - it cannot be switched off, only refined by AP Access later).', 'ap-family-archive' ) . '</p>';
            echo '</div>';
        }
        // ── Pages & shortcodes ────────────────────────────────────────────────
        echo '<div class="apfa-section-card">';
        echo '<h2 class="apfa-options-section-title">' . esc_html__( 'Archive pages', 'ap-family-archive' ) . '</h2>';
        if ( '' === $gedcom ) {
            echo '<p class="description">' . esc_html__( 'Select a tree above first - the pages are useful once there is something to publish.', 'ap-family-archive' ) . '</p>';
        } else {
            echo '<p class="description">' . esc_html__( 'Each archive view is a shortcode on an ordinary page, so it works with any theme. "Make the pages for me" creates the four pages below as drafts (existing mappings are kept); review and publish them when ready.', 'ap-family-archive' ) . '</p>';
            $mapping = self::page_map();
            foreach ( $mapping as $key => $info ) {
                $page_id = absint( get_option( 'apfaar_page_' . $key, 0 ) );
                $status = ( $page_id && get_post( $page_id ) ? '<a href="' . esc_url( get_edit_post_link( $page_id ) ) . '">' . esc_html( get_the_title( $page_id ) ) . '</a> (' . esc_html( get_post_status( $page_id ) ) . ')' : '<em>' . esc_html__( 'not created yet', 'ap-family-archive' ) . '</em>' );
                echo '<div class="apfa-shortcode-row"><span class="apfa-shortcode-label">' . esc_html( $info[0] ) . '</span>';
                echo '<span class="apfa-shortcode-field"><input class="apfa-shortcode-input" type="text" readonly value="' . esc_attr( $info[1] ) . '" onfocus="this.select()" /> ' . wp_kses_post( $status ) . '</span></div>';
            }
            echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="margin-top:12px">';
            wp_nonce_field( 'apfaar_create_pages' );
            echo '<input type="hidden" name="action" value="apfaar_create_pages" />';
            submit_button(
                __( 'Make the pages for me', 'ap-family-archive' ),
                'secondary',
                'submit',
                false
            );
            echo '</form>';
        }
        echo '</div>';
        // ── Most Wanted ───────────────────────────────────────────────────────
        if ( '' !== $gedcom ) {
            $this->render_most_wanted( $gedcom );
        }
    }

    /**
     * The archive page map: key => (label, shortcode, page title).
     *
     * Single source for the mapping list, "Make the pages for me" and
     * uninstall cleanup (which keeps its own copy - update both).
     *
     * @return array
     */
    public static function page_map() {
        $map = array(
            'hub'           => array(__( 'Site hub (landing page)', 'ap-family-archive' ), '[apfaar_hub]', __( 'Family history', 'ap-family-archive' )),
            'surnames'      => array(__( 'Surname index / A-Z browse', 'ap-family-archive' ), '[apfaar_surnames]', __( 'Surnames', 'ap-family-archive' )),
            'search'        => array(__( 'Search (name, dates, place)', 'ap-family-archive' ), '[apfaar_search]', __( 'Search the archive', 'ap-family-archive' )),
            'person'        => array(__( 'Person page', 'ap-family-archive' ), '[apfaar_person]', __( 'Person', 'ap-family-archive' )),
            'family'        => array(__( 'Family group page', 'ap-family-archive' ), '[apfaar_family]', __( 'Family', 'ap-family-archive' )),
            'places'        => array(__( 'Place browse', 'ap-family-archive' ), '[apfaar_places]', __( 'Places', 'ap-family-archive' )),
            'anniversaries' => array(__( 'Dates & anniversaries', 'ap-family-archive' ), '[apfaar_anniversaries]', __( 'Anniversaries', 'ap-family-archive' )),
            'whatsnew'      => array(__( "What's New", 'ap-family-archive' ), '[apfaar_whatsnew]', __( "What's new", 'ap-family-archive' )),
            'mostwanted'    => array(__( 'Most Wanted', 'ap-family-archive' ), '[apfaar_mostwanted]', __( 'Most wanted', 'ap-family-archive' )),
            'statistics'    => array(__( 'Statistics', 'ap-family-archive' ), '[apfaar_statistics]', __( 'Statistics', 'ap-family-archive' )),
            'ahnentafel'    => array(__( 'Ahnentafel report', 'ap-family-archive' ), '[apfaar_ahnentafel]', __( 'Ahnentafel', 'ap-family-archive' )),
            'descendants'   => array(__( 'Descendants report', 'ap-family-archive' ), '[apfaar_descendants]', __( 'Descendants', 'ap-family-archive' )),
            'source'        => array(__( 'Source detail page', 'ap-family-archive' ), '[apfaar_source]', __( 'Source', 'ap-family-archive' )),
        );
        return $map;
    }

    /**
     * Most Wanted manager: current entries + add form.
     *
     * @param  string $gedcom Selected tree.
     * @return void
     */
    private function render_most_wanted( $gedcom ) {
        echo '<div class="apfa-section-card">';
        echo '<h2 class="apfa-options-section-title">' . esc_html__( 'Most Wanted', 'ap-family-archive' ) . '</h2>';
        echo '<p class="description">' . esc_html__( 'A curated "help us find out more" list for the [apfaar_mostwanted] page. Add a person by their record ID (the I-number shown on their person page) with a note about what you are trying to discover. Living or private people are never shown publicly even if added here.', 'ap-family-archive' ) . '</p>';
        $entries = get_option( 'apfaar_most_wanted', array() );
        if ( is_array( $entries ) && !empty( $entries ) ) {
            echo '<table class="widefat striped"><thead><tr><th>' . esc_html__( 'Person', 'ap-family-archive' ) . '</th><th>' . esc_html__( 'Note', 'ap-family-archive' ) . '</th><th></th></tr></thead><tbody>';
            foreach ( $entries as $i => $entry ) {
                $pid = ( isset( $entry['pid'] ) ? (string) $entry['pid'] : '' );
                $person = APFAAR_People::get_person_raw( $gedcom, $pid );
                $name = ( $person ? APFAAR_People::display_name( $person ) : __( '(not found in this tree)', 'ap-family-archive' ) );
                $url = wp_nonce_url( admin_url( 'admin-post.php?action=apfaar_mw_delete&index=' . (int) $i ), 'apfaar_mw_delete_' . (int) $i );
                echo '<tr><td>' . esc_html( $name ) . ' <span class="apfa-gedcom-ref">' . esc_html( $pid ) . '</span></td>';
                echo '<td>' . esc_html( (string) ($entry['note'] ?? '') ) . '</td>';
                echo '<td><a class="apfa-action-delete" href="' . esc_url( $url ) . '">' . esc_html__( 'Remove', 'ap-family-archive' ) . '</a></td></tr>';
            }
            echo '</tbody></table>';
        }
        echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="margin-top:12px">';
        wp_nonce_field( 'apfaar_mw_add' );
        echo '<input type="hidden" name="action" value="apfaar_mw_add" />';
        echo '<label>' . esc_html__( 'Person ID', 'ap-family-archive' ) . ' <input type="text" name="apfaar_mw_pid" placeholder="I123" required /></label> ';
        echo '<label>' . esc_html__( 'Note', 'ap-family-archive' ) . ' <input type="text" name="apfaar_mw_note" size="50" placeholder="' . esc_attr__( 'e.g. Where was she between the 1861 and 1871 censuses?', 'ap-family-archive' ) . '" /></label> ';
        submit_button(
            __( 'Add to Most Wanted', 'ap-family-archive' ),
            'secondary',
            'submit',
            false
        );
        echo '</form></div>';
    }

    /**
     * Success/error notices for this screen's own actions.
     *
     * @return void
     */
    private function render_notices() {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only flag set by our own redirects.
        $done = ( isset( $_GET['apfaar_done'] ) ? sanitize_key( wp_unslash( $_GET['apfaar_done'] ) ) : '' );
        if ( 'saved' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Settings saved.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'pages' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Archive pages created as drafts (existing pages kept). Review and publish them from Pages.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'mw' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Most Wanted list updated.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'person_saved' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Person saved.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'person_deleted' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Person and all their links deleted.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'family_saved' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Family saved.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'family_deleted' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Family deleted (people kept).', 'ap-family-archive' ) . '</div>';
        } elseif ( 'child' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Children updated.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'source_saved' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Saved.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'source_deleted' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Deleted.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'cite' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Citations updated.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'media_saved' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Media saved.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'media_deleted' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Media item and its links deleted (files stay in the media library).', 'ap-family-archive' ) . '</div>';
        } elseif ( 'media_link' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Media links updated.', 'ap-family-archive' ) . '</div>';
        } elseif ( 'branch_deleted' === $done ) {
            echo '<div class="apfa-notice-verdant">' . esc_html__( 'Branch deleted and its labels removed (people and families untouched).', 'ap-family-archive' ) . '</div>';
        }
        // Detailed one-shot messages (import results, validation warnings).
        $flash = get_transient( 'apfaar_notice_' . get_current_user_id() );
        if ( $flash && is_array( $flash ) ) {
            delete_transient( 'apfaar_notice_' . get_current_user_id() );
            $class = ( !empty( $flash['ok'] ) ? 'apfa-notice-verdant' : 'apfa-notice-amber' );
            echo '<div class="' . esc_attr( $class ) . '">' . esc_html( (string) $flash['message'] ) . '</div>';
        }
    }

    /**
     * Stash a one-shot notice for the next screen render.
     *
     * @param  bool   $ok      Success or warning.
     * @param  string $message The message.
     * @return void
     */
    private function flash( $ok, $message ) {
        set_transient( 'apfaar_notice_' . get_current_user_id(), array(
            'ok'      => $ok,
            'message' => $message,
        ), 60 );
    }

    // ── Handlers ──────────────────────────────────────────────────────────────
    /**
     * Save settings (dataset + uninstall option).
     *
     * @return void
     */
    public function handle_save_settings() {
        if ( !current_user_can( self::CAP ) ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_save_settings' );
        $gedcom = ( isset( $_POST['apfaar_gedcom'] ) ? sanitize_text_field( wp_unslash( $_POST['apfaar_gedcom'] ) ) : '' );
        // Accept only an existing tree (or the explicit offline choice).
        if ( '' !== $gedcom && class_exists( 'AncestorPress\\Core\\Trees_Table' ) && !\AncestorPress\Core\Trees_Table::get_by_gedcom( $gedcom ) ) {
            $gedcom = '';
        }
        update_option( 'apfaar_gedcom', $gedcom );
        update_option( 'apfaar_delete_data_on_uninstall', ( empty( $_POST['apfaar_delete_data_on_uninstall'] ) ? 0 : 1 ) );
        if ( isset( $_POST['apfaar_display_style'] ) ) {
            // 'modern' is accepted by the extension package's save path;
            // here anything unrecognised falls back to Heritage.
            $style = sanitize_key( wp_unslash( $_POST['apfaar_display_style'] ) );
            update_option( 'apfaar_display_style', ( 'none' === $style ? 'none' : 'heritage' ) );
        }
        if ( isset( $_POST['apfaar_hub_intro'] ) ) {
            update_option( 'apfaar_hub_intro', wp_kses_post( wp_unslash( $_POST['apfaar_hub_intro'] ) ) );
        }
        if ( isset( $_POST['apfaar_whatsnew_days'] ) ) {
            update_option( 'apfaar_whatsnew_days', min( 365, max( 1, absint( $_POST['apfaar_whatsnew_days'] ) ) ) );
        }
        if ( isset( $_POST['apfaar_whatsnew_max'] ) ) {
            update_option( 'apfaar_whatsnew_max', min( 100, max( 1, absint( $_POST['apfaar_whatsnew_max'] ) ) ) );
        }
        wp_safe_redirect( add_query_arg( 'apfaar_done', 'saved', admin_url( 'admin.php?page=apfaar' ) ) );
        exit;
    }

    /**
     * Create the four archive pages as drafts and store the mapping.
     *
     * Idempotent: an existing mapped page is kept, never duplicated.
     *
     * @return void
     */
    public function handle_create_pages() {
        if ( !current_user_can( self::CAP ) ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_create_pages' );
        foreach ( self::page_map() as $key => $info ) {
            $existing = absint( get_option( 'apfaar_page_' . $key, 0 ) );
            if ( $existing && get_post( $existing ) ) {
                continue;
            }
            $page_id = wp_insert_post( array(
                'post_title'   => $info[2],
                'post_content' => $info[1],
                'post_status'  => 'draft',
                'post_type'    => 'page',
            ) );
            if ( $page_id && !is_wp_error( $page_id ) ) {
                update_option( 'apfaar_page_' . $key, (int) $page_id );
            }
        }
        wp_safe_redirect( add_query_arg( 'apfaar_done', 'pages', admin_url( 'admin.php?page=apfaar' ) ) );
        exit;
    }

    /**
     * Add a Most Wanted entry.
     *
     * @return void
     */
    public function handle_mw_add() {
        if ( !current_user_can( self::CAP ) ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_mw_add' );
        $pid = ( isset( $_POST['apfaar_mw_pid'] ) ? sanitize_text_field( wp_unslash( $_POST['apfaar_mw_pid'] ) ) : '' );
        $note = ( isset( $_POST['apfaar_mw_note'] ) ? sanitize_text_field( wp_unslash( $_POST['apfaar_mw_note'] ) ) : '' );
        if ( '' !== $pid ) {
            $entries = get_option( 'apfaar_most_wanted', array() );
            $entries = ( is_array( $entries ) ? $entries : array() );
            $entries[] = array(
                'pid'  => $pid,
                'note' => $note,
            );
            update_option( 'apfaar_most_wanted', array_values( $entries ) );
        }
        wp_safe_redirect( add_query_arg( 'apfaar_done', 'mw', admin_url( 'admin.php?page=apfaar' ) ) );
        exit;
    }

    /**
     * Remove a Most Wanted entry by index.
     *
     * @return void
     */
    public function handle_mw_delete() {
        if ( !current_user_can( self::CAP ) ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        $index = ( isset( $_GET['index'] ) ? absint( $_GET['index'] ) : 0 );
        check_admin_referer( 'apfaar_mw_delete_' . $index );
        $entries = get_option( 'apfaar_most_wanted', array() );
        if ( is_array( $entries ) && isset( $entries[$index] ) ) {
            unset($entries[$index]);
            update_option( 'apfaar_most_wanted', array_values( $entries ) );
        }
        wp_safe_redirect( add_query_arg( 'apfaar_done', 'mw', admin_url( 'admin.php?page=apfaar' ) ) );
        exit;
    }

    // ── Data-management endpoints (delegate to the tab classes) ───────────────
    /**
     * Working tree from a request, validated against the core.
     *
     * @param  string $method GET|POST.
     * @return string         Gedcom identifier or '' (invalid).
     */
    private function request_tree( $method = 'POST' ) {
        // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- callers verify nonces.
        $raw = ( 'GET' === $method ? ( isset( $_GET['tree'] ) ? sanitize_text_field( wp_unslash( $_GET['tree'] ) ) : '' ) : (( isset( $_POST['tree'] ) ? sanitize_text_field( wp_unslash( $_POST['tree'] ) ) : '' )) );
        // phpcs:enable
        if ( '' === $raw || !class_exists( 'AncestorPress\\Core\\Trees_Table' ) || !\AncestorPress\Core\Trees_Table::get_by_gedcom( $raw ) ) {
            return '';
        }
        return $raw;
    }

    /**
     * Redirect back to a data tab with a completion flag.
     *
     * @param  string $tab    Tab key.
     * @param  string $tree   Working tree.
     * @param  string $done   Notice flag.
     * @param  array  $extra  Extra args (e.g. reopen an edit form).
     * @return void
     */
    private function back_to(
        $tab,
        $tree,
        $done,
        $extra = array()
    ) {
        $args = array_merge( array(
            'page'        => 'apfaar',
            'tab'         => $tab,
            'tree'        => rawurlencode( $tree ),
            'apfaar_tn'   => wp_create_nonce( 'apfaar_switch_tree' ),
            'apfaar_done' => $done,
        ), $extra );
        wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
        exit;
    }

    /**
     * Save a person (add or edit).
     *
     * @return void
     */
    public function handle_person_save() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_person_save' );
        $tree = $this->request_tree();
        if ( '' === $tree ) {
            wp_die( esc_html__( 'Unknown tree.', 'ap-family-archive' ) );
        }
        $pid = APFAAR_Admin_People::save( $tree );
        $this->back_to(
            'people',
            $tree,
            'person_saved',
            array(
                'apfaar_action' => 'edit',
                'apfaar_pid'    => rawurlencode( $pid ),
            )
        );
    }

    /**
     * Delete a person (cascade).
     *
     * @return void
     */
    public function handle_person_delete() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $pid = ( isset( $_GET['apfaar_pid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_pid'] ) ) : '' );
        check_admin_referer( 'apfaar_person_delete_' . $pid );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $pid ) {
            APFAAR_Admin_People::delete( $tree, $pid );
        }
        $this->back_to( 'people', $tree, 'person_deleted' );
    }

    /**
     * Save a family (add or edit).
     *
     * @return void
     */
    public function handle_family_save() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_family_save' );
        $tree = $this->request_tree();
        if ( '' === $tree ) {
            wp_die( esc_html__( 'Unknown tree.', 'ap-family-archive' ) );
        }
        $result = APFAAR_Admin_Families::save( $tree );
        if ( !empty( $result['errors'] ) ) {
            $this->flash( false, implode( ' ', $result['errors'] ) );
        }
        $this->back_to(
            'families',
            $tree,
            'family_saved',
            array(
                'apfaar_action' => 'edit',
                'apfaar_fid'    => rawurlencode( $result['fid'] ),
            )
        );
    }

    /**
     * Delete a family (cascade; people kept).
     *
     * @return void
     */
    public function handle_family_delete() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $fid = ( isset( $_GET['apfaar_fid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_fid'] ) ) : '' );
        check_admin_referer( 'apfaar_family_delete_' . $fid );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $fid ) {
            APFAAR_Admin_Families::delete( $tree, $fid );
        }
        $this->back_to( 'families', $tree, 'family_deleted' );
    }

    /**
     * Link a child to a family.
     *
     * @return void
     */
    public function handle_child_add() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_child_add' );
        $tree = $this->request_tree();
        // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified above.
        $fid = ( isset( $_POST['apfaar_fid'] ) ? sanitize_text_field( wp_unslash( $_POST['apfaar_fid'] ) ) : '' );
        $pid = ( isset( $_POST['apfaar_pid'] ) ? sanitize_text_field( wp_unslash( $_POST['apfaar_pid'] ) ) : '' );
        // phpcs:enable
        if ( '' !== $tree && '' !== $fid && '' !== $pid ) {
            $error = APFAAR_Admin_Families::add_child( $tree, $fid, $pid );
            if ( $error ) {
                $this->flash( false, $error );
            }
        }
        $this->back_to(
            'families',
            $tree,
            'child',
            array(
                'apfaar_action' => 'edit',
                'apfaar_fid'    => rawurlencode( $fid ),
            )
        );
    }

    /**
     * Unlink a child from a family.
     *
     * @return void
     */
    public function handle_child_remove() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- verified below.
        $fid = ( isset( $_GET['apfaar_fid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_fid'] ) ) : '' );
        $pid = ( isset( $_GET['apfaar_pid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_pid'] ) ) : '' );
        // phpcs:enable
        check_admin_referer( 'apfaar_child_remove_' . $fid . '_' . $pid );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $fid && '' !== $pid ) {
            APFAAR_Admin_Families::remove_child( $tree, $fid, $pid );
        }
        $this->back_to(
            'families',
            $tree,
            'child',
            array(
                'apfaar_action' => 'edit',
                'apfaar_fid'    => rawurlencode( $fid ),
            )
        );
    }

    /**
     * Save a source (add or edit).
     *
     * @return void
     */
    public function handle_source_save() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_source_save' );
        $tree = $this->request_tree();
        if ( '' === $tree ) {
            wp_die( esc_html__( 'Unknown tree.', 'ap-family-archive' ) );
        }
        $sid = APFAAR_Admin_Sources::save_source( $tree );
        $this->back_to(
            'sources',
            $tree,
            'source_saved',
            array(
                'apfaar_action' => 'source_edit',
                'apfaar_sid'    => rawurlencode( $sid ),
            )
        );
    }

    /**
     * Delete a source and its citations.
     *
     * @return void
     */
    public function handle_source_delete() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $sid = ( isset( $_GET['apfaar_sid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_sid'] ) ) : '' );
        check_admin_referer( 'apfaar_source_delete_' . $sid );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $sid ) {
            APFAAR_Admin_Sources::delete_source( $tree, $sid );
        }
        $this->back_to( 'sources', $tree, 'source_deleted' );
    }

    /**
     * Save a repository (add or edit).
     *
     * @return void
     */
    public function handle_repo_save() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_repo_save' );
        $tree = $this->request_tree();
        if ( '' === $tree ) {
            wp_die( esc_html__( 'Unknown tree.', 'ap-family-archive' ) );
        }
        APFAAR_Admin_Sources::save_repo( $tree );
        $this->back_to( 'sources', $tree, 'source_saved' );
    }

    /**
     * Delete a repository.
     *
     * @return void
     */
    public function handle_repo_delete() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $rid = ( isset( $_GET['apfaar_rid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_rid'] ) ) : '' );
        check_admin_referer( 'apfaar_repo_delete_' . $rid );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $rid ) {
            APFAAR_Admin_Sources::delete_repo( $tree, $rid );
        }
        $this->back_to( 'sources', $tree, 'source_deleted' );
    }

    /**
     * Redirect back to the record whose citations changed.
     *
     * @param  string $tree Working tree.
     * @return void
     */
    private function back_to_cited_record( $tree ) {
        // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- callers verified nonces.
        $back = ( isset( $_REQUEST['back'] ) && 'families' === $_REQUEST['back'] ? 'families' : 'people' );
        $ref = ( isset( $_REQUEST['ref'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ref'] ) ) : '' );
        // phpcs:enable
        $args = ( 'families' === $back ? array(
            'apfaar_action' => 'edit',
            'apfaar_fid'    => rawurlencode( $ref ),
        ) : array(
            'apfaar_action' => 'edit',
            'apfaar_pid'    => rawurlencode( $ref ),
        ) );
        $this->back_to(
            $back,
            $tree,
            'cite',
            $args
        );
    }

    /**
     * Add a citation to a person/family.
     *
     * @return void
     */
    public function handle_cite_add() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_cite_add' );
        $tree = $this->request_tree();
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
        $ref = ( isset( $_POST['ref'] ) ? sanitize_text_field( wp_unslash( $_POST['ref'] ) ) : '' );
        if ( '' !== $tree && '' !== $ref ) {
            $error = APFAAR_Admin_Sources::add_citation( $tree, $ref );
            if ( $error ) {
                $this->flash( false, $error );
            }
        }
        $this->back_to_cited_record( $tree );
    }

    /**
     * Remove a citation by row id.
     *
     * @return void
     */
    public function handle_cite_delete() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $cite_id = ( isset( $_GET['cite'] ) ? absint( $_GET['cite'] ) : 0 );
        check_admin_referer( 'apfaar_cite_delete_' . $cite_id );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && $cite_id && class_exists( 'AncestorPress\\Core\\Citations_Table' ) ) {
            // Tree-scoped delete (core 0.23.0): a manipulated row ID cannot
            // remove a citation belonging to a different tree.
            \AncestorPress\Core\Citations_Table::delete( $cite_id, $tree );
        }
        $this->back_to_cited_record( $tree );
    }

    /**
     * Save a media item (add or edit, including upload).
     *
     * @return void
     */
    public function handle_media_save() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_media_save' );
        $tree = $this->request_tree();
        if ( '' === $tree ) {
            wp_die( esc_html__( 'Unknown tree.', 'ap-family-archive' ) );
        }
        $result = APFAAR_Admin_Media::save( $tree );
        if ( null !== $result['error'] ) {
            $this->flash( false, $result['error'] );
            $this->back_to( 'media', $tree, '' );
        }
        $this->back_to(
            'media',
            $tree,
            'media_saved',
            array(
                'apfaar_action' => 'media_edit',
                'apfaar_mid'    => rawurlencode( $result['mid'] ),
            )
        );
    }

    /**
     * Delete a media item (cascade to its links).
     *
     * @return void
     */
    public function handle_media_delete() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $mid = ( isset( $_GET['apfaar_mid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_mid'] ) ) : '' );
        check_admin_referer( 'apfaar_media_delete_' . $mid );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $mid ) {
            APFAAR_Admin_Media::delete( $tree, $mid );
        }
        $this->back_to( 'media', $tree, 'media_deleted' );
    }

    /**
     * Link a media item to a person/family.
     *
     * @return void
     */
    public function handle_media_link() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_media_link' );
        $tree = $this->request_tree();
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
        $mid = ( isset( $_POST['apfaar_mid'] ) ? sanitize_text_field( wp_unslash( $_POST['apfaar_mid'] ) ) : '' );
        if ( '' !== $tree && '' !== $mid ) {
            $error = APFAAR_Admin_Media::add_link( $tree, $mid );
            if ( $error ) {
                $this->flash( false, $error );
            }
        }
        $this->back_to(
            'media',
            $tree,
            'media_link',
            array(
                'apfaar_action' => 'media_edit',
                'apfaar_mid'    => rawurlencode( $mid ),
            )
        );
    }

    /**
     * Unlink a media item from a record (by link row id).
     *
     * @return void
     */
    public function handle_media_unlink() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- verified below.
        $link_id = ( isset( $_GET['link'] ) ? absint( $_GET['link'] ) : 0 );
        $mid = ( isset( $_GET['apfaar_mid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_mid'] ) ) : '' );
        // phpcs:enable
        check_admin_referer( 'apfaar_media_unlink_' . $link_id );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && $link_id ) {
            // Tree-scoped unlink (core 0.23.0): a manipulated link ID cannot
            // detach media belonging to a different tree.
            \AncestorPress\Core\Media::unlink( $link_id, $tree );
        }
        $this->back_to(
            'media',
            $tree,
            'media_link',
            array(
                'apfaar_action' => 'media_edit',
                'apfaar_mid'    => rawurlencode( $mid ),
            )
        );
    }

    /**
     * Make a media item a record's profile/default photo.
     *
     * @return void
     */
    public function handle_media_defphoto() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- verified below.
        $mid = ( isset( $_GET['apfaar_mid'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_mid'] ) ) : '' );
        $linktype = ( isset( $_GET['linktype'] ) && 'family' === $_GET['linktype'] ? 'family' : 'person' );
        $ref = ( isset( $_GET['ref'] ) ? sanitize_text_field( wp_unslash( $_GET['ref'] ) ) : '' );
        // phpcs:enable
        check_admin_referer( 'apfaar_media_defphoto_' . $ref );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $mid && '' !== $ref ) {
            \AncestorPress\Core\Media::set_defphoto(
                $tree,
                $linktype,
                $ref,
                $mid
            );
        }
        $this->back_to(
            'media',
            $tree,
            'media_link',
            array(
                'apfaar_action' => 'media_edit',
                'apfaar_mid'    => rawurlencode( $mid ),
            )
        );
    }

    /**
     * Save a branch rule (add or edit) and apply it.
     *
     * @return void
     */
    public function handle_branch_save() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_branch_save' );
        $tree = $this->request_tree();
        if ( '' === $tree ) {
            wp_die( esc_html__( 'Unknown tree.', 'ap-family-archive' ) );
        }
        $result = APFAAR_Admin_Branches::save( $tree );
        if ( null !== $result['error'] ) {
            $this->flash( false, $result['error'] );
            $this->back_to( 'branches', $tree, '' );
        }
        $this->flash( true, sprintf( 
            /* translators: 1: branch name, 2: number of people. */
            __( 'Branch "%1$s" applied: %2$d people labeled.', 'ap-family-archive' ),
            $result['branch'],
            (int) $result['count']
         ) );
        $this->back_to( 'branches', $tree, '' );
    }

    /**
     * Re-apply a branch rule.
     *
     * @return void
     */
    public function handle_branch_apply() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $name = ( isset( $_GET['apfaar_branch'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_branch'] ) ) : '' );
        check_admin_referer( 'apfaar_branch_apply_' . $name );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $name ) {
            $count = APFAAR_Admin_Branches::apply( $tree, $name );
            // Statement-level on purpose: comments inside expressions do not
            // survive the free-build reformat, which loses the i18n note.
            if ( false !== $count ) {
                /* translators: 1: branch name, 2: number of people labeled. */
                $msg = sprintf( __( 'Branch "%1$s" re-applied: %2$d people labeled.', 'ap-family-archive' ), $name, (int) $count );
            } else {
                $msg = __( 'Could not apply the branch - its starting person may have been deleted.', 'ap-family-archive' );
            }
            $this->flash( false !== $count, $msg );
        }
        $this->back_to( 'branches', $tree, '' );
    }

    /**
     * Delete a branch (labels removed, records untouched).
     *
     * @return void
     */
    public function handle_branch_delete() {
        if ( !self::can_edit_data() ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $name = ( isset( $_GET['apfaar_branch'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_branch'] ) ) : '' );
        check_admin_referer( 'apfaar_branch_delete_' . $name );
        $tree = $this->request_tree( 'GET' );
        if ( '' !== $tree && '' !== $name ) {
            APFAAR_Admin_Branches::delete( $tree, $name );
        }
        $this->back_to( 'branches', $tree, 'branch_deleted' );
    }

    /**
     * GEDCOM import.
     *
     * @return void
     */
    public function handle_import() {
        if ( !current_user_can( self::CAP ) ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        check_admin_referer( 'apfaar_import' );
        $result = APFAAR_Admin_Data::import();
        $this->flash( !empty( $result['ok'] ), $result['message'] );
        wp_safe_redirect( add_query_arg( array(
            'page' => 'apfaar',
            'tab'  => 'data',
        ), admin_url( 'admin.php' ) ) );
        exit;
    }

    /**
     * GEDCOM export download.
     *
     * @return void
     */
    public function handle_export() {
        if ( !current_user_can( self::CAP ) ) {
            wp_die( esc_html__( 'Not allowed.', 'ap-family-archive' ) );
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line.
        $tree = ( isset( $_GET['tree'] ) ? sanitize_text_field( wp_unslash( $_GET['tree'] ) ) : '' );
        check_admin_referer( 'apfaar_export_' . $tree );
        $error = APFAAR_Admin_Data::export( $tree );
        // Exits on success.
        $this->flash( false, ( $error ? $error : __( 'Export failed.', 'ap-family-archive' ) ) );
        wp_safe_redirect( add_query_arg( array(
            'page' => 'apfaar',
            'tab'  => 'data',
        ), admin_url( 'admin.php' ) ) );
        exit;
    }

}
