<?php

/**
 * AP Family Archive - public archive pages (shortcodes).
 *
 * The free-tier spine (spec-02 Section 3): person pages, family group pages,
 * the surname index / A-Z browse, and name/date/place search. Shortcode-based
 * so it works with any theme and any page structure - the site owner drops
 * [apfaar_person], [apfaar_surnames] etc. into ordinary pages (the settings
 * screen creates them ready-made).
 *
 * Privacy fails closed everywhere (APFAAR_Privacy, the core floor - spec-00
 * Section 7): living/private people are excluded from indexes and search,
 * and appear in family listings only as a neutral placeholder.
 *
 * Navigation: record pages (person/family/reports) get pretty permalinks
 * via APFAAR_Rewrites when the site has a permalink structure; everything
 * else (and every fallback) uses plain query args (apfaar_surname,
 * apfaar_letter, apfaar_q, ...), which always keep working.
 *
 * @package    APFAAR_Family_Archive
 * @subpackage APFAAR_Family_Archive/public
 * @since      0.1.0
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Public shortcodes.
 *
 * @since 0.1.0
 */
class APFAAR_Public {
    /**
     * Rows per page on public listings.
     */
    const PER_PAGE = 50;

    /**
     * Register all shortcodes.
     *
     * @return void
     */
    public function register_shortcodes() {
        add_shortcode( 'apfaar_person', array($this, 'sc_person') );
        add_shortcode( 'apfaar_family', array($this, 'sc_family') );
        add_shortcode( 'apfaar_surnames', array($this, 'sc_surnames') );
        add_shortcode( 'apfaar_search', array($this, 'sc_search') );
        add_shortcode( 'apfaar_hub', array($this, 'sc_hub') );
        add_shortcode( 'apfaar_statistics', array($this, 'sc_statistics') );
        add_shortcode( 'apfaar_whatsnew', array($this, 'sc_whatsnew') );
        add_shortcode( 'apfaar_anniversaries', array($this, 'sc_anniversaries') );
        add_shortcode( 'apfaar_mostwanted', array($this, 'sc_mostwanted') );
        add_shortcode( 'apfaar_places', array($this, 'sc_places') );
        add_shortcode( 'apfaar_source', array($this, 'sc_source') );
    }

    // ── Shared plumbing ───────────────────────────────────────────────────────
    /**
     * The site-wide public display style (the APON precedent).
     *
     * 'heritage' (default) - serif headings, warm paper tones, matches the
     * AP Family Trees front end. 'none' - theme default, minimal plugin
     * styling. 'modern' joins the list with the extension package; until
     * then any unexpected stored value falls back to Heritage.
     *
     * @return string heritage|none.
     */
    public static function display_style() {
        $style = (string) get_option( 'apfaar_display_style', 'heritage' );
        return ( in_array( $style, array('heritage', 'none'), true ) ? $style : 'heritage' );
    }

    /**
     * Wrapper classes for a public block: base + display style + block class.
     *
     * @param  string $extra Block-specific class(es).
     * @return string
     */
    public static function wrap_class( $extra = '' ) {
        return 'apfa-pub apfa-style-' . self::display_style() . (( '' !== $extra ? ' ' . $extra : '' ));
    }

    /**
     * Opening wrapper div for a public block.
     *
     * @param  string $extra Block-specific class(es).
     * @return string
     */
    public static function wrap_open( $extra = '' ) {
        return '<div class="' . esc_attr( self::wrap_class( $extra ) ) . '">';
    }

    /**
     * The tree the CURRENT request is browsing when it is not the primary
     * one (P5) - link generation appends it so navigation stays inside the
     * visitor's chosen tree. '' = primary (links stay clean, the pre-P5
     * form).
     *
     * @var string
     */
    private static $current_tree = '';

    /**
     * THE tree resolver (P5) - every public surface resolves through here:
     * the shortcode `tree` attribute wins (an owner-fixed embed); else the
     * visitor's apfaar_tree choice, honoured only when the extension package provides them AND the
     * tree in the owner's published list (fail closed - core trees the
     * owner did not publish are never reachable from a URL); else the
     * primary tree. Sets the request tree context for link generation.
     *
     * @param  array $atts Shortcode attributes.
     * @return string      Gedcom identifier, '' when unconfigured.
     */
    public static function resolve_tree( $atts ) {
        $atts = (array) $atts;
        $primary = APFAAR_People::gedcom();
        $attr = ( isset( $atts['tree'] ) ? sanitize_text_field( (string) $atts['tree'] ) : '' );
        if ( '' !== $attr ) {
            // Owner-fixed embed: carry the context only for published trees
            // (navigation then stays inside it; unpublished embed trees keep
            // the pre-P5 behaviour).
            self::$current_tree = ( $attr !== $primary && in_array( $attr, APFAAR_People::published_gedcoms(), true ) ? $attr : '' );
            return $attr;
        }
        self::$current_tree = '';
        return $primary;
    }

    /**
     * The non-primary tree the request is browsing ('' when on the primary).
     *
     * @return string
     */
    public static function current_tree() {
        return self::$current_tree;
    }

    /**
     * Resolve the tree a shortcode displays (see resolve_tree) and enqueue
     * the public styles when something will render.
     *
     * @param  array $atts Shortcode attributes.
     * @return string      Gedcom identifier, '' when unconfigured.
     */
    private function gedcom_for( $atts ) {
        $gedcom = self::resolve_tree( $atts );
        if ( '' !== $gedcom ) {
            $this->enqueue_styles();
        }
        return $gedcom;
    }

    /**
     * Admin-only inline notice when the archive is not configured (public
     * visitors get nothing - fail closed, never an error message).
     *
     * @return string
     */
    private function unconfigured_notice() {
        if ( current_user_can( 'manage_options' ) ) {
            return '<p class="apfa-admin-notice">' . esc_html__( 'AP Family Archive: no tree selected yet. Choose the dataset to publish under Family Archive in wp-admin. (Only administrators see this message.)', 'ap-family-archive' ) . '</p>';
        }
        return '';
    }

    /**
     * Enqueue the public stylesheet (on demand, only where a shortcode renders).
     *
     * @return void
     */
    private function enqueue_styles() {
        $css = APFAAR_DIR . 'public/css/apfa-public.css';
        if ( file_exists( $css ) ) {
            wp_enqueue_style(
                'apfa-public',
                APFAAR_URL . 'public/css/apfa-public.css',
                array(),
                (string) filemtime( $css )
            );
        }
    }

    /**
     * URL of a mapped archive page, with query args appended.
     *
     * Falls back to the current page when the mapping is unset, so the
     * shortcodes still navigate when dropped onto a single combined page.
     *
     * @param  string $which person|family|surnames|search.
     * @param  array  $args  Query args to append.
     * @return string
     */
    public static function page_url( $which, $args = array() ) {
        // Multi-tree context (P5): while browsing a published non-primary
        // tree, every generated link carries it so navigation stays inside
        // that tree. An explicit apfaar_tree in $args wins.
        $tree = ( isset( $args['apfaar_tree'] ) ? (string) $args['apfaar_tree'] : self::$current_tree );
        unset($args['apfaar_tree']);
        // Record pages get a pretty URL (/person-page/I123/, or
        // /person-page/t/<tree>/I123/ off the primary tree) when rewrites
        // are available; any remaining args ride along as query args.
        if ( isset( APFAAR_Rewrites::PARAMS[$which], $args[APFAAR_Rewrites::PARAMS[$which]] ) ) {
            $param = APFAAR_Rewrites::PARAMS[$which];
            $pretty = APFAAR_Rewrites::pretty_url( $which, (string) $args[$param], $tree );
            if ( '' !== $pretty ) {
                unset($args[$param]);
                return ( empty( $args ) ? $pretty : add_query_arg( array_map( 'rawurlencode', $args ), $pretty ) );
            }
        }
        if ( '' !== $tree ) {
            $args['apfaar_tree'] = $tree;
        }
        $page_id = absint( get_option( 'apfaar_page_' . $which, 0 ) );
        $base = ( $page_id ? get_permalink( $page_id ) : get_permalink() );
        if ( !$base ) {
            $base = home_url( '/' );
        }
        return add_query_arg( array_map( 'rawurlencode', $args ), $base );
    }

    /**
     * Public tree switcher (P5): a dropdown of the published trees, ''
     * when there is only one (or none are provided). GET form - the choice rides
     * the URL like every other navigation parameter.
     *
     * @param  string $gedcom The tree currently rendering.
     * @return string
     */
    private function tree_switcher_html( $gedcom ) {
        // Extension seam: other packages may contribute switcher markup for
        // multi-tree sites. A single-tree install renders nothing here.
        // Whatever a package contributes is sanitised to plain form controls
        // before it is rendered - no scripts or event handlers survive.
        $html = (string) apply_filters( 'apfaar_tree_switcher_html', '', $gedcom );
        return ( '' === $html ? '' : wp_kses( $html, self::seam_form_allowed_html() ) );
    }

    /**
     * Allowed markup for extension-contributed filter forms (the tree
     * switcher and branch filter): plain form controls only.
     *
     * @return array
     */
    private static function seam_form_allowed_html() {
        return array(
            'form'   => array(
                'class'  => true,
                'method' => true,
                'action' => true,
            ),
            'label'  => array(
                'class' => true,
                'for'   => true,
            ),
            'select' => array(
                'class' => true,
                'name'  => true,
                'id'    => true,
            ),
            'option' => array(
                'value'    => true,
                'selected' => true,
            ),
            'button' => array(
                'type'  => true,
                'class' => true,
            ),
            'input'  => array(
                'type'  => true,
                'name'  => true,
                'value' => true,
                'class' => true,
            ),
            'span'   => array(
                'class' => true,
            ),
            'a'      => array(
                'href'  => true,
                'class' => true,
            ),
        );
    }

    /**
     * A record navigation parameter: the registered query var (set by the
     * pretty-permalink rewrites) first, the plain query arg second.
     *
     * @param  string $key apfaar_pid|apfaar_fid.
     * @return string      Sanitised value or ''.
     */
    public static function param( $key ) {
        $value = (string) get_query_var( $key, '' );
        if ( '' === $value ) {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only public navigation parameter.
            $value = ( isset( $_GET[$key] ) ? sanitize_text_field( wp_unslash( $_GET[$key] ) ) : '' );
        }
        return sanitize_text_field( $value );
    }

    /**
     * Whether an archive page is mapped and published/existing.
     *
     * Used to decide whether to render cross-links (a link that would fall
     * back to the current page is worse than no link).
     *
     * @param  string $which Page key.
     * @return bool
     */
    public static function has_page( $which ) {
        $page_id = absint( get_option( 'apfaar_page_' . $which, 0 ) );
        return $page_id && get_post( $page_id );
    }

    /**
     * Person name rendered as link, plain text, or privacy placeholder.
     *
     * THE one path every person mention goes through, so the floor cannot
     * be missed: viewable people link to their page; non-viewable rows
     * render the neutral placeholder with no name, dates or link.
     *
     * @param  object|null $person   Person row (raw, ungated).
     * @param  bool        $lifespan Append the lifespan years.
     * @return string
     */
    public function person_link( $person, $lifespan = true ) {
        if ( !is_object( $person ) ) {
            return '<span class="apfa-placeholder">' . esc_html__( 'Unknown', 'ap-family-archive' ) . '</span>';
        }
        if ( !APFAAR_Privacy::can_view( $person ) ) {
            return '<span class="apfa-placeholder">' . esc_html( APFAAR_Privacy::placeholder_label( $person ) ) . '</span>';
        }
        $url = self::page_url( 'person', array(
            'apfaar_pid' => $person->personID,
        ) );
        $html = '<a class="apfa-person-link" href="' . esc_url( $url ) . '">' . esc_html( APFAAR_People::display_name( $person ) ) . '</a>';
        $years = ( $lifespan ? APFAAR_People::lifespan( $person ) : '' );
        if ( '' !== $years ) {
            $html .= ' <span class="apfa-lifespan">(' . wp_kses_post( $years ) . ')</span>';
        }
        return $html;
    }

    /**
     * A labelled vital-event line (date as recorded + place), or '' when empty.
     *
     * @param  string $label Event label.
     * @param  string $date  As-recorded date string.
     * @param  string $place Place string.
     * @return string
     */
    private function vital_line( $label, $date, $place ) {
        $date = trim( (string) $date );
        $place = trim( (string) $place );
        if ( '' === $date && '' === $place ) {
            return '';
        }
        $value = esc_html( $date );
        if ( '' !== $place ) {
            $value .= (( '' !== $value ? ' &mdash; ' : '' )) . esc_html( $place );
        }
        return '<li class="apfa-vital"><span class="apfa-vital-label">' . esc_html( $label ) . '</span> ' . $value . '</li>';
    }

    /**
     * Citations for a record, fetched once per page render.
     *
     * @param  string $gedcom    Tree identifier.
     * @param  string $persfamID Person or family reference.
     * @return array
     */
    private function citations_for( $gedcom, $persfamID ) {
        if ( !class_exists( 'AncestorPress\\Core\\Citations_Table' ) ) {
            return array();
        }
        return (array) \AncestorPress\Core\Citations_Table::get_for_persfam( $gedcom, $persfamID );
    }

    /**
     * Map eventID => source-list numbers, for the per-event [n] markers.
     *
     * Citation numbering matches the rendered Sources list (1-based, list
     * order); only citations linked to a specific event appear here.
     *
     * @param  array $citations Citation rows.
     * @return array            eventID => int[].
     */
    private function citation_marks_by_event( $citations ) {
        $map = array();
        $n = 0;
        foreach ( $citations as $cit ) {
            $n++;
            if ( !empty( $cit->eventID ) ) {
                $map[(string) $cit->eventID][] = $n;
            }
        }
        return $map;
    }

    /**
     * Superscript source markers for one event ([1][3] linking to the
     * Sources list anchors), or '' when the event has no linked citation.
     *
     * @param  array       $marks   Map from citation_marks_by_event().
     * @param  string|null $eventID Event reference.
     * @return string
     */
    private function cite_sup( $marks, $eventID ) {
        if ( empty( $eventID ) || empty( $marks[(string) $eventID] ) ) {
            return '';
        }
        $out = '';
        foreach ( $marks[(string) $eventID] as $n ) {
            $out .= '<a href="#apfa-src-' . (int) $n . '">[' . (int) $n . ']</a>';
        }
        return ' <sup class="apfa-cite-ref">' . $out . '</sup>';
    }

    /**
     * Sources/citations block for a person or family page (core 0.7.0).
     *
     * Renders a numbered, anchor-targetable source list: title (linked to
     * the source detail page when mapped), author/publisher, page, the
     * event a citation backs (when event-linked), quoted text. Returns ''
     * when the core predates 0.7.0 or nothing is cited. Caller has already
     * privacy-gated the subject.
     *
     * @param  array $citations    Citation rows (citations_for()).
     * @param  array $event_labels Map eventID => display label, for the
     *                             "backs this event" annotation.
     * @return string
     */
    private function citations_block( $citations, $event_labels = array() ) {
        if ( empty( $citations ) ) {
            return '';
        }
        $link_sources = self::has_page( 'source' );
        $out = '<h3>' . esc_html__( 'Sources', 'ap-family-archive' ) . '</h3><ol class="apfa-sources">';
        $n = 0;
        foreach ( $citations as $cit ) {
            $n = $n + 1;
            $title = ( '' !== (string) $cit->source_title ? $cit->source_title : $cit->sourceID );
            $line = '<span class="apfa-source-title">' . esc_html( $title ) . '</span>';
            if ( $link_sources && '' !== (string) $cit->sourceID ) {
                $line = '<a href="' . esc_url( self::page_url( 'source', array(
                    'apfaar_sid' => $cit->sourceID,
                ) ) ) . '">' . $line . '</a>';
            }
            $meta = array_filter( array(trim( (string) $cit->source_author ), trim( (string) $cit->source_publisher )) );
            if ( !empty( $meta ) ) {
                $line .= ' <span class="apfa-source-meta">(' . esc_html( implode( '; ', $meta ) ) . ')</span>';
            }
            if ( !empty( $cit->page ) ) {
                $line .= ' &mdash; ' . esc_html( $cit->page );
            }
            if ( !empty( $cit->eventID ) && isset( $event_labels[(string) $cit->eventID] ) ) {
                /* translators: %s: event label (e.g. Occupation). */
                $line .= ' <span class="apfa-source-event">' . esc_html( sprintf( __( 'for: %s', 'ap-family-archive' ), $event_labels[(string) $cit->eventID] ) ) . '</span>';
            }
            $out .= '<li id="apfa-src-' . (int) $n . '">' . $line;
            if ( !empty( $cit->citetext ) ) {
                $out .= '<blockquote class="apfa-source-text">' . esc_html( $cit->citetext ) . '</blockquote>';
            }
            $out .= '</li>';
        }
        return $out . '</ol>';
    }

    /**
     * Portrait figure for a person page: the default photo, floated beside
     * the vitals. '' when the person has no viewable photo.
     *
     * @param  object $person Person row (already privacy-gated by the caller).
     * @param  array  $media  Media rows from APFAAR_People::media_for().
     * @return string
     */
    private function portrait_html( $person, $media ) {
        if ( empty( $media ) || empty( $media[0]->defphoto ) ) {
            return '';
        }
        $thumb = APFAAR_People::media_thumb_url( $media[0] );
        $full = APFAAR_People::media_url( $media[0] );
        if ( '' === $thumb ) {
            return '';
        }
        $img = '<img src="' . esc_url( $thumb ) . '" alt="' . esc_attr( APFAAR_People::display_name( $person ) ) . '" loading="lazy" />';
        if ( '' !== $full && $full !== $thumb ) {
            $img = '<a href="' . esc_url( $full ) . '">' . $img . '</a>';
        }
        return '<figure class="apfa-portrait">' . $img . '</figure>';
    }

    /**
     * Media gallery section (thumbnails linking to the full item, caption =
     * description + as-recorded date). '' when there is nothing to show.
     *
     * @param  array $media Media rows from APFAAR_People::media_for().
     * @param  bool  $skip_portrait Leave out the leading defphoto row (it is
     *                              already rendered as the page portrait).
     * @return string
     */
    private function media_gallery( $media, $skip_portrait = false ) {
        if ( $skip_portrait && !empty( $media ) && !empty( $media[0]->defphoto ) ) {
            array_shift( $media );
        }
        if ( empty( $media ) ) {
            return '';
        }
        $out = '<h3>' . esc_html__( 'Media', 'ap-family-archive' ) . '</h3><ul class="apfa-media-list">';
        foreach ( $media as $item ) {
            $thumb = APFAAR_People::media_thumb_url( $item );
            $full = APFAAR_People::media_url( $item );
            if ( '' === $thumb && '' === $full ) {
                continue;
            }
            $caption = trim( (string) $item->description );
            $alt = ( '' !== $caption ? $caption : (string) $item->mediaID );
            $img = '<img src="' . esc_url( ( '' !== $thumb ? $thumb : $full ) ) . '" alt="' . esc_attr( $alt ) . '" loading="lazy" />';
            $href = $this->media_item_href( $item, $full );
            if ( '' !== $href ) {
                $img = '<a href="' . esc_url( $href ) . '">' . $img . '</a>';
            }
            $out .= '<li class="apfa-media-item">' . $img;
            $meta = '';
            if ( '' !== $caption ) {
                $meta .= esc_html( $caption );
            }
            if ( !empty( $item->mediadate ) ) {
                // As-recorded date string, shown verbatim.
                $meta .= (( '' !== $meta ? ' ' : '' )) . '<span class="apfa-media-date">' . esc_html( $item->mediadate ) . '</span>';
            }
            if ( '' !== $meta ) {
                $out .= '<span class="apfa-media-caption">' . $meta . '</span>';
            }
            $out .= '</li>';
        }
        return $out . '</ul>';
    }

    /**
     * The requested public branch filter. Returns '' unless a package
     * resolves it AND the named branch exists in the tree.
     *
     * @param  string $gedcom Tree identifier.
     * @return string         Validated branch name or ''.
     */
    private function requested_branch( $gedcom ) {
        // Extension seam: other packages may resolve a validated public
        // branch filter. Nothing narrows the archive by default.
        return (string) apply_filters( 'apfaar_public_branch', '', $gedcom );
    }

    /**
     * Branch filter dropdown (the extension package): '' when unavailable or no branches exist.
     * Submits as a GET form so the choice rides the URL like every other
     * navigation parameter.
     *
     * @param  string $gedcom  Tree identifier.
     * @param  string $current Currently-selected branch ('' = all).
     * @return string
     */
    private function branch_filter_html( $gedcom, $current ) {
        // Extension seam: other packages may contribute a branch filter form.
        // Sanitised to plain form controls before rendering, exactly like the
        // tree-switcher seam.
        $html = (string) apply_filters(
            'apfaar_branch_filter_html',
            '',
            $gedcom,
            $current
        );
        return ( '' === $html ? '' : wp_kses( $html, self::seam_form_allowed_html() ) );
    }

    /**
     * Where a gallery thumbnail should link. By default every item links to
     * its file; a package may route viewer-capable items to a viewer page
     * through the seam below.
     *
     * @param  object $item Media row.
     * @param  string $full Full-size file URL (may be '').
     * @return string       Href or '' for no link.
     */
    private function media_item_href( $item, $full ) {
        // Extension seam: other packages may route certain items to a viewer
        // page; galleries link to the file itself by default.
        return (string) apply_filters( 'apfaar_media_item_href', $full, $item );
    }

    /**
     * Listing thumbnail for a person (from the photo_thumbs batch map).
     *
     * A person without a photo gets nothing - listings stay a plain list,
     * they never show placeholder boxes.
     *
     * @param  array  $thumbs Map personID => URL.
     * @param  object $person Person row.
     * @return string
     */
    private function list_thumb( $thumbs, $person ) {
        if ( !is_object( $person ) || empty( $thumbs[$person->personID] ) ) {
            return '';
        }
        return '<img class="apfa-list-thumb" src="' . esc_url( $thumbs[$person->personID] ) . '" alt="" loading="lazy" />';
    }

    /**
     * Simple pagination links preserving current query args.
     *
     * @param  int    $total    Total rows.
     * @param  int    $page     Current 1-based page.
     * @param  string $page_arg Query arg name for the page number.
     * @return string
     */
    private function paginate( $total, $page, $page_arg = 'apfaar_pg' ) {
        $pages = (int) ceil( $total / self::PER_PAGE );
        if ( $pages < 2 ) {
            return '';
        }
        $out = '<nav class="apfa-pagination">';
        if ( $page > 1 ) {
            $out .= '<a href="' . esc_url( add_query_arg( $page_arg, $page - 1 ) ) . '">&laquo; ' . esc_html__( 'Previous', 'ap-family-archive' ) . '</a> ';
        }
        /* translators: 1: current page, 2: total pages. */
        $out .= '<span class="apfa-page-x-of-y">' . esc_html( sprintf( __( 'Page %1$d of %2$d', 'ap-family-archive' ), $page, $pages ) ) . '</span>';
        if ( $page < $pages ) {
            $out .= ' <a href="' . esc_url( add_query_arg( $page_arg, $page + 1 ) ) . '">' . esc_html__( 'Next', 'ap-family-archive' ) . ' &raquo;</a>';
        }
        return $out . '</nav>';
    }

    // ── [apfaar_person] ─────────────────────────────────────────────────────────
    /**
     * Person page: vitals, events timeline, family navigation.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_person( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        $pid = self::param( 'apfaar_pid' );
        if ( '' === $pid ) {
            return '<p class="apfa-empty">' . esc_html__( 'No person selected.', 'ap-family-archive' ) . '</p>';
        }
        $person = APFAAR_People::get_person( $gedcom, $pid );
        if ( !$person ) {
            // Missing and not-viewable read identically - never confirm a
            // living/private person exists at a guessable URL.
            return '<p class="apfa-empty">' . esc_html__( 'This person is not available.', 'ap-family-archive' ) . '</p>';
        }
        $media = APFAAR_People::media_for( $gedcom, 'person', $person->personID );
        $out = self::wrap_open( 'apfa-person' );
        $out .= $this->portrait_html( $person, $media );
        $out .= '<h2 class="apfa-person-name">' . esc_html( APFAAR_People::display_name( $person ) ) . ' <span class="apfa-record-id">' . esc_html( $person->personID ) . '</span></h2>';
        $years = APFAAR_People::lifespan( $person );
        if ( '' !== $years ) {
            $out .= '<p class="apfa-person-years">' . wp_kses_post( $years ) . '</p>';
        }
        // Person-first navigation (the TNG convention): reports start from the
        // individual. Links render only when the target page exists.
        $report_links = array();
        if ( self::has_page( 'ahnentafel' ) ) {
            $report_links[] = '<a href="' . esc_url( self::page_url( 'ahnentafel', array(
                'apfaar_pid' => $person->personID,
            ) ) ) . '">' . esc_html__( 'Ahnentafel (ancestors)', 'ap-family-archive' ) . '</a>';
        }
        if ( self::has_page( 'descendants' ) ) {
            $report_links[] = '<a href="' . esc_url( self::page_url( 'descendants', array(
                'apfaar_pid' => $person->personID,
            ) ) ) . '">' . esc_html__( 'Descendants', 'ap-family-archive' ) . '</a>';
        }
        if ( !empty( $report_links ) ) {
            $out .= '<p class="apfa-person-nav">' . implode( ' &middot; ', $report_links ) . '</p>';
        }
        // Vitals (as-recorded date strings, never reformatted).
        $vitals = '';
        $vitals .= $this->vital_line( __( 'Born', 'ap-family-archive' ), $person->birthdate, $person->birthplace );
        $vitals .= $this->vital_line( __( 'Baptised', 'ap-family-archive' ), $person->baptdate, $person->baptplace );
        $vitals .= $this->vital_line( __( 'Died', 'ap-family-archive' ), $person->deathdate, $person->deathplace );
        $vitals .= $this->vital_line( __( 'Buried', 'ap-family-archive' ), $person->burialdate, $person->burialplace );
        if ( '' !== $vitals ) {
            $out .= '<ul class="apfa-vitals">' . $vitals . '</ul>';
        }
        // Parents.
        $parents_family = APFAAR_People::parents_family( $gedcom, $person );
        if ( $parents_family ) {
            $father = ( !empty( $parents_family->husband ) ? APFAAR_People::get_person_raw( $gedcom, $parents_family->husband ) : null );
            $mother = ( !empty( $parents_family->wife ) ? APFAAR_People::get_person_raw( $gedcom, $parents_family->wife ) : null );
            if ( $father || $mother ) {
                $out .= '<h3>' . esc_html__( 'Parents', 'ap-family-archive' ) . '</h3><ul class="apfa-parents">';
                if ( $father ) {
                    $out .= '<li>' . $this->person_link( $father ) . '</li>';
                }
                if ( $mother ) {
                    $out .= '<li>' . $this->person_link( $mother ) . '</li>';
                }
                $out .= '</ul>';
            }
        }
        // Spouse families with children.
        $families = APFAAR_People::spouse_families( $gedcom, $person->personID );
        foreach ( $families as $family ) {
            $spouse_ref = ( $family->husband === $person->personID ? $family->wife : $family->husband );
            $spouse = ( !empty( $spouse_ref ) ? APFAAR_People::get_person_raw( $gedcom, $spouse_ref ) : null );
            $out .= '<div class="apfa-family-block">';
            $out .= '<h3>' . esc_html__( 'Family', 'ap-family-archive' ) . ' <a class="apfa-family-link" href="' . esc_url( self::page_url( 'family', array(
                'apfaar_fid' => $family->familyID,
            ) ) ) . '">' . esc_html__( '(family group page)', 'ap-family-archive' ) . '</a></h3>';
            if ( $spouse ) {
                $out .= '<p class="apfa-spouse">' . esc_html__( 'Partner:', 'ap-family-archive' ) . ' ' . $this->person_link( $spouse ) . '</p>';
            }
            $marriage = $this->vital_line( __( 'Married', 'ap-family-archive' ), $family->marrdate, $family->marrplace );
            if ( '' !== $marriage ) {
                $out .= '<ul class="apfa-vitals">' . $marriage . '</ul>';
            }
            $children = APFAAR_People::children_of( $gedcom, $family->familyID );
            if ( !empty( $children ) ) {
                $out .= '<h4>' . esc_html__( 'Children', 'ap-family-archive' ) . '</h4><ol class="apfa-children">';
                foreach ( $children as $link ) {
                    $child = APFAAR_People::get_person_raw( $gedcom, $link->personID );
                    $out .= '<li>' . $this->person_link( $child ) . '</li>';
                }
                $out .= '</ol>';
            }
            $out .= '</div>';
        }
        // Events timeline (non-vital events), with [n] markers on events a
        // citation specifically backs.
        $events = APFAAR_People::events_for( $gedcom, $person->personID );
        $citations = $this->citations_for( $gedcom, $person->personID );
        $marks = $this->citation_marks_by_event( $citations );
        $labels = array();
        if ( !empty( $events ) ) {
            $out .= '<h3>' . esc_html__( 'Timeline', 'ap-family-archive' ) . '</h3><ul class="apfa-timeline">';
            foreach ( $events as $event ) {
                if ( !empty( $event->eventID ) ) {
                    $labels[(string) $event->eventID] = $event->label;
                }
                $line = esc_html( $event->label );
                if ( !empty( $event->eventdate ) ) {
                    $line .= ': ' . esc_html( $event->eventdate );
                }
                if ( !empty( $event->eventplace ) ) {
                    $line .= ' &mdash; ' . esc_html( $event->eventplace );
                }
                if ( !empty( $event->info ) ) {
                    $line .= ' <span class="apfa-event-info">' . esc_html( $event->info ) . '</span>';
                }
                $out .= '<li>' . wp_kses_post( $line . $this->cite_sup( $marks, $event->eventID ) ) . '</li>';
            }
            $out .= '</ul>';
        }
        // Media beyond the portrait (photos, documents...). By default all
        // media (headstones included) renders in the general gallery; the
        // separate package splits headstones into their own section.
        $media_done = false;
        if ( !$media_done ) {
            $out .= $this->media_gallery( $media, true );
        }
        // Sources cited for this person (core 0.7.0).
        $out .= $this->citations_block( $citations, $labels );
        return $out . '</div>';
    }

    // ── [apfaar_family] ─────────────────────────────────────────────────────────
    /**
     * Family group page: partners, marriage, children.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_family( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        $fid = self::param( 'apfaar_fid' );
        if ( '' === $fid ) {
            return '<p class="apfa-empty">' . esc_html__( 'No family selected.', 'ap-family-archive' ) . '</p>';
        }
        $family = APFAAR_People::get_family( $gedcom, $fid );
        if ( !$family ) {
            return '<p class="apfa-empty">' . esc_html__( 'This family is not available.', 'ap-family-archive' ) . '</p>';
        }
        // A family flagged living/private in its own right follows the floor
        // as a whole (member-level gating handles the common mixed case).
        if ( (!empty( $family->living ) || !empty( $family->private )) && !APFAAR_Privacy::viewer_is_privileged() ) {
            return '<p class="apfa-empty">' . esc_html__( 'This family is not available.', 'ap-family-archive' ) . '</p>';
        }
        $husband = ( !empty( $family->husband ) ? APFAAR_People::get_person_raw( $gedcom, $family->husband ) : null );
        $wife = ( !empty( $family->wife ) ? APFAAR_People::get_person_raw( $gedcom, $family->wife ) : null );
        $out = self::wrap_open( 'apfa-family' );
        $out .= '<h2 class="apfa-family-title">' . esc_html__( 'Family group', 'ap-family-archive' ) . ' <span class="apfa-record-id">' . esc_html( $family->familyID ) . '</span></h2>';
        $out .= '<ul class="apfa-partners">';
        if ( $husband || !empty( $family->husband ) ) {
            $out .= '<li>' . $this->person_link( $husband ) . '</li>';
        }
        if ( $wife || !empty( $family->wife ) ) {
            $out .= '<li>' . $this->person_link( $wife ) . '</li>';
        }
        $out .= '</ul>';
        $vitals = $this->vital_line( __( 'Married', 'ap-family-archive' ), $family->marrdate, $family->marrplace );
        $vitals .= $this->vital_line( __( 'Divorced', 'ap-family-archive' ), $family->divdate, $family->divplace );
        if ( '' !== $vitals ) {
            $out .= '<ul class="apfa-vitals">' . $vitals . '</ul>';
        }
        $children = APFAAR_People::children_of( $gedcom, $fid );
        if ( !empty( $children ) ) {
            $out .= '<h3>' . esc_html__( 'Children', 'ap-family-archive' ) . '</h3><ol class="apfa-children">';
            foreach ( $children as $link ) {
                $child = APFAAR_People::get_person_raw( $gedcom, $link->personID );
                $out .= '<li>' . $this->person_link( $child ) . '</li>';
            }
            $out .= '</ol>';
        }
        // Family-scoped events (residence, census as a family...), with [n]
        // markers on events a citation specifically backs.
        $events = APFAAR_People::events_for( $gedcom, $fid );
        $citations = $this->citations_for( $gedcom, $fid );
        $marks = $this->citation_marks_by_event( $citations );
        $labels = array();
        if ( !empty( $events ) ) {
            $out .= '<h3>' . esc_html__( 'Events', 'ap-family-archive' ) . '</h3><ul class="apfa-timeline">';
            foreach ( $events as $event ) {
                if ( !empty( $event->eventID ) ) {
                    $labels[(string) $event->eventID] = $event->label;
                }
                $line = esc_html( $event->label );
                if ( !empty( $event->eventdate ) ) {
                    $line .= ': ' . esc_html( $event->eventdate );
                }
                if ( !empty( $event->eventplace ) ) {
                    $line .= ' &mdash; ' . esc_html( $event->eventplace );
                }
                $out .= '<li>' . wp_kses_post( $line . $this->cite_sup( $marks, $event->eventID ) ) . '</li>';
            }
            $out .= '</ul>';
        }
        // Media attached to the family record (certificates, group photos...).
        $out .= $this->media_gallery( APFAAR_People::media_for( $gedcom, 'family', $fid ) );
        // Sources cited for this family (core 0.7.0).
        $out .= $this->citations_block( $citations, $labels );
        return $out . '</div>';
    }

    // ── [apfaar_surnames] ───────────────────────────────────────────────────────
    /**
     * Surname index: A-Z bar, surname counts, and the people under a surname.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_surnames( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only public navigation parameters.
        $letter = ( isset( $_GET['apfaar_letter'] ) ? strtoupper( substr( sanitize_text_field( wp_unslash( $_GET['apfaar_letter'] ) ), 0, 1 ) ) : '' );
        $surname = ( isset( $_GET['apfaar_surname'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_surname'] ) ) : '' );
        $page = ( isset( $_GET['apfaar_pg'] ) ? max( 1, absint( $_GET['apfaar_pg'] ) ) : 1 );
        // phpcs:enable
        $branch = $this->requested_branch( $gedcom );
        $out = self::wrap_open( 'apfa-surnames' );
        $out .= $this->tree_switcher_html( $gedcom );
        // Branch-scoped display (P3): the dropdown narrows every list
        // below; the privacy floor is untouched.
        $out .= $this->branch_filter_html( $gedcom, $branch );
        if ( '' !== $branch ) {
            /* translators: %s: branch name. */
            $out .= '<p class="apfa-branch-current">' . esc_html( sprintf( __( 'Showing the %s branch only.', 'ap-family-archive' ), $branch ) ) . '</p>';
        }
        // One surname drilled open: list its people.
        if ( '' !== $surname ) {
            $result = APFAAR_People::people_by_surname(
                $gedcom,
                $surname,
                self::PER_PAGE,
                $page,
                $branch
            );
            $out .= '<h2>' . esc_html( $surname ) . '</h2>';
            /* translators: %d: number of people. */
            $out .= '<p class="apfa-count">' . esc_html( sprintf( _n(
                '%d person',
                '%d people',
                $result['total'],
                'ap-family-archive'
            ), $result['total'] ) ) . '</p>';
            if ( empty( $result['rows'] ) ) {
                $out .= '<p class="apfa-empty">' . esc_html__( 'No viewable people under this surname.', 'ap-family-archive' ) . '</p>';
            } else {
                $thumbs = APFAAR_People::photo_thumbs( $gedcom, wp_list_pluck( $result['rows'], 'personID' ) );
                $out .= '<ul class="apfa-people-list">';
                foreach ( $result['rows'] as $person ) {
                    $out .= '<li>' . $this->list_thumb( $thumbs, $person ) . $this->person_link( $person ) . '</li>';
                }
                $out .= '</ul>' . $this->paginate( $result['total'], $page );
            }
            $out .= '<p><a href="' . esc_url( remove_query_arg( array('apfaar_surname', 'apfaar_pg') ) ) . '">&laquo; ' . esc_html__( 'All surnames', 'ap-family-archive' ) . '</a></p>';
            return $out . '</div>';
        }
        // Top surnames block (TNG convention: the 30 most frequent; the `top`
        // attribute changes the count, 0 hides the block; hidden while a
        // branch filter is active - it is a whole-archive landing block).
        $top_n = ( isset( $atts['top'] ) ? absint( $atts['top'] ) : 30 );
        if ( $top_n > 0 && '' === $letter && '' === $branch ) {
            $top = APFAAR_People::top_surnames( $gedcom, $top_n );
            if ( !empty( $top ) ) {
                $out .= '<h3 class="apfa-top-surnames-title">' . esc_html__( 'Most frequent surnames', 'ap-family-archive' ) . '</h3>';
                $out .= '<ul class="apfa-surname-list apfa-top-surnames">';
                foreach ( $top as $row ) {
                    $url = add_query_arg( 'apfaar_surname', rawurlencode( $row->lastname ), remove_query_arg( 'apfaar_pg' ) );
                    $out .= '<li><a href="' . esc_url( $url ) . '">' . esc_html( $row->lastname ) . '</a> ' . '<span class="apfa-surname-count">(' . (int) $row->total . ')</span></li>';
                }
                $out .= '</ul><h3 class="apfa-all-surnames-title">' . esc_html__( 'All surnames', 'ap-family-archive' ) . '</h3>';
            }
        }
        // A-Z bar.
        $letters = APFAAR_People::surname_letters( $gedcom, $branch );
        if ( empty( $letters ) ) {
            return $out . '<p class="apfa-empty">' . esc_html__( 'No people published yet.', 'ap-family-archive' ) . '</p></div>';
        }
        $out .= '<nav class="apfa-az">';
        foreach ( $letters as $az ) {
            $class = ( $az === $letter ? ' class="apfa-az-current"' : '' );
            $out .= '<a' . $class . ' href="' . esc_url( add_query_arg( 'apfaar_letter', rawurlencode( $az ), remove_query_arg( array('apfaar_surname', 'apfaar_pg') ) ) ) . '">' . esc_html( $az ) . '</a> ';
        }
        if ( '' !== $letter ) {
            $out .= '<a href="' . esc_url( remove_query_arg( array('apfaar_letter', 'apfaar_surname', 'apfaar_pg') ) ) . '">' . esc_html__( 'All', 'ap-family-archive' ) . '</a>';
        }
        $out .= '</nav>';
        // Surname counts (all, or one initial).
        $surnames = APFAAR_People::surname_counts(
            $gedcom,
            $letter,
            0,
            $branch
        );
        if ( empty( $surnames ) ) {
            $out .= '<p class="apfa-empty">' . esc_html__( 'No surnames under this letter.', 'ap-family-archive' ) . '</p>';
        } else {
            $out .= '<ul class="apfa-surname-list">';
            foreach ( $surnames as $row ) {
                $url = add_query_arg( 'apfaar_surname', rawurlencode( $row->lastname ), remove_query_arg( 'apfaar_pg' ) );
                $out .= '<li><a href="' . esc_url( $url ) . '">' . esc_html( $row->lastname ) . '</a> ' . '<span class="apfa-surname-count">(' . (int) $row->total . ')</span></li>';
            }
            $out .= '</ul>';
        }
        return $out . '</div>';
    }

    // ── [apfaar_search] ─────────────────────────────────────────────────────────
    /**
     * Search form + results: name (optionally sounds-like), years, place.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_search( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        // Criteria are honoured only when the search form's nonce verifies;
        // otherwise the empty form is shown (nothing is modified either way).
        $nf = ( isset( $_GET['apfaar_nf'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_nf'] ) ) : '' );
        $filtered = '' !== $nf && false !== wp_verify_nonce( $nf, 'apfaar_public_filter' );
        $name = ( $filtered && isset( $_GET['apfaar_q'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_q'] ) ) : '' );
        $phonetic = $filtered && !empty( $_GET['apfaar_ph'] );
        $year_from = ( $filtered && isset( $_GET['apfaar_from'] ) ? absint( $_GET['apfaar_from'] ) : 0 );
        $year_to = ( $filtered && isset( $_GET['apfaar_to'] ) ? absint( $_GET['apfaar_to'] ) : 0 );
        $place = ( $filtered && isset( $_GET['apfaar_place'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_place'] ) ) : '' );
        $page = ( $filtered && isset( $_GET['apfaar_pg'] ) ? max( 1, absint( $_GET['apfaar_pg'] ) ) : 1 );
        $branch = $this->requested_branch( $gedcom );
        // Extended criteria (P6/P3) resolve through the separate package's
        // helper when present; the defaults below apply otherwise. Everything
        // here narrows only - the privacy floor always stands.
        $sex = '';
        $evtype = '';
        $name_op = 'contains';
        $branch_field = '';
        $advanced_fields = '';
        $out = self::wrap_open( 'apfa-search' );
        $out .= $this->tree_switcher_html( $gedcom );
        $out .= '<form class="apfa-search-form" method="get" action="' . esc_url( self::page_url( 'search' ) ) . '">';
        $out .= wp_nonce_field(
            'apfaar_public_filter',
            'apfaar_nf',
            false,
            false
        );
        $out .= '<p><label>' . esc_html__( 'Name', 'ap-family-archive' ) . ' <input type="text" name="apfaar_q" value="' . esc_attr( $name ) . '" /></label> ' . '<label class="apfa-phonetic"><input type="checkbox" name="apfaar_ph" value="1"' . checked( $phonetic, true, false ) . ' /> ' . esc_html__( 'Sounds like (finds spelling variants)', 'ap-family-archive' ) . '</label></p>';
        $out .= '<p><label>' . esc_html__( 'Between years', 'ap-family-archive' ) . ' <input type="number" name="apfaar_from" min="0" max="9999" value="' . esc_attr( ( $year_from ? $year_from : '' ) ) . '" placeholder="' . esc_attr__( 'from', 'ap-family-archive' ) . '" /></label> ' . '<label><input type="number" name="apfaar_to" min="0" max="9999" value="' . esc_attr( ( $year_to ? $year_to : '' ) ) . '" placeholder="' . esc_attr__( 'to', 'ap-family-archive' ) . '" /></label> ' . '<label>' . esc_html__( 'Place', 'ap-family-archive' ) . ' <input type="text" name="apfaar_place" value="' . esc_attr( $place ) . '" /></label>' . $branch_field . ' ' . '<button type="submit" class="apfa-search-btn">' . esc_html__( 'Search', 'ap-family-archive' ) . '</button></p>';
        $out .= $advanced_fields;
        $out .= '</form>';
        $has_criteria = '' !== $name || $year_from || $year_to || '' !== $place || '' !== $branch || '' !== $sex || '' !== $evtype;
        if ( !$has_criteria ) {
            return $out . '</div>';
        }
        $result = APFAAR_People::search( $gedcom, array(
            'name'       => $name,
            'phonetic'   => $phonetic,
            'name_op'    => $name_op,
            'year_from'  => $year_from,
            'year_to'    => $year_to,
            'place'      => $place,
            'branch'     => $branch,
            'sex'        => $sex,
            'event_type' => $evtype,
            'per_page'   => self::PER_PAGE,
            'page'       => $page,
        ) );
        /* translators: %d: number of matches. */
        $out .= '<p class="apfa-count">' . esc_html( sprintf( _n(
            '%d match',
            '%d matches',
            $result['total'],
            'ap-family-archive'
        ), $result['total'] ) ) . '</p>';
        if ( empty( $result['rows'] ) ) {
            $out .= '<p class="apfa-empty">' . esc_html__( 'No matches. Try the sounds-like option, fewer criteria, or a wider year range.', 'ap-family-archive' ) . '</p>';
        } else {
            $thumbs = APFAAR_People::photo_thumbs( $gedcom, wp_list_pluck( $result['rows'], 'personID' ) );
            $out .= '<ul class="apfa-people-list">';
            foreach ( $result['rows'] as $person ) {
                $line = $this->list_thumb( $thumbs, $person ) . $this->person_link( $person );
                $bits = array_filter( array(trim( (string) $person->birthplace ), trim( (string) $person->deathplace )) );
                if ( !empty( $bits ) ) {
                    $line .= ' <span class="apfa-result-places">' . esc_html( implode( ' / ', $bits ) ) . '</span>';
                }
                $out .= '<li>' . $line . '</li>';
            }
            $out .= '</ul>' . $this->paginate( $result['total'], $page );
        }
        return $out . '</div>';
    }

    // ── [apfaar_hub] ────────────────────────────────────────────────────────────
    /**
     * Site hub landing block: intro, headline numbers, top surnames, latest
     * additions, and links to every mapped archive page.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_hub( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        $out = self::wrap_open( 'apfa-hub' );
        $out .= $this->tree_switcher_html( $gedcom );
        $intro = (string) get_option( 'apfaar_hub_intro', '' );
        if ( '' !== $intro ) {
            $out .= '<div class="apfa-hub-intro">' . wp_kses_post( wpautop( $intro ) ) . '</div>';
        }
        // Headline numbers.
        $stats = APFAAR_People::stats( $gedcom );
        $out .= '<div class="apfa-hub-stats">';
        foreach ( array(
            'people'   => __( 'People', 'ap-family-archive' ),
            'families' => __( 'Families', 'ap-family-archive' ),
            'surnames' => __( 'Surnames', 'ap-family-archive' ),
        ) as $key => $label ) {
            $out .= '<div class="apfa-hub-stat"><span class="apfa-hub-stat-n">' . esc_html( number_format_i18n( $stats[$key] ) ) . '</span>' . '<span class="apfa-hub-stat-l">' . esc_html( $label ) . '</span></div>';
        }
        $out .= '</div>';
        // Explore links (only mapped pages).
        $explore = array(
            'surnames'      => __( 'Browse surnames', 'ap-family-archive' ),
            'search'        => __( 'Search the archive', 'ap-family-archive' ),
            'places'        => __( 'Places', 'ap-family-archive' ),
            'anniversaries' => __( 'Dates & anniversaries', 'ap-family-archive' ),
            'whatsnew'      => __( "What's new", 'ap-family-archive' ),
            'mostwanted'    => __( 'Most wanted', 'ap-family-archive' ),
            'statistics'    => __( 'Statistics', 'ap-family-archive' ),
        );
        $links = array();
        foreach ( $explore as $key => $label ) {
            if ( self::has_page( $key ) ) {
                $links[] = '<a href="' . esc_url( self::page_url( $key ) ) . '">' . esc_html( $label ) . '</a>';
            }
        }
        if ( !empty( $links ) ) {
            $out .= '<nav class="apfa-hub-links">' . implode( ' &middot; ', $links ) . '</nav>';
        }
        // Featured surnames.
        $top = APFAAR_People::top_surnames( $gedcom, 10 );
        if ( !empty( $top ) && self::has_page( 'surnames' ) ) {
            $out .= '<h3>' . esc_html__( 'Featured surnames', 'ap-family-archive' ) . '</h3><ul class="apfa-surname-list">';
            foreach ( $top as $row ) {
                $url = self::page_url( 'surnames', array(
                    'apfaar_surname' => $row->lastname,
                ) );
                $out .= '<li><a href="' . esc_url( $url ) . '">' . esc_html( $row->lastname ) . '</a> ' . '<span class="apfa-surname-count">(' . (int) $row->total . ')</span></li>';
            }
            $out .= '</ul>';
        }
        // Latest additions teaser.
        $fresh = APFAAR_People::whats_new( $gedcom, (int) get_option( 'apfaar_whatsnew_days', 30 ), 5 );
        if ( !empty( $fresh['people_added'] ) ) {
            $out .= '<h3>' . esc_html__( 'Recently added', 'ap-family-archive' ) . '</h3><ul class="apfa-people-list">';
            foreach ( $fresh['people_added'] as $person ) {
                $out .= '<li>' . $this->person_link( $person ) . '</li>';
            }
            $out .= '</ul>';
            if ( self::has_page( 'whatsnew' ) ) {
                $out .= '<p><a href="' . esc_url( self::page_url( 'whatsnew' ) ) . '">' . esc_html__( "See everything that's new", 'ap-family-archive' ) . ' &raquo;</a></p>';
            }
        }
        return $out . '</div>';
    }

    // ── [apfaar_statistics] ─────────────────────────────────────────────────────
    /**
     * Public statistics page.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_statistics( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        $s = APFAAR_People::stats_extended( $gedcom );
        $pct = function ( $n ) use($s) {
            return ( $s['total'] > 0 ? ' (' . round( $n / $s['total'] * 100, 1 ) . '%)' : '' );
        };
        $rows = array(
            array(__( 'Total individuals', 'ap-family-archive' ), number_format_i18n( $s['total'] )),
            array(__( 'Males', 'ap-family-archive' ), number_format_i18n( $s['males'] ) . $pct( $s['males'] )),
            array(__( 'Females', 'ap-family-archive' ), number_format_i18n( $s['females'] ) . $pct( $s['females'] )),
            array(__( 'Sex unknown', 'ap-family-archive' ), number_format_i18n( $s['unknown_sex'] ) . $pct( $s['unknown_sex'] )),
            array(__( 'Living (protected)', 'ap-family-archive' ), number_format_i18n( $s['living'] )),
            array(__( 'Families', 'ap-family-archive' ), number_format_i18n( $s['families'] )),
            array(__( 'Unique surnames', 'ap-family-archive' ), number_format_i18n( $s['surnames'] )),
            array(__( 'Events recorded', 'ap-family-archive' ), number_format_i18n( $s['events'] )),
            array(__( 'Sources', 'ap-family-archive' ), number_format_i18n( $s['sources'] )),
            array(__( 'Places', 'ap-family-archive' ), number_format_i18n( $s['places'] ))
        );
        if ( null !== $s['avg_lifespan'] ) {
            /* translators: %s: number of years. */
            $rows[] = array(__( 'Average lifespan', 'ap-family-archive' ), sprintf( __( '%s years', 'ap-family-archive' ), number_format_i18n( $s['avg_lifespan'], 1 ) ));
        }
        $out = self::wrap_open( 'apfa-statistics' );
        $out .= $this->tree_switcher_html( $gedcom );
        $out .= '<table class="apfa-stats-table"><tbody>';
        foreach ( $rows as $r ) {
            $out .= '<tr><th scope="row">' . esc_html( $r[0] ) . '</th><td>' . esc_html( $r[1] ) . '</td></tr>';
        }
        if ( $s['earliest'] ) {
            $out .= '<tr><th scope="row">' . esc_html__( 'Earliest birth', 'ap-family-archive' ) . '</th><td>' . $this->person_link( $s['earliest'] ) . '</td></tr>';
        }
        if ( $s['latest'] ) {
            $out .= '<tr><th scope="row">' . esc_html__( 'Most recent birth (public)', 'ap-family-archive' ) . '</th><td>' . $this->person_link( $s['latest'] ) . '</td></tr>';
        }
        $out .= '</tbody></table>';
        return $out . '</div>';
    }

    // ── [apfaar_whatsnew] ───────────────────────────────────────────────────────
    /**
     * "What's New": recently added and updated people/families. The recency
     * window and per-category cap are owner-set (options), overridable per
     * shortcode via days="" and max="".
     *
     * @param  array $atts Shortcode attributes.
     * @return string
     */
    public function sc_whatsnew( $atts ) {
        $atts = (array) $atts;
        $gedcom = $this->gedcom_for( $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        $days = ( isset( $atts['days'] ) ? absint( $atts['days'] ) : (int) get_option( 'apfaar_whatsnew_days', 30 ) );
        $max = ( isset( $atts['max'] ) ? absint( $atts['max'] ) : (int) get_option( 'apfaar_whatsnew_max', 10 ) );
        $new = APFAAR_People::whats_new( $gedcom, $days, $max );
        $out = self::wrap_open( 'apfa-whatsnew' );
        $out .= $this->tree_switcher_html( $gedcom );
        /* translators: %d: number of days. */
        $out .= '<p class="apfa-count">' . esc_html( sprintf( __( 'Additions and changes from the last %d days.', 'ap-family-archive' ), $days ) ) . '</p>';
        $sections = array(
            'people_added'     => __( 'New people', 'ap-family-archive' ),
            'people_updated'   => __( 'Updated people', 'ap-family-archive' ),
            'families_added'   => __( 'New families', 'ap-family-archive' ),
            'families_updated' => __( 'Updated families', 'ap-family-archive' ),
        );
        $any = false;
        foreach ( $sections as $key => $title ) {
            if ( empty( $new[$key] ) ) {
                continue;
            }
            $any = true;
            $out .= '<h3>' . esc_html( $title ) . '</h3><ul class="apfa-people-list">';
            foreach ( $new[$key] as $row ) {
                if ( isset( $row->personID ) ) {
                    $out .= '<li>' . $this->person_link( $row ) . '</li>';
                } else {
                    $out .= '<li>' . $this->family_link( $gedcom, $row ) . '</li>';
                }
            }
            $out .= '</ul>';
        }
        if ( !$any ) {
            $out .= '<p class="apfa-empty">' . esc_html__( 'Nothing new in this period.', 'ap-family-archive' ) . '</p>';
        }
        return $out . '</div>';
    }

    /**
     * Family rendered as a link labelled by its partners (privacy-gated
     * through the same person path as everywhere else).
     *
     * @param  string $gedcom Tree identifier.
     * @param  object $family Family row.
     * @return string
     */
    private function family_link( $gedcom, $family ) {
        $names = array();
        foreach ( array($family->husband, $family->wife) as $ref ) {
            if ( !empty( $ref ) ) {
                $person = APFAAR_People::get_person_raw( $gedcom, $ref );
                if ( is_object( $person ) && APFAAR_Privacy::can_view( $person ) ) {
                    $names[] = APFAAR_People::display_name( $person );
                } else {
                    $names[] = APFAAR_Privacy::placeholder_label( $person );
                }
            }
        }
        $label = ( !empty( $names ) ? implode( ' & ', $names ) : $family->familyID );
        $url = self::page_url( 'family', array(
            'apfaar_fid' => $family->familyID,
        ) );
        return '<a href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a> <span class="apfa-record-id">' . esc_html( $family->familyID ) . '</span>';
    }

    // ── [apfaar_anniversaries] ──────────────────────────────────────────────────
    /**
     * Dates & anniversaries: births, deaths and marriages on a chosen day
     * and/or month. Defaults to today on first view.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_anniversaries( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        // The day/month filter is honoured only when the form's nonce verifies;
        // otherwise the view falls back to today (nothing is modified either way).
        $nf = ( isset( $_GET['apfaar_nf'] ) ? sanitize_text_field( wp_unslash( $_GET['apfaar_nf'] ) ) : '' );
        $verified = '' !== $nf && false !== wp_verify_nonce( $nf, 'apfaar_public_filter' );
        $submitted = $verified && isset( $_GET['apfaar_ann'] );
        $day = ( $submitted && isset( $_GET['apfaar_day'] ) ? min( 31, absint( $_GET['apfaar_day'] ) ) : 0 );
        $month = ( $submitted && isset( $_GET['apfaar_month'] ) ? min( 12, absint( $_GET['apfaar_month'] ) ) : 0 );
        if ( !$submitted ) {
            // First view: today (site timezone).
            $day = (int) current_time( 'j' );
            $month = (int) current_time( 'n' );
        }
        global $wp_locale;
        $out = self::wrap_open( 'apfa-anniversaries' );
        $out .= $this->tree_switcher_html( $gedcom );
        $out .= '<form class="apfa-ann-form" method="get" action="' . esc_url( self::page_url( 'anniversaries' ) ) . '">';
        $out .= wp_nonce_field(
            'apfaar_public_filter',
            'apfaar_nf',
            false,
            false
        );
        $out .= '<input type="hidden" name="apfaar_ann" value="1" />';
        $out .= '<label>' . esc_html__( 'Day', 'ap-family-archive' ) . ' <select name="apfaar_day"><option value="0">' . esc_html__( 'any', 'ap-family-archive' ) . '</option>';
        for ($d = 1; $d <= 31; $d++) {
            $out .= '<option value="' . $d . '"' . selected( $day, $d, false ) . '>' . $d . '</option>';
        }
        $out .= '</select></label> <label>' . esc_html__( 'Month', 'ap-family-archive' ) . ' <select name="apfaar_month"><option value="0">' . esc_html__( 'any', 'ap-family-archive' ) . '</option>';
        for ($m = 1; $m <= 12; $m++) {
            $out .= '<option value="' . $m . '"' . selected( $month, $m, false ) . '>' . esc_html( $wp_locale->get_month( $m ) ) . '</option>';
        }
        $out .= '</select></label> <button type="submit" class="apfa-search-btn">' . esc_html__( 'Show', 'ap-family-archive' ) . '</button></form>';
        if ( !$day && !$month ) {
            return $out . '<p class="apfa-empty">' . esc_html__( 'Choose a day and/or month.', 'ap-family-archive' ) . '</p></div>';
        }
        $ann = APFAAR_People::anniversaries( $gedcom, $day, $month );
        $sections = array(
            'births'    => __( 'Births', 'ap-family-archive' ),
            'deaths'    => __( 'Deaths', 'ap-family-archive' ),
            'marriages' => __( 'Marriages', 'ap-family-archive' ),
        );
        $any = false;
        foreach ( $sections as $key => $title ) {
            if ( empty( $ann[$key] ) ) {
                continue;
            }
            $any = true;
            $out .= '<h3>' . esc_html( $title ) . '</h3><ul class="apfa-people-list">';
            foreach ( $ann[$key] as $row ) {
                if ( 'marriages' === $key ) {
                    $date = ( !empty( $row->marrdate ) ? $row->marrdate : '' );
                    $out .= '<li>' . $this->family_link( $gedcom, $row ) . (( $date ? ' <span class="apfa-lifespan">' . esc_html( $date ) . '</span>' : '' )) . '</li>';
                } else {
                    $date = ( 'births' === $key ? $row->birthdate : $row->deathdate );
                    $out .= '<li>' . $this->person_link( $row, false ) . (( $date ? ' <span class="apfa-lifespan">' . esc_html( $date ) . '</span>' : '' )) . '</li>';
                }
            }
            $out .= '</ul>';
        }
        if ( !$any ) {
            $out .= '<p class="apfa-empty">' . esc_html__( 'No events on this date.', 'ap-family-archive' ) . '</p>';
        }
        return $out . '</div>';
    }

    // ── [apfaar_mostwanted] ─────────────────────────────────────────────────────
    /**
     * Most Wanted: the owner-curated "help us find out more" list.
     * Entries are managed on the admin screen; a wanted person who is
     * living/private is skipped entirely (fail closed).
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_mostwanted( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        $entries = get_option( 'apfaar_most_wanted', array() );
        $out = self::wrap_open( 'apfa-mostwanted' );
        $shown = 0;
        if ( is_array( $entries ) ) {
            $out .= '<ul class="apfa-mostwanted-list">';
            foreach ( $entries as $entry ) {
                $pid = ( isset( $entry['pid'] ) ? (string) $entry['pid'] : '' );
                $person = APFAAR_People::get_person( $gedcom, $pid );
                if ( !$person ) {
                    continue;
                }
                $shown++;
                $out .= '<li>' . $this->person_link( $person );
                if ( !empty( $entry['note'] ) ) {
                    $out .= '<p class="apfa-mostwanted-note">' . esc_html( $entry['note'] ) . '</p>';
                }
                $out .= '</li>';
            }
            $out .= '</ul>';
        }
        if ( !$shown ) {
            $out .= '<p class="apfa-empty">' . esc_html__( 'No wanted entries yet.', 'ap-family-archive' ) . '</p>';
        }
        return $out . '</div>';
    }

    // ── [apfaar_places] ─────────────────────────────────────────────────────────
    /**
     * Place browse (free-lite): letter bar + place list; each place links to
     * the search page pre-filtered on that place.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_places( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only public navigation parameter.
        $letter = ( isset( $_GET['apfaar_letter'] ) ? strtoupper( substr( sanitize_text_field( wp_unslash( $_GET['apfaar_letter'] ) ), 0, 1 ) ) : '' );
        $index = APFAAR_People::places_index( $gedcom, $letter );
        $out = self::wrap_open( 'apfa-places' );
        $out .= $this->tree_switcher_html( $gedcom );
        if ( empty( $index['letters'] ) ) {
            return $out . '<p class="apfa-empty">' . esc_html__( 'No places recorded yet.', 'ap-family-archive' ) . '</p></div>';
        }
        $out .= '<nav class="apfa-az">';
        foreach ( $index['letters'] as $az ) {
            $class = ( $az === $letter ? ' class="apfa-az-current"' : '' );
            $out .= '<a' . $class . ' href="' . esc_url( add_query_arg( 'apfaar_letter', rawurlencode( $az ) ) ) . '">' . esc_html( $az ) . '</a> ';
        }
        if ( '' !== $letter ) {
            $out .= '<a href="' . esc_url( remove_query_arg( 'apfaar_letter' ) ) . '">' . esc_html__( 'All', 'ap-family-archive' ) . '</a>';
        }
        $out .= '</nav>';
        $out .= '<ul class="apfa-surname-list apfa-place-list">';
        foreach ( $index['places'] as $place ) {
            $url = ( self::has_page( 'search' ) ? self::page_url( 'search', array(
                'apfaar_place' => $place,
            ) ) : '' );
            $out .= '<li>' . (( $url ? '<a href="' . esc_url( $url ) . '">' . esc_html( $place ) . '</a>' : esc_html( $place ) )) . '</li>';
        }
        $out .= '</ul>';
        return $out . '</div>';
    }

    // ── [apfaar_source] ─────────────────────────────────────────────────────────
    /**
     * Source detail page: bibliographic detail, repository, transcript, and
     * the records that cite it ("cited by").
     *
     * Privacy: non-viewable people are EXCLUDED from the cited-by list
     * entirely (like indexes/search - not even a placeholder), and a family
     * flagged living/private is skipped the same way.
     *
     * @param  array $atts Shortcode attributes (tree optional).
     * @return string
     */
    public function sc_source( $atts ) {
        $gedcom = $this->gedcom_for( (array) $atts );
        if ( '' === $gedcom ) {
            return $this->unconfigured_notice();
        }
        if ( !class_exists( 'AncestorPress\\Core\\Sources_Table' ) ) {
            return '';
        }
        $sid = self::param( 'apfaar_sid' );
        if ( '' === $sid ) {
            return '<p class="apfa-empty">' . esc_html__( 'No source selected.', 'ap-family-archive' ) . '</p>';
        }
        $source = \AncestorPress\Core\Sources_Table::get_by_ref( $gedcom, $sid );
        if ( !$source ) {
            return '<p class="apfa-empty">' . esc_html__( 'This source is not available.', 'ap-family-archive' ) . '</p>';
        }
        $title = ( '' !== trim( (string) $source->title ) ? $source->title : $source->sourceID );
        $out = self::wrap_open( 'apfa-source' );
        $out .= '<h2 class="apfa-source-heading">' . esc_html( $title ) . ' <span class="apfa-record-id">' . esc_html( $source->sourceID ) . '</span></h2>';
        // Bibliographic detail (only fields with values render).
        $detail = array(
            __( 'Author', 'ap-family-archive' )      => (string) ($source->author ?? ''),
            __( 'Publisher', 'ap-family-archive' )   => (string) ($source->publisher ?? ''),
            __( 'Published', 'ap-family-archive' )   => (string) ($source->pubdate ?? ''),
            __( 'Call number', 'ap-family-archive' ) => (string) ($source->callnum ?? ''),
        );
        if ( !empty( $source->repoID ) && class_exists( 'AncestorPress\\Core\\Repositories_Table' ) ) {
            $repo = \AncestorPress\Core\Repositories_Table::get_by_ref( $gedcom, $source->repoID );
            if ( $repo ) {
                $where = implode( ', ', array_filter( array((string) $repo->city, (string) $repo->country) ) );
                $detail[__( 'Repository', 'ap-family-archive' )] = trim( (string) $repo->reponame . (( '' !== $where ? ' (' . $where . ')' : '' )) );
            }
        }
        $rows = '';
        foreach ( $detail as $label => $value ) {
            $value = trim( $value );
            if ( '' === $value ) {
                continue;
            }
            $rows .= '<li class="apfa-vital"><span class="apfa-vital-label">' . esc_html( $label ) . '</span> ' . esc_html( $value ) . '</li>';
        }
        if ( '' !== $rows ) {
            $out .= '<ul class="apfa-vitals apfa-source-detail">' . $rows . '</ul>';
        }
        if ( !empty( $source->actualtext ) ) {
            $out .= '<h3>' . esc_html__( 'Transcript / extract', 'ap-family-archive' ) . '</h3>' . '<blockquote class="apfa-source-text">' . esc_html( $source->actualtext ) . '</blockquote>';
        }
        // Cited by: every viewable person/family with a citation of this
        // source (one entry per record, however many citations it holds).
        $citations = ( class_exists( 'AncestorPress\\Core\\Citations_Table' ) ? (array) \AncestorPress\Core\Citations_Table::get_by_source( $gedcom, $source->sourceID ) : array() );
        $items = array();
        foreach ( $citations as $cit ) {
            $ref = (string) $cit->persfamID;
            if ( '' === $ref || isset( $items[$ref] ) ) {
                continue;
            }
            $person = APFAAR_People::get_person_raw( $gedcom, $ref );
            if ( $person ) {
                if ( APFAAR_Privacy::can_view( $person ) ) {
                    $items[$ref] = '<li>' . $this->person_link( $person ) . '</li>';
                }
                continue;
            }
            $family = APFAAR_People::get_family( $gedcom, $ref );
            if ( $family ) {
                if ( (!empty( $family->living ) || !empty( $family->private )) && !APFAAR_Privacy::viewer_is_privileged() ) {
                    continue;
                }
                $items[$ref] = '<li>' . $this->family_link( $gedcom, $family ) . '</li>';
            }
        }
        if ( !empty( $items ) ) {
            $out .= '<h3>' . esc_html__( 'Cited by', 'ap-family-archive' ) . '</h3><ul class="apfa-people-list">' . implode( '', $items ) . '</ul>';
        }
        return $out . '</div>';
    }

}
