<?php
/**
 * ANCP Core - GEDCOM Exporter
 *
 * Generates GEDCOM 5.5.1 content from the core relational tables and returns it
 * as a string. Moved from AP Family Trees into the ANCP Core Library (spec-01
 * item 4) so every suite plugin exports through one code path.
 *
 * GEDCOM spec reference: https://gedcom.io/specifications/FamilySearchGEDCOMv7.html
 * (We target 5.5.1 for maximum software compatibility.)
 *
 * @package AncestorPress\Core
 * @since   0.3.0
 */

namespace AncestorPress\Core;

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

/**
 * GEDCOM 5.5.1 exporter.
 *
 * Usage: Gedcom_Exporter::export( $gedcom_slug )
 * Returns GEDCOM content as a string on success, WP_Error on failure.
 * The caller is responsible for streaming the string to the browser.
 *
 * @since 0.0.1
 */
class Gedcom_Exporter {

	/**
	 * Maximum GEDCOM line length in characters (spec: 255; we stay under for safety).
	 *
	 * @since 0.0.1
	 * @var int
	 */
	const MAX_LINE_LENGTH = 248;

	/**
	 * GEDCOM event tags that are stored as direct columns on apft_people
	 * and must not also be written from the events table.
	 *
	 * @since 0.0.1
	 * @var array
	 */
	const PERSON_DIRECT_TAGS = array( 'BIRT', 'DEAT', 'BURI', 'CREM', 'BAPM', 'CONF' );

	/**
	 * GEDCOM event tags stored as direct columns on apft_families.
	 *
	 * @since 0.0.1
	 * @var array
	 */
	const FAMILY_DIRECT_TAGS = array( 'MARR', 'DIV' );

	// ── Instance state ────────────────────────────────────────────────────────

	/** @var string Tree identifier (gedcom slug). */
	private $gedcom_slug;

	/** @var array Media rows for the tree, in insertion order (0.9.0). */
	private $media_rows = array();

	/** @var array Media links: linktype -> persfamID -> link rows, display-ordered (0.9.0). */
	private $media_links = array();

	/**
	 * Subset export (0.10.0): personID => true for the people to include,
	 * or null for the whole tree (the default; that path is byte-identical
	 * to before the option existed).
	 *
	 * @var array|null
	 */
	private $subset = null;

	/** @var array familyID => true for families included by the subset (0.10.0). */
	private $included_fams = array();

	/**
	 * familyID => true for families kept ONLY through an included child - no
	 * included spouse (0.10.0). These are written as pointer skeletons (CHIL
	 * lines only): the marriage data, events, notes, citations and media all
	 * belong to the excluded couple and must leave no trace in the export.
	 *
	 * @var array
	 */
	private $skeleton_fams = array();

	/**
	 * Optional media row filter (0.10.0): callable( object $media_row ): bool,
	 * false = leave the row (and its links) out of the export. The privacy
	 * filter hook for callers whose export surface must exclude media the
	 * caller's own rules deem unreachable (core stays privacy-agnostic; the
	 * display plugin owns the policy).
	 *
	 * @var callable|null
	 */
	private $media_filter = null;

	// ── Public API ────────────────────────────────────────────────────────────

	/**
	 * Export a tree as GEDCOM 5.5.1 content.
	 *
	 * Generates the GEDCOM entirely in memory using a php://temp stream and
	 * returns the complete file content as a string. No files are written to
	 * the public filesystem.
	 *
	 * Subset option (0.10.0, additive): shared subset-export support used by
	 * any suite consumer that exports a selected set of records.
	 * $args['person_ids'] = array of personIDs to include. Families are kept
	 * when any partner or child is in the set, with pointers to excluded
	 * records stripped; events/notes/citations follow their record; sources,
	 * repositories and media shrink to what the included records actually
	 * reference. The CALLER computes the person set (and owns any privacy
	 * policy - e.g. a visitor surface excludes living/private people before
	 * calling); core does the consistent writing. $args['media_filter'] is
	 * an optional callable( object $media_row ): bool for callers that must
	 * additionally drop media rows (privacy filter hook). Without $args the
	 * output is byte-identical to previous releases.
	 *
	 * @since  0.0.1
	 * @param  string $gedcom_slug Tree identifier.
	 * @param  array  $args        Optional: person_ids (string[]), media_filter (callable).
	 * @return string|WP_Error    GEDCOM content string on success, WP_Error on failure.
	 */
	public static function export( $gedcom_slug, $args = array() ) {
		$instance = new self( sanitize_text_field( $gedcom_slug ) );
		if ( ! empty( $args['person_ids'] ) && is_array( $args['person_ids'] ) ) {
			$instance->subset = array_fill_keys( array_map( 'strval', array_values( $args['person_ids'] ) ), true );
		}
		if ( isset( $args['media_filter'] ) && is_callable( $args['media_filter'] ) ) {
			$instance->media_filter = $args['media_filter'];
		}
		return $instance->run();
	}

	// ── Constructor ───────────────────────────────────────────────────────────

	/**
	 * Constructor.
	 *
	 * @since 0.0.1
	 * @param string $gedcom_slug Sanitized tree identifier.
	 */
	private function __construct( $gedcom_slug ) {
		$this->gedcom_slug = $gedcom_slug;
	}

	// ── Main export logic ─────────────────────────────────────────────────────

	/**
	 * Run the full export, building the GEDCOM content in memory.
	 *
	 * Uses a php://temp stream (memory up to 2 MB, then a PHP-managed temp file)
	 * so that very large trees do not exhaust available RAM while still avoiding
	 * any writes to the public web-accessible filesystem.
	 *
	 * @since  0.0.1
	 * @return string|WP_Error GEDCOM file content on success, WP_Error on failure.
	 */
	private function run() {
		// Validate tree.
		$tree = Trees_Table::get_by_gedcom( $this->gedcom_slug );
		if ( ! $tree ) {
			return new \WP_Error(
				'apft_export_not_found',
				'Tree not found.'
			);
		}

		// Load all data into memory-efficient lookups.
		$people   = People_Table::get_by_tree( $this->gedcom_slug, array( 'include_living' => true ) );
		$families = Families_Table::get_by_tree( $this->gedcom_slug );

		// Index events by persfamID for O(1) lookup during write.
		$events = array();
		foreach ( Events_Table::get_by_tree( $this->gedcom_slug ) as $ev ) {
			$events[ $ev->persfamID ][] = $ev;
		}

		// Index children by familyID (ordered by ordernum from DB).
		$children = array();
		// Index children by personID for FAMC links.
		$famc = array();
		foreach ( Children_Table::get_by_tree( $this->gedcom_slug ) as $ch ) {
			$children[ $ch->familyID ][] = $ch->personID;
			$famc[ $ch->personID ][]     = $ch->familyID;
		}

		// Subset export (0.10.0): shrink every lookup consistently so no
		// pointer to an excluded record survives. Skipped entirely for the
		// whole-tree export - that path must stay byte-identical.
		if ( null !== $this->subset ) {
			$people = array_values( array_filter( $people, function ( $p ) {
				return isset( $this->subset[ $p->personID ] );
			} ) );

			// Children lists keep only subset members; a family stays when
			// any partner or (remaining) child is in the set.
			foreach ( $children as $fam_id => $kids ) {
				$children[ $fam_id ] = array_values( array_filter( $kids, function ( $kid ) {
					return isset( $this->subset[ $kid ] );
				} ) );
			}
			$this->included_fams = array();
			$this->skeleton_fams = array();
			$families            = array_values( array_filter( $families, function ( $fam ) use ( $children ) {
				$spouse_in = ( ! empty( $fam->husband ) && isset( $this->subset[ $fam->husband ] ) )
					|| ( ! empty( $fam->wife ) && isset( $this->subset[ $fam->wife ] ) );
				$keep      = $spouse_in || ! empty( $children[ $fam->familyID ] );
				if ( $keep ) {
					$this->included_fams[ $fam->familyID ] = true;
					if ( ! $spouse_in ) {
						$this->skeleton_fams[ $fam->familyID ] = true;
					}
				}
				return $keep;
			} ) );

			// FAMC lookup keeps only included families (the direct famc
			// column fallback is guarded at write time).
			foreach ( $famc as $pid => $fam_ids ) {
				$famc[ $pid ] = array_values( array_filter( $fam_ids, function ( $fid ) {
					return isset( $this->included_fams[ $fid ] );
				} ) );
			}
		}

		// Build FAMS map: personID -> array of familyIDs where person is husband or wife.
		$fams = array();
		foreach ( $families as $fam ) {
			if ( ! empty( $fam->husband ) ) {
				$fams[ $fam->husband ][] = $fam->familyID;
			}
			if ( ! empty( $fam->wife ) ) {
				$fams[ $fam->wife ][] = $fam->familyID;
			}
		}

		// Index notes by persfamID.
		$notes = array();
		foreach ( Note_Links_Table::get_by_tree( $this->gedcom_slug ) as $nl ) {
			if ( ! empty( $nl->note ) ) {
				$notes[ $nl->persfamID ][] = $nl->note;
			}
		}

		// Custom field values indexed by personID.
		$custom_values = Field_Values_Table::get_all_for_tree( $this->gedcom_slug );

		// Sources extension (0.7.0): sources + repositories emitted as records.
		// Citations split by attachment (0.8.0): persfam-level ones indexed by
		// persfamID and emitted at level 1 inside INDI/FAM (as before); event-
		// level ones indexed by eventID and emitted at level 2 inside their
		// event block. Class guards keep the exporter working if an older
		// bundle's loader ran.
		$sources         = class_exists( 'AncestorPress\Core\Sources_Table' ) ? Sources_Table::get_by_tree( $this->gedcom_slug ) : array();
		$repositories    = class_exists( 'AncestorPress\Core\Repositories_Table' ) ? Repositories_Table::get_by_tree( $this->gedcom_slug ) : array();
		$citations       = array();
		$event_citations = array();
		if ( class_exists( 'AncestorPress\Core\Citations_Table' ) ) {
			global $wpdb;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- identifier via %i, value via %s.
			foreach ( $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE gedcom = %s ORDER BY ordernum ASC, id ASC', Tables::citations(), $this->gedcom_slug ) ) as $cit ) {
				if ( ! empty( $cit->eventID ) ) {
					$event_citations[ $cit->eventID ][] = $cit;
				} else {
					$citations[ $cit->persfamID ][] = $cit;
				}
			}
		}

		// Media extension (0.9.0): media rows emitted as 0-level OBJE records,
		// person/family links as OBJE pointers - only when present, so exports
		// of media-less trees stay byte-identical. Links are ordered defphoto
		// first (GEDCOM has no standard flag; first-listed is the convention
		// the importer reads back), then ordernum.
		$this->media_rows  = array();
		$this->media_links = array();
		if ( class_exists( 'AncestorPress\Core\Media' ) && method_exists( 'AncestorPress\Core\Media', 'get_by_tree' ) ) {
			$this->media_rows = Media::get_by_tree( $this->gedcom_slug );
			if ( ! empty( $this->media_rows ) ) {
				global $wpdb;
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- identifier via %i, value via %s.
				foreach ( $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE gedcom = %s AND linktype IN ('person','family') ORDER BY defphoto DESC, ordernum ASC, id ASC", Tables::media_links(), $this->gedcom_slug ) ) as $ml ) {
					$this->media_links[ $ml->linktype ][ $ml->persfamID ][] = $ml;
				}
			}
		}

		// Subset export (0.10.0): sources, repositories and media shrink to
		// what the included records actually reference.
		if ( null !== $this->subset ) {
			// Citations of excluded records go; event-level citations follow
			// their record's events. Skeleton families count as excluded here:
			// their body content (incl. citations) is never written.
			$body_refs = $this->subset + array_diff_key( $this->included_fams, $this->skeleton_fams );
			$citations = array_intersect_key( $citations, $body_refs );
			$included_event_ids = array();
			foreach ( $body_refs as $ref => $unused ) {
				foreach ( $events[ $ref ] ?? array() as $ev ) {
					if ( ! empty( $ev->eventID ) ) {
						$included_event_ids[ $ev->eventID ] = true;
					}
				}
			}
			$event_citations = array_intersect_key( $event_citations, $included_event_ids );

			// Only sources still cited, and only repositories those sources use.
			$used_sources = array();
			foreach ( array_merge( $citations, $event_citations ) as $rows ) {
				foreach ( $rows as $cit ) {
					if ( ! empty( $cit->sourceID ) ) {
						$used_sources[ $cit->sourceID ] = true;
					}
				}
			}
			$sources    = array_values( array_filter( $sources, static function ( $s ) use ( $used_sources ) {
				return isset( $used_sources[ $s->sourceID ] );
			} ) );
			$used_repos = array();
			foreach ( $sources as $s ) {
				if ( ! empty( $s->repoID ) ) {
					$used_repos[ $s->repoID ] = true;
				}
			}
			$repositories = array_values( array_filter( $repositories, static function ( $r ) use ( $used_repos ) {
				return isset( $used_repos[ $r->repoID ] );
			} ) );

			// Media links only to included records (skeleton families are
			// excluded - their media belongs to the excluded couple); media
			// rows only when a link survives (unlinked media is not part of
			// any branch).
			foreach ( $this->media_links as $linktype => $by_target ) {
				$keep_targets = ( 'family' === $linktype )
					? array_diff_key( $this->included_fams, $this->skeleton_fams )
					: $this->subset;
				$this->media_links[ $linktype ] = array_intersect_key( $by_target, $keep_targets );
			}
			$linked = array();
			foreach ( $this->media_links as $by_target ) {
				foreach ( $by_target as $rows ) {
					foreach ( $rows as $ml ) {
						$linked[ $ml->mediaID ] = true;
					}
				}
			}
			$this->media_rows = array_values( array_filter( $this->media_rows, static function ( $m ) use ( $linked ) {
				return isset( $linked[ $m->mediaID ] );
			} ) );
		}

		// Media privacy filter hook (0.10.0): the caller's policy decides;
		// a dropped row loses its links too so no dangling OBJE pointer is
		// ever written.
		if ( null !== $this->media_filter && ! empty( $this->media_rows ) ) {
			$dropped = array();
			$this->media_rows = array_values( array_filter( $this->media_rows, function ( $m ) use ( &$dropped ) {
				$keep = (bool) call_user_func( $this->media_filter, $m );
				if ( ! $keep ) {
					$dropped[ $m->mediaID ] = true;
				}
				return $keep;
			} ) );
			if ( $dropped ) {
				foreach ( $this->media_links as $linktype => $by_target ) {
					foreach ( $by_target as $target => $rows ) {
						$this->media_links[ $linktype ][ $target ] = array_values( array_filter( $rows, static function ( $ml ) use ( $dropped ) {
							return ! isset( $dropped[ $ml->mediaID ] );
						} ) );
					}
				}
			}
		}

		// Open an in-memory stream. php://temp spills to a PHP-managed temp file
		// above 2 MB so that large trees do not exhaust PHP memory.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- php://temp is an in-memory stream, not a filesystem file.
		$fh = fopen( 'php://temp', 'w+' );
		if ( ! $fh ) {
			return new \WP_Error(
				'apft_export_stream_failed',
				'Could not open in-memory stream for export.'
			);
		}

		// Derive a display filename for the GEDCOM HEAD > FILE tag.
		$display_filename = 'family-tree-' . sanitize_file_name( $this->gedcom_slug )
		                    . '-' . gmdate( 'Y-m-d' ) . '.ged';

		// Write GEDCOM header.
		$this->write_header( $fh, $tree, $display_filename );

		// Write INDI records.
		foreach ( $people as $person ) {
			$this->write_individual( $fh, $person, $fams, $famc, $events, $notes, $custom_values, $citations, $event_citations );
		}

		// Write FAM records.
		foreach ( $families as $fam ) {
			$this->write_family( $fh, $fam, $children[ $fam->familyID ] ?? array(), $events, $notes, $citations, $event_citations );
		}

		// Write SOUR + REPO records (0.7.0; emitted only when present, so
		// exports of source-less trees are byte-identical to before).
		foreach ( $sources as $source ) {
			$this->write_source( $fh, $source );
		}
		foreach ( $repositories as $repository ) {
			$this->write_repository( $fh, $repository );
		}

		// Write 0-level OBJE multimedia records (0.9.0; only when present).
		foreach ( $this->media_rows as $media_row ) {
			$this->write_media_object( $fh, $media_row );
		}

		// Trailer.
		$this->write_line( $fh, '0 TRLR' );

		// Read back the full stream content.
		rewind( $fh );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading from php://temp stream handle, not a filesystem file.
		$content = stream_get_contents( $fh );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- matches fopen above.
		fclose( $fh );

		return false === $content
			? new \WP_Error( 'apft_export_read_failed', 'Could not read generated GEDCOM content.' )
			: $content;
	}

	// ── Record writers ────────────────────────────────────────────────────────

	/**
	 * Write the GEDCOM HEAD record.
	 *
	 * @since  0.0.1
	 * @param  resource $fh       File handle.
	 * @param  object   $tree     apft_trees row.
	 * @param  string   $filename Export filename.
	 * @return void
	 */
	private function write_header( $fh, $tree, $filename ) {
		$this->write_line( $fh, '0 HEAD' );
		$this->write_line( $fh, '1 SOUR AP Family Trees' );
		$this->write_line( $fh, '2 VERS ' . APFT_VERSION );
		$this->write_line( $fh, '2 NAME AP Family Trees' );
		$this->write_line( $fh, '2 CORP AP Family Trees' );
		$this->write_line( $fh, '3 WWW https://ancestorpress.com/' );
		$this->write_line( $fh, '1 GEDC' );
		$this->write_line( $fh, '2 VERS 5.5.1' );
		$this->write_line( $fh, '2 FORM LINEAGE-LINKED' );
		$this->write_line( $fh, '1 CHAR UTF-8' );
		$this->write_line( $fh, '1 DATE ' . strtoupper( gmdate( 'j M Y' ) ) );
		$this->write_tagged_value( $fh, 1, 'FILE', $filename );
		if ( ! empty( $tree->treename ) ) {
			$this->write_tagged_value( $fh, 1, 'NOTE', 'Tree: ' . $tree->treename );
		}
	}

	/**
	 * Write a single INDI (individual) record.
	 *
	 * @since  0.0.1
	 * @param  resource $fh           File handle.
	 * @param  object   $person       apft_people row.
	 * @param  array    $fams         FAMS lookup: personID -> familyIDs[].
	 * @param  array    $famc         FAMC lookup: personID -> familyIDs[].
	 * @param  array    $events       Events lookup: persfamID -> event objects[].
	 * @param  array    $notes        Notes lookup: persfamID -> note strings[].
	 * @param  array    $custom_values Custom field values: personID -> [ key => value ].
	 * @param  array    $citations       Persfam-level citations: persfamID -> rows[].
	 * @param  array    $event_citations Event-level citations: eventID -> rows[] (0.8.0).
	 * @return void
	 */
	private function write_individual( $fh, $person, $fams, $famc, $events, $notes, $custom_values = array(), $citations = array(), $event_citations = array() ) {
		$person_events = $events[ $person->personID ] ?? array();
		$emitted       = array(); // eventIDs whose citations have been written.

		$this->write_line( $fh, '0 @' . $person->personID . '@ INDI' );

		// Name block.
		$name_val = $this->build_name( $person );
		$this->write_tagged_value( $fh, 1, 'NAME', $name_val );
		if ( ! empty( $person->title ) )     $this->write_tagged_value( $fh, 2, 'NPFX', $person->title );
		if ( ! empty( $person->firstname ) ) $this->write_tagged_value( $fh, 2, 'GIVN', $person->firstname );
		if ( ! empty( $person->nickname ) )  $this->write_tagged_value( $fh, 2, 'NICK', $person->nickname );
		if ( ! empty( $person->lnprefix ) )  $this->write_tagged_value( $fh, 2, 'SPFX', $person->lnprefix );
		if ( ! empty( $person->lastname ) )  $this->write_tagged_value( $fh, 2, 'SURN', $person->lastname );
		if ( ! empty( $person->suffix ) )    $this->write_tagged_value( $fh, 2, 'NSFX', $person->suffix );

		// Sex.
		$sex = in_array( strtoupper( $person->sex ?? '' ), array( 'M', 'F' ), true )
			? strtoupper( $person->sex )
			: 'U';
		$this->write_line( $fh, '1 SEX ' . $sex );

		// Birth (primary).
		if ( ! empty( $person->birthdate ) || ! empty( $person->birthplace ) ) {
			$this->write_line( $fh, '1 BIRT' );
			if ( ! empty( $person->birthdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $person->birthdate );
			if ( ! empty( $person->birthplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $person->birthplace );
			$this->write_direct_event_citations( $fh, $person_events, array( 'BIRT' ), $event_citations, $emitted );
		}

		// Alt birth.
		if ( ! empty( $person->altbirthdate ) || ! empty( $person->altbirthplace ) ) {
			$this->write_line( $fh, '1 BIRT' );
			if ( ! empty( $person->altbirthdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $person->altbirthdate );
			if ( ! empty( $person->altbirthplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $person->altbirthplace );
		}

		// Death.
		if ( ! empty( $person->deathdate ) || ! empty( $person->deathplace ) ) {
			$this->write_line( $fh, '1 DEAT' );
			if ( ! empty( $person->deathdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $person->deathdate );
			if ( ! empty( $person->deathplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $person->deathplace );
			$this->write_direct_event_citations( $fh, $person_events, array( 'DEAT' ), $event_citations, $emitted );
		}

		// Burial / Cremation.
		if ( ! empty( $person->burialdate ) || ! empty( $person->burialplace ) ) {
			$burial_tag = ( 1 === (int) ( $person->burialtype ?? 0 ) ) ? 'CREM' : 'BURI';
			$this->write_line( $fh, '1 ' . $burial_tag );
			if ( ! empty( $person->burialdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $person->burialdate );
			if ( ! empty( $person->burialplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $person->burialplace );
			$this->write_direct_event_citations( $fh, $person_events, array( 'BURI', 'CREM' ), $event_citations, $emitted );
		}

		// Baptism.
		if ( ! empty( $person->baptdate ) || ! empty( $person->baptplace ) ) {
			$this->write_line( $fh, '1 BAPM' );
			if ( ! empty( $person->baptdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $person->baptdate );
			if ( ! empty( $person->baptplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $person->baptplace );
			// BAPM only: CHR events keep their own events-table emission below.
			$this->write_direct_event_citations( $fh, $person_events, array( 'BAPM' ), $event_citations, $emitted );
		}

		// Confirmation.
		if ( ! empty( $person->confdate ) || ! empty( $person->confplace ) ) {
			$this->write_line( $fh, '1 CONF' );
			if ( ! empty( $person->confdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $person->confdate );
			if ( ! empty( $person->confplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $person->confplace );
			$this->write_direct_event_citations( $fh, $person_events, array( 'CONF' ), $event_citations, $emitted );
		}

		// Additional events from events table (skip types already written from direct columns).
		foreach ( $person_events as $ev ) {
			$ev_type = strtoupper( $ev->eventtypeID );
			if ( in_array( $ev_type, self::PERSON_DIRECT_TAGS, true ) ) {
				continue;
			}
			$this->write_line( $fh, '1 ' . $ev_type );
			if ( ! empty( $ev->eventdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $ev->eventdate );
			if ( ! empty( $ev->eventplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $ev->eventplace );
			if ( ! empty( $ev->age ) )         $this->write_tagged_value( $fh, 2, 'AGE', $ev->age );
			if ( ! empty( $ev->cause ) )       $this->write_tagged_value( $fh, 2, 'CAUS', $ev->cause );
			if ( ! empty( $ev->info ) )        $this->write_tagged_value( $fh, 2, 'NOTE', $ev->info );
			$this->write_event_citations( $fh, $ev, $event_citations, $emitted );
		}

		// FAMS (families where this person is a spouse).
		if ( ! empty( $fams[ $person->personID ] ) ) {
			foreach ( $fams[ $person->personID ] as $fam_id ) {
				$this->write_line( $fh, '1 FAMS @' . $fam_id . '@' );
			}
		}

		// FAMC (families where this person is a child).
		if ( ! empty( $famc[ $person->personID ] ) ) {
			foreach ( $famc[ $person->personID ] as $fam_id ) {
				$this->write_line( $fh, '1 FAMC @' . $fam_id . '@' );
			}
		} elseif ( ! empty( $person->famc ) && ( null === $this->subset || isset( $this->included_fams[ $person->famc ] ) ) ) {
			// Fallback: use the direct famc column if children table has no
			// entry (subset export: only when that family is included).
			$this->write_line( $fh, '1 FAMC @' . $person->famc . '@' );
		}

		// Media links (0.9.0): OBJE pointers, defphoto first.
		$this->write_media_refs( $fh, 'person', $person->personID );

		// Privacy / confidentiality restriction.
		if ( ! empty( $person->private ) ) {
			$this->write_line( $fh, '1 RESN privacy' );
		}

		// Custom field values (stored as _APFT_fieldkey custom tags).
		if ( ! empty( $custom_values[ $person->personID ] ) ) {
			foreach ( $custom_values[ $person->personID ] as $field_key => $field_value ) {
				if ( '' === (string) $field_value ) {
					continue;
				}
				$tag = '_APFT_' . strtoupper( $field_key );
				$this->write_tagged_value( $fh, 1, $tag, $field_value );
			}
		}

		// Source citations (0.7.0): persfam-level, at level 1.
		if ( ! empty( $citations[ $person->personID ] ) ) {
			foreach ( $citations[ $person->personID ] as $cit ) {
				$this->write_citation( $fh, $cit );
			}
		}

		// Safety net (0.8.0): event-attached citations whose event block was
		// not written (e.g. a direct-tag event with no date/place) fall back
		// to level 1 so no citation is ever dropped from an export.
		$this->write_leftover_event_citations( $fh, $person_events, $event_citations, $emitted );

		// Notes.
		if ( ! empty( $notes[ $person->personID ] ) ) {
			foreach ( $notes[ $person->personID ] as $note_text ) {
				$this->write_tagged_value( $fh, 1, 'NOTE', $note_text );
			}
		}
	}

	/**
	 * Write a single FAM (family) record.
	 *
	 * @since  0.0.1
	 * @param  resource $fh        File handle.
	 * @param  object   $fam       apft_families row.
	 * @param  array    $child_ids Array of personIDs who are children.
	 * @param  array    $events    Events lookup: persfamID -> event objects[].
	 * @param  array    $notes     Notes lookup: persfamID -> note strings[].
	 * @param  array    $citations       Persfam-level citations: persfamID -> rows[].
	 * @param  array    $event_citations Event-level citations: eventID -> rows[] (0.8.0).
	 * @return void
	 */
	private function write_family( $fh, $fam, $child_ids, $events, $notes, $citations = array(), $event_citations = array() ) {
		$family_events = $events[ $fam->familyID ] ?? array();
		$emitted       = array(); // eventIDs whose citations have been written.

		$this->write_line( $fh, '0 @' . $fam->familyID . '@ FAM' );

		// Subset export (0.10.0): a family kept only through an included
		// child is a pointer skeleton - CHIL lines and nothing else. The
		// marriage data, events, notes and citations belong to the excluded
		// couple and must leave no trace.
		if ( null !== $this->subset && isset( $this->skeleton_fams[ $fam->familyID ] ) ) {
			foreach ( $child_ids as $child_personID ) {
				$this->write_line( $fh, '1 CHIL @' . $child_personID . '@' );
			}
			return;
		}

		// Subset export (0.10.0): pointers to excluded people are stripped.
		$husband_ok = ! empty( $fam->husband ) && ( null === $this->subset || isset( $this->subset[ $fam->husband ] ) );
		$wife_ok    = ! empty( $fam->wife ) && ( null === $this->subset || isset( $this->subset[ $fam->wife ] ) );
		if ( $husband_ok ) $this->write_line( $fh, '1 HUSB @' . $fam->husband . '@' );
		if ( $wife_ok )    $this->write_line( $fh, '1 WIFE @' . $fam->wife . '@' );

		// Children.
		foreach ( $child_ids as $child_personID ) {
			$this->write_line( $fh, '1 CHIL @' . $child_personID . '@' );
		}

		// Marriage.
		if ( ! empty( $fam->marrdate ) || ! empty( $fam->marrplace ) ) {
			$this->write_line( $fh, '1 MARR' );
			if ( ! empty( $fam->marrdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $fam->marrdate );
			if ( ! empty( $fam->marrplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $fam->marrplace );
			if ( ! empty( $fam->marrtype ) )  $this->write_tagged_value( $fh, 2, 'TYPE', $fam->marrtype );
		}

		// Divorce.
		if ( ! empty( $fam->divdate ) || ! empty( $fam->divplace ) ) {
			$this->write_line( $fh, '1 DIV' );
			if ( ! empty( $fam->divdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $fam->divdate );
			if ( ! empty( $fam->divplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $fam->divplace );
		}

		// Sealing (LDS).
		if ( ! empty( $fam->sealdate ) || ! empty( $fam->sealplace ) ) {
			$this->write_line( $fh, '1 SLGS' );
			if ( ! empty( $fam->sealdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $fam->sealdate );
			if ( ! empty( $fam->sealplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $fam->sealplace );
		}

		// Additional family events.
		foreach ( $family_events as $ev ) {
			$ev_type = strtoupper( $ev->eventtypeID );
			if ( in_array( $ev_type, self::FAMILY_DIRECT_TAGS, true ) ) {
				continue;
			}
			$this->write_line( $fh, '1 ' . $ev_type );
			if ( ! empty( $ev->eventdate ) )  $this->write_tagged_value( $fh, 2, 'DATE', $ev->eventdate );
			if ( ! empty( $ev->eventplace ) ) $this->write_tagged_value( $fh, 2, 'PLAC', $ev->eventplace );
			if ( ! empty( $ev->info ) )       $this->write_tagged_value( $fh, 2, 'NOTE', $ev->info );
			$this->write_event_citations( $fh, $ev, $event_citations, $emitted );
		}

		// Media links (0.9.0): OBJE pointers, defphoto first.
		$this->write_media_refs( $fh, 'family', $fam->familyID );

		// Source citations (0.7.0): persfam-level, at level 1.
		if ( ! empty( $citations[ $fam->familyID ] ) ) {
			foreach ( $citations[ $fam->familyID ] as $cit ) {
				$this->write_citation( $fh, $cit );
			}
		}

		// Safety net (0.8.0): see write_individual.
		$this->write_leftover_event_citations( $fh, $family_events, $event_citations, $emitted );

		// Notes.
		if ( ! empty( $notes[ $fam->familyID ] ) ) {
			foreach ( $notes[ $fam->familyID ] as $note_text ) {
				$this->write_tagged_value( $fh, 1, 'NOTE', $note_text );
			}
		}
	}

	// ── Name builder ──────────────────────────────────────────────────────────

	/**
	 * Build a GEDCOM NAME value from a person row.
	 *
	 * GEDCOM convention: "Given /Surname/" with surname in forward slashes.
	 *
	 * @since  0.0.1
	 * @param  object $person apft_people row.
	 * @return string         GEDCOM NAME value.
	 */
	private function build_name( $person ) {
		$parts = array();

		if ( ! empty( $person->title ) ) {
			$parts[] = $person->title;
		}
		if ( ! empty( $person->firstname ) ) {
			$parts[] = $person->firstname;
		}
		if ( ! empty( $person->nickname ) ) {
			$parts[] = '"' . $person->nickname . '"';
		}

		// Surname with optional prefix, enclosed in slashes.
		$surname = '';
		if ( ! empty( $person->lnprefix ) ) {
			$surname .= $person->lnprefix . ' ';
		}
		if ( ! empty( $person->lastname ) ) {
			$surname .= $person->lastname;
		}
		if ( $surname ) {
			$parts[] = '/' . trim( $surname ) . '/';
		}

		if ( ! empty( $person->suffix ) ) {
			$parts[] = $person->suffix;
		}

		return implode( ' ', $parts );
	}

	// ── Line writing helpers ──────────────────────────────────────────────────

	/**
	 * Write a tagged value, handling multi-line content with CONT and CONC.
	 *
	 * GEDCOM rules:
	 * - Lines exceeding MAX_LINE_LENGTH are wrapped using CONC (same logical line).
	 * - Embedded newlines in the value are written as CONT (new logical line).
	 *
	 * @since  0.0.1
	 * @param  resource $fh    File handle.
	 * @param  int      $level GEDCOM level (0-9).
	 * @param  string   $tag   GEDCOM tag.
	 * @param  string   $value Tag value.
	 * @return void
	 */
	private function write_tagged_value( $fh, $level, $tag, $value ) {
		if ( '' === (string) $value ) {
			return;
		}

		// Normalise all newline variants to \n.
		$value = str_replace( array( "\r\n", "\r" ), "\n", $value );

		// Split on embedded newlines; first segment uses the tag, rest use CONT.
		$segments  = explode( "\n", $value );
		$is_first  = true;
		$cont_level = $level + 1;

		foreach ( $segments as $segment ) {
			if ( $is_first ) {
				$this->write_long_line( $fh, $level, $tag, $segment );
				$is_first = false;
			} else {
				$this->write_long_line( $fh, $cont_level, 'CONT', $segment );
			}
		}
	}

	/**
	 * Write a single GEDCOM line, splitting overlong values with CONC.
	 *
	 * @since  0.0.1
	 * @param  resource $fh    File handle.
	 * @param  int      $level GEDCOM level.
	 * @param  string   $tag   GEDCOM tag.
	 * @param  string   $value Value (no embedded newlines).
	 * @return void
	 */
	private function write_long_line( $fh, $level, $tag, $value ) {
		$prefix  = $level . ' ' . $tag . ' ';
		$max_val = self::MAX_LINE_LENGTH - strlen( $prefix );

		if ( mb_strlen( $value ) <= $max_val ) {
			$this->write_line( $fh, $prefix . $value );
			return;
		}

		// Fold at word boundaries. php-gedcom rejoins CONC segments by inserting
		// a single space, so we break on a space and drop it - the parser re-adds
		// exactly one space and the text reconstructs faithfully. (A compliant
		// no-space CONC would need a parser fix; folding on spaces keeps this
		// self-contained in the exporter.) A token longer than a line is hard-
		// split as a last resort.
		list( $chunk, $remaining ) = $this->split_at_word( $value, $max_val );
		$this->write_line( $fh, $prefix . $chunk );

		$conc_level  = $level + 1;
		$conc_prefix = $conc_level . ' CONC ';
		$conc_max    = self::MAX_LINE_LENGTH - strlen( $conc_prefix );

		while ( '' !== $remaining ) {
			list( $chunk, $remaining ) = $this->split_at_word( $remaining, $conc_max );
			$this->write_line( $fh, $conc_prefix . $chunk );
		}
	}

	/**
	 * Split a value into a head chunk (<= $max) and the remainder, breaking on
	 * the last space within the limit so a CONC rejoin re-inserts that space.
	 *
	 * @since  0.3.0
	 * @param  string $value Value to split.
	 * @param  int    $max   Max characters for the head chunk.
	 * @return array{0:string,1:string} [head, remainder].
	 */
	private function split_at_word( $value, $max ) {
		if ( mb_strlen( $value ) <= $max ) {
			return array( $value, '' );
		}
		$head = mb_substr( $value, 0, $max );
		$pos  = mb_strrpos( $head, ' ' );
		if ( false === $pos || 0 === $pos ) {
			// No usable space to break on (single long token): hard split.
			return array( mb_substr( $value, 0, $max ), mb_substr( $value, $max ) );
		}
		// Break before the space; drop the boundary space (CONC re-adds it).
		return array( mb_substr( $value, 0, $pos ), mb_substr( $value, $pos + 1 ) );
	}

	/**
	 * Write a source citation under the current INDI/FAM record (0.7.0).
	 *
	 * @since  0.7.0
	 * @param  resource $fh    Stream handle.
	 * @param  object   $cit   Citation row.
	 * @param  int      $level GEDCOM level: 1 for persfam-level citations,
	 *                         2 for event-level ones (0.8.0).
	 * @return void
	 */
	private function write_citation( $fh, $cit, $level = 1 ) {
		if ( empty( $cit->sourceID ) ) {
			return;
		}
		$sub = $level + 1;
		$this->write_line( $fh, $level . ' SOUR @' . $cit->sourceID . '@' );
		if ( ! empty( $cit->page ) ) {
			$this->write_tagged_value( $fh, $sub, 'PAGE', $cit->page );
		}
		if ( null !== $cit->quay && '' !== (string) $cit->quay ) {
			$this->write_line( $fh, $sub . ' QUAY ' . (int) $cit->quay );
		}
		if ( ! empty( $cit->citetext ) ) {
			$this->write_tagged_value( $fh, $sub, 'TEXT', $cit->citetext );
		}
	}

	/**
	 * Write an event's citations at level 2 inside its just-written event
	 * block, once (0.8.0).
	 *
	 * @since  0.8.0
	 * @param  resource $fh              Stream handle.
	 * @param  object   $ev              Events-table row.
	 * @param  array    $event_citations Event-level citations: eventID -> rows[].
	 * @param  array    $emitted         By-ref set of already-written eventIDs.
	 * @return void
	 */
	private function write_event_citations( $fh, $ev, $event_citations, &$emitted ) {
		if ( empty( $ev->eventID ) || isset( $emitted[ $ev->eventID ] ) || empty( $event_citations[ $ev->eventID ] ) ) {
			return;
		}
		foreach ( $event_citations[ $ev->eventID ] as $cit ) {
			$this->write_citation( $fh, $cit, 2 );
		}
		$emitted[ $ev->eventID ] = true;
	}

	/**
	 * Write the citations of the FIRST event matching one of $tags, for event
	 * blocks emitted from person direct columns (which hold the first event of
	 * their type) rather than from the events table (0.8.0).
	 *
	 * @since  0.8.0
	 * @param  resource $fh              Stream handle.
	 * @param  array    $rows            Events-table rows for this persfam.
	 * @param  array    $tags            Uppercase GEDCOM tags to match.
	 * @param  array    $event_citations Event-level citations: eventID -> rows[].
	 * @param  array    $emitted         By-ref set of already-written eventIDs.
	 * @return void
	 */
	private function write_direct_event_citations( $fh, $rows, $tags, $event_citations, &$emitted ) {
		foreach ( $rows as $ev ) {
			if ( in_array( strtoupper( $ev->eventtypeID ), $tags, true ) ) {
				$this->write_event_citations( $fh, $ev, $event_citations, $emitted );
				return;
			}
		}
	}

	/**
	 * Fallback for event-attached citations whose event block was never
	 * written (e.g. a direct-tag event with no date/place): emit them at
	 * level 1 so the citation still reaches the export (0.8.0).
	 *
	 * @since  0.8.0
	 * @param  resource $fh              Stream handle.
	 * @param  array    $rows            Events-table rows for this persfam.
	 * @param  array    $event_citations Event-level citations: eventID -> rows[].
	 * @param  array    $emitted         By-ref set of already-written eventIDs.
	 * @return void
	 */
	private function write_leftover_event_citations( $fh, $rows, $event_citations, &$emitted ) {
		foreach ( $rows as $ev ) {
			if ( empty( $ev->eventID ) || isset( $emitted[ $ev->eventID ] ) || empty( $event_citations[ $ev->eventID ] ) ) {
				continue;
			}
			foreach ( $event_citations[ $ev->eventID ] as $cit ) {
				$this->write_citation( $fh, $cit, 1 );
			}
			$emitted[ $ev->eventID ] = true;
		}
	}

	/**
	 * Write a 0-level SOUR record (0.7.0).
	 *
	 * @since  0.7.0
	 * @param  resource $fh     Stream handle.
	 * @param  object   $source Source row.
	 * @return void
	 */
	private function write_source( $fh, $source ) {
		$this->write_line( $fh, '0 @' . $source->sourceID . '@ SOUR' );
		if ( ! empty( $source->title ) ) {
			$this->write_tagged_value( $fh, 1, 'TITL', $source->title );
		}
		if ( ! empty( $source->author ) ) {
			$this->write_tagged_value( $fh, 1, 'AUTH', $source->author );
		}
		if ( ! empty( $source->publisher ) ) {
			$this->write_tagged_value( $fh, 1, 'PUBL', $source->publisher );
		}
		if ( ! empty( $source->actualtext ) ) {
			$this->write_tagged_value( $fh, 1, 'TEXT', $source->actualtext );
		}
		if ( ! empty( $source->repoID ) ) {
			$this->write_line( $fh, '1 REPO @' . $source->repoID . '@' );
			if ( ! empty( $source->callnum ) ) {
				$this->write_tagged_value( $fh, 2, 'CALN', $source->callnum );
			}
		}
	}

	/**
	 * Write a 0-level REPO record (0.7.0).
	 *
	 * @since  0.7.0
	 * @param  resource $fh         Stream handle.
	 * @param  object   $repository Repository row.
	 * @return void
	 */
	private function write_repository( $fh, $repository ) {
		$this->write_line( $fh, '0 @' . $repository->repoID . '@ REPO' );
		if ( ! empty( $repository->reponame ) ) {
			$this->write_tagged_value( $fh, 1, 'NAME', $repository->reponame );
		}
		if ( ! empty( $repository->address ) || ! empty( $repository->city ) || ! empty( $repository->country ) ) {
			$this->write_tagged_value( $fh, 1, 'ADDR', (string) $repository->address );
			if ( ! empty( $repository->city ) ) {
				$this->write_tagged_value( $fh, 2, 'CITY', $repository->city );
			}
			if ( ! empty( $repository->state ) ) {
				$this->write_tagged_value( $fh, 2, 'STAE', $repository->state );
			}
			if ( ! empty( $repository->zip ) ) {
				$this->write_tagged_value( $fh, 2, 'POST', $repository->zip );
			}
			if ( ! empty( $repository->country ) ) {
				$this->write_tagged_value( $fh, 2, 'CTRY', $repository->country );
			}
		}
		if ( ! empty( $repository->phone ) ) {
			$this->write_tagged_value( $fh, 1, 'PHON', $repository->phone );
		}
	}

	/**
	 * Write the OBJE pointers for one person/family (0.9.0).
	 *
	 * @since  0.9.0
	 * @param  resource $fh        Stream handle.
	 * @param  string   $linktype  person|family.
	 * @param  string   $persfamID Target reference.
	 * @return void
	 */
	private function write_media_refs( $fh, $linktype, $persfamID ) {
		if ( empty( $this->media_links[ $linktype ][ $persfamID ] ) ) {
			return;
		}
		foreach ( $this->media_links[ $linktype ][ $persfamID ] as $ml ) {
			$this->write_line( $fh, '1 OBJE @' . $ml->mediaID . '@' );
		}
	}

	/**
	 * Write a 0-level OBJE multimedia record (0.9.0).
	 *
	 * GEDCOM 5.5.1 structure: FILE at level 1, FORM under it, TYPE under FORM.
	 * FILE is the external URL, or the WP attachment URL for library-backed
	 * media (the binary itself never leaves the site). Media metadata GEDCOM
	 * has no tag for (as-recorded date, transcription, private flag) is
	 * deliberately not exported - GEDCOM is interchange, not backup.
	 *
	 * @since  0.9.0
	 * @param  resource $fh  Stream handle.
	 * @param  object   $row Media row.
	 * @return void
	 */
	private function write_media_object( $fh, $row ) {
		$this->write_line( $fh, '0 @' . $row->mediaID . '@ OBJE' );

		$file = ! empty( $row->external_url ) ? (string) $row->external_url : '';
		if ( '' === $file && ! empty( $row->attachment_id ) && function_exists( 'wp_get_attachment_url' ) ) {
			$url = wp_get_attachment_url( (int) $row->attachment_id );
			if ( $url ) {
				$file = (string) $url;
			}
		}

		if ( '' !== $file ) {
			$this->write_tagged_value( $fh, 1, 'FILE', $file );
			$ext = strtolower( (string) pathinfo( (string) wp_parse_url( $file, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
			$this->write_tagged_value( $fh, 2, 'FORM', '' !== $ext ? $ext : 'unknown' );
			if ( ! empty( $row->mediatype ) ) {
				$this->write_tagged_value( $fh, 3, 'TYPE', $row->mediatype );
			}
		}
		if ( ! empty( $row->description ) ) {
			$this->write_tagged_value( $fh, 1, 'TITL', $row->description );
		}
	}

	/**
	 * Write a single raw line to the stream with GEDCOM-spec CRLF line ending.
	 *
	 * @since  0.0.1
	 * @param  resource $fh   Stream handle.
	 * @param  string   $line Line content (without line ending).
	 * @return void
	 */
	private function write_line( $fh, $line ) {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- writing to php://temp in-memory stream, not a filesystem file.
		fwrite( $fh, rtrim( $line ) . "\r\n" );
	}
}
