<?php
/**
 * ANCP Core - GEDCOM Importer
 *
 * Parses a GEDCOM file using php-gedcom and writes all records into the core
 * relational tables. Moved from AP Family Trees into the ANCP Core Library
 * (spec-01 item 4) so every suite plugin imports through one code path.
 *
 * @package AncestorPress\Core
 * @since   0.3.0
 */

namespace AncestorPress\Core;

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

/**
 * GEDCOM import handler.
 *
 * @since 2.0.0
 */
class Gedcom_Importer {

	/**
	 * Per-import citation ID sequence (C1, C2...).
	 *
	 * @since 0.7.0
	 * @var int
	 */
	private static $citation_seq = 1;

	/**
	 * Per-import citation count (for the result array).
	 *
	 * @since 0.7.0
	 * @var int
	 */
	private static $citations = 0;

	/**
	 * Per-import event ID sequence (E1, E2... unique per tree).
	 *
	 * @since 0.8.0
	 * @var int
	 */
	private static $event_seq = 1;

	/**
	 * Per-import inline-media ID sequence (MI1, MI2... for OBJE links that
	 * carry their data inline instead of pointing at a 0-level record).
	 *
	 * @since 0.9.0
	 * @var int
	 */
	private static $inline_media_seq = 1;

	/**
	 * Per-import media count (for the result array).
	 *
	 * @since 0.9.0
	 * @var int
	 */
	private static $media = 0;

	/**
	 * Per-import: targets (linktype.persfamID) that already received their
	 * first media link - the first OBJE per target becomes the defphoto.
	 *
	 * @since 0.9.0
	 * @var array
	 */
	private static $media_first = array();

	/**
	 * Run the GEDCOM import.
	 *
	 * @since  2.0.0
	 * @param  string $file_path   Absolute path to the uploaded .ged file.
	 * @param  string $tree_name   Human-readable name for this tree.
	 * @param  string $gedcom_slug Unique tree identifier slug (auto-generated if empty).
	 * @param  string $original_filename Original uploaded file name, for display/record.
	 * @return array { errors, people, families, gedcom }
	 */
	public static function run( $file_path, $tree_name, $gedcom_slug = '', $original_filename = '' ) {
		$result = array(
			'errors'   => array(),
			'people'   => 0,
			'families' => 0,
			'gedcom'   => '',
		);

		if ( ! file_exists( $file_path ) ) {
			$result['errors'][] = 'GEDCOM file not found.';
			return $result;
		}

		// Generate a gedcom slug from the tree name if not supplied.
		if ( ! $gedcom_slug ) {
			$gedcom_slug = self::make_gedcom_slug( $tree_name );
		}

		// Check the slug is not already in use.
		if ( Trees_Table::get_by_gedcom( $gedcom_slug ) ) {
			$result['errors'][] = sprintf(
				/* translators: %s: tree identifier */
				'A tree with the identifier "%s" already exists. Delete it first or choose a different name.',
				esc_html( $gedcom_slug )
			);
			return $result;
		}

		// Register the php-gedcom autoloader.
		self::register_autoloader();

		// Parse the GEDCOM file.
		try {
			$parser    = new \AncestorPress\Vendor\PhpGedcom\Parser();
			$gedcom_obj = $parser->parse( $file_path );
		} catch ( \Exception $e ) {
			$result['errors'][] = 'Could not parse the GEDCOM file. Please check it is a valid .ged file.';
			return $result;
		}

		if ( ! $gedcom_obj ) {
			$result['errors'][] = 'The GEDCOM file appears to be empty or invalid.';
			return $result;
		}

		// Create the tree record.
		$tree_id = Trees_Table::insert( array(
			'gedcom'         => $gedcom_slug,
			'treename'       => $tree_name,
			'importfilename' => $original_filename ? $original_filename : basename( $file_path ),
			'lastimportdate' => gmdate( 'Y-m-d' ),
		) );

		if ( ! $tree_id ) {
			$result['errors'][] = 'Could not create the tree record. Please try again.';
			return $result;
		}

		$result['gedcom'] = $gedcom_slug;

		// Import individuals and families, catching any unexpected errors.
		try {
			$top_notes              = self::build_note_map( $gedcom_obj );
			self::$citation_seq     = 1;
			self::$citations        = 0;
			self::$event_seq        = 1;
			self::$inline_media_seq = 1;
			self::$media            = 0;
			self::$media_first      = array();
			// Source records first so citations always have a target (0.7.0);
			// media records likewise before the OBJE links that point at them (0.9.0).
			$result['repositories'] = self::import_repositories( $gedcom_obj->getRepo(), $gedcom_slug );
			$result['sources']      = self::import_sources( $gedcom_obj->getSour(), $gedcom_slug );
			$result['media_records'] = self::import_media( $gedcom_obj->getObje(), $gedcom_slug );
			$result['people']       = self::import_individuals( $gedcom_obj->getIndi(), $gedcom_slug, $top_notes );
			$result['families']     = self::import_families( $gedcom_obj->getFam(), $gedcom_slug );
			$result['citations']    = self::$citations;
			$result['media']        = self::$media;
		} catch ( \Throwable $e ) {
			// PHP 7+ Throwable catches both Exception and Error (including TypeError, Error).
			$result['errors'][] = sprintf(
				/* translators: %s: error message */
				'Import encountered an error: %s',
				esc_html( $e->getMessage() )
			);
			// Roll back the partially-imported tree so no half-tree is left behind
			// and a retry is not blocked by the "identifier already exists" check.
			Trees_Table::delete_by_gedcom( $gedcom_slug );
			$result['gedcom']   = '';
			$result['people']   = 0;
			$result['families'] = 0;
		}

		return $result;
	}

	/**
	 * Import GEDCOM REPO records into the repositories table (0.7.0).
	 *
	 * @since  0.7.0
	 * @param  array  $repos  PhpGedcom Record\Repo objects.
	 * @param  string $gedcom Tree identifier.
	 * @return int            Repositories imported.
	 */
	private static function import_repositories( $repos, $gedcom ) {
		if ( empty( $repos ) ) {
			return 0;
		}
		$count = 0;
		foreach ( $repos as $repo ) {
			// php-gedcom stores the REPO xref in _repo (there is no _id).
			$repoID = trim( (string) $repo->getRepo(), '@ ' );
			if ( '' === $repoID ) {
				continue;
			}
			$data = array(
				'gedcom'   => $gedcom,
				'repoID'   => $repoID,
				'reponame' => sanitize_text_field( (string) $repo->getName() ),
				'phone'    => '',
			);
			try {
				$phones = $repo->getPhon();
				if ( ! empty( $phones ) && is_array( $phones ) ) {
					$data['phone'] = sanitize_text_field( (string) $phones[0] );
				}
				$addr = $repo->getAddr();
				if ( $addr ) {
					$data['address'] = sanitize_text_field( (string) $addr->getAddr() );
					$data['city']    = sanitize_text_field( (string) $addr->getCity() );
					$data['state']   = sanitize_text_field( (string) $addr->getStae() );
					$data['zip']     = sanitize_text_field( (string) $addr->getPost() );
					$data['country'] = sanitize_text_field( (string) $addr->getCtry() );
				}
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Address details are best-effort; the record itself still imports.
			}
			if ( Repositories_Table::insert( $data ) ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Import GEDCOM SOUR records into the sources table (0.7.0).
	 *
	 * @since  0.7.0
	 * @param  array  $sources PhpGedcom Record\Sour objects.
	 * @param  string $gedcom  Tree identifier.
	 * @return int             Sources imported.
	 */
	private static function import_sources( $sources, $gedcom ) {
		if ( empty( $sources ) ) {
			return 0;
		}
		$count = 0;
		foreach ( $sources as $sour ) {
			// php-gedcom stores the SOUR xref in _sour (there is no _id).
			$sourceID = trim( (string) $sour->getSour(), '@ ' );
			if ( '' === $sourceID ) {
				continue;
			}
			$data = array(
				'gedcom'     => $gedcom,
				'sourceID'   => $sourceID,
				'title'      => sanitize_textarea_field( (string) $sour->getTitl() ),
				'author'     => sanitize_text_field( (string) $sour->getAuth() ),
				'publisher'  => sanitize_text_field( (string) $sour->getPubl() ),
				'actualtext' => sanitize_textarea_field( (string) $sour->getText() ),
			);
			try {
				$repo_ref = $sour->getRepo();
				if ( $repo_ref ) {
					$data['repoID'] = sanitize_text_field( trim( (string) $repo_ref->getRepo(), '@ ' ) );
					$calns          = $repo_ref->getCaln();
					if ( ! empty( $calns ) && is_array( $calns ) ) {
						$first          = reset( $calns );
						$data['callnum'] = sanitize_text_field( is_object( $first ) ? (string) $first->getCaln() : (string) $first );
					}
				}
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Repository linkage is best-effort; the source itself still imports.
			}
			if ( Sources_Table::insert( $data ) ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Import source citations (SOUR refs) attached to a person or family.
	 *
	 * Since 0.8.0 event-level citations carry the minted eventID of the event
	 * row they belong to (spec-00 5.4), replacing the 0.7.0 v1 flattening.
	 * Citations on events that have no events-table row (MARR/DIV live on the
	 * family record; dataless events are skipped) still flatten to persfam.
	 *
	 * @since  0.7.0
	 * @param  array  $refs      PhpGedcom Record\SourRef objects (may be empty).
	 * @param  string $gedcom    Tree identifier.
	 * @param  string $persfamID Person or family reference.
	 * @param  string $eventID   Minted eventID when the citations belong to a
	 *                           specific events-table row ('' = persfam level).
	 * @return void
	 */
	private static function import_citations( $refs, $gedcom, $persfamID, $eventID = '' ) {
		if ( empty( $refs ) ) {
			return;
		}
		foreach ( $refs as $ref ) {
			if ( ! is_object( $ref ) ) {
				continue;
			}
			try {
				$sourceID = trim( (string) $ref->getSour(), '@ ' );
				if ( '' === $sourceID ) {
					continue;
				}
				$quay = (string) $ref->getQuay();
				$text = (string) $ref->getText();
				if ( '' === $text ) {
					$data_obj = $ref->getData();
					if ( is_object( $data_obj ) ) {
						$text = (string) $data_obj->getText();
					}
				}
				Citations_Table::insert( array(
					'gedcom'     => $gedcom,
					'citationID' => 'C' . self::$citation_seq++,
					'persfamID'  => $persfamID,
					'eventID'    => '' !== $eventID ? $eventID : null,
					'sourceID'   => $sourceID,
					'page'       => sanitize_text_field( (string) $ref->getPage() ),
					'quay'       => ( '' !== $quay && is_numeric( $quay ) ) ? (int) $quay : null,
					'citetext'   => sanitize_textarea_field( $text ),
					'ordernum'   => self::$citation_seq,
				) );
				self::$citations++;
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// A malformed citation never aborts the person/family import.
			}
		}
	}

	/**
	 * Import 0-level GEDCOM OBJE multimedia records into the media table (0.9.0).
	 *
	 * The binary itself is never fetched: FILE becomes external_url (spec-00
	 * 5.5 - media binaries live in the WP library OR offsite; imports are
	 * always the offsite case).
	 *
	 * @since  0.9.0
	 * @param  array  $objes  PhpGedcom Record\Obje objects.
	 * @param  string $gedcom Tree identifier.
	 * @return int            Media records imported.
	 */
	private static function import_media( $objes, $gedcom ) {
		if ( empty( $objes ) || ! class_exists( 'AncestorPress\Core\Media' ) ) {
			return 0;
		}
		$count = 0;
		foreach ( $objes as $obje ) {
			if ( ! is_object( $obje ) ) {
				continue;
			}
			try {
				$mediaID = trim( (string) $obje->getId(), '@ ' );
				if ( '' === $mediaID ) {
					continue;
				}
				$file  = (string) $obje->getFile();
				$type  = strtolower( trim( (string) $obje->getType() ) );
				$row_id = Media::insert_media( array(
					'gedcom'       => $gedcom,
					'mediaID'      => $mediaID,
					'mediatype'    => '' !== $type ? sanitize_key( $type ) : self::mediatype_from_file( $file ),
					'external_url' => esc_url_raw( $file ),
					'description'  => sanitize_text_field( (string) $obje->getTitl() ),
				) );
				if ( $row_id ) {
					$count++;
					self::$media++;
				}
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// A malformed media record never aborts the import.
			}
		}
		return $count;
	}

	/**
	 * Derive a mediatype from a file reference when the GEDCOM carries no TYPE.
	 *
	 * @since  0.9.0
	 * @param  string $file File reference (URL or path).
	 * @return string       photo|document|audio|video ('photo' when unknown).
	 */
	private static function mediatype_from_file( $file ) {
		$ext = strtolower( (string) pathinfo( (string) wp_parse_url( $file, PHP_URL_PATH ), PATHINFO_EXTENSION ) );
		if ( in_array( $ext, array( 'pdf', 'doc', 'docx', 'txt', 'rtf' ), true ) ) {
			return 'document';
		}
		if ( in_array( $ext, array( 'mp3', 'wav', 'ogg', 'm4a' ), true ) ) {
			return 'audio';
		}
		if ( in_array( $ext, array( 'mp4', 'mov', 'avi', 'webm', 'mkv' ), true ) ) {
			return 'video';
		}
		return 'photo';
	}

	/**
	 * Import OBJE links attached to a person or family (0.9.0).
	 *
	 * Reference form (1 OBJE @M1@) links to the already-imported 0-level
	 * record; inline form (1 OBJE with FILE/TITL sub-tags, the 5.5 style)
	 * creates the media row on the spot with a minted MIn id. The first OBJE
	 * per target becomes its defphoto (GEDCOM has no standard flag; first-
	 * listed is the interoperable convention, and the exporter emits the
	 * defphoto first so round trips are stable).
	 *
	 * @since  0.9.0
	 * @param  array  $refs      PhpGedcom Record\ObjeRef objects (may be empty).
	 * @param  string $gedcom    Tree identifier.
	 * @param  string $linktype  person|family.
	 * @param  string $persfamID Person or family reference.
	 * @return void
	 */
	private static function import_media_links( $refs, $gedcom, $linktype, $persfamID ) {
		if ( empty( $refs ) || ! class_exists( 'AncestorPress\Core\Media' ) ) {
			return;
		}
		foreach ( $refs as $ref ) {
			if ( ! is_object( $ref ) ) {
				continue;
			}
			try {
				$mediaID = '';
				if ( is_callable( array( $ref, 'getIsReference' ) ) && $ref->getIsReference() ) {
					$mediaID = trim( (string) $ref->getObje(), '@ ' );
					if ( '' === $mediaID || ! Media::get_by_ref( $gedcom, $mediaID ) ) {
						continue; // Dangling pointer - nothing to link.
					}
				} else {
					// Inline OBJE: the media data lives on the link itself.
					$file    = (string) $ref->getFile();
					$titl    = (string) $ref->getTitl();
					if ( '' === $file && '' === $titl ) {
						continue;
					}
					$mediaID = 'MI' . self::$inline_media_seq;
					$row_id  = Media::insert_media( array(
						'gedcom'       => $gedcom,
						'mediaID'      => $mediaID,
						'mediatype'    => self::mediatype_from_file( $file ),
						'external_url' => esc_url_raw( $file ),
						'description'  => sanitize_text_field( $titl ),
					) );
					if ( ! $row_id ) {
						continue;
					}
					self::$inline_media_seq++;
					self::$media++;
				}

				$key      = $linktype . '.' . $persfamID;
				$ordernum = isset( self::$media_first[ $key ] ) ? self::$media_first[ $key ] : 0;
				Media::link( $gedcom, $mediaID, $linktype, $persfamID, array(
					'defphoto' => 0 === $ordernum,
					'ordernum' => $ordernum,
				) );
				self::$media_first[ $key ] = $ordernum + 1;
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// A malformed media link never aborts the person/family import.
			}
		}
	}

	/**
	 * Import GEDCOM individuals into apft_people + apft_events + apft_notes.
	 *
	 * @since  2.0.0
	 * @param  array  $individuals Array of PhpGedcom Record\Indi objects.
	 * @param  string $gedcom      Tree identifier.
	 * @return int                 Number of people imported.
	 */
	private static function import_individuals( $individuals, $gedcom, $top_notes = array() ) {
		if ( empty( $individuals ) ) {
			return 0;
		}

		$count    = 0;
		$note_seq = 1;

		// Living-person cutoff from Settings (clamped default 100 years): a person
		// with no recorded death and born within this many years is treated as
		// living and suppressed in public view.
		$living_cutoff = (int) get_option( 'apft_ft_living_year_cutoff', 100 );

		foreach ( $individuals as $indi ) {

			$personID = (string) $indi->getId();
			if ( ! $personID ) {
				continue;
			}

			// Build name fields from the first NAME record.
			$firstname = '';
			$lastname  = '';
			$lnprefix  = '';
			$nickname  = '';
			$title     = '';
			$suffix    = '';
			$prefix    = '';

			$names = $indi->getName();
			if ( ! empty( $names ) ) {
				$name_obj = $names[0];
				$givn     = (string) $name_obj->getGivn();
				$surn     = (string) $name_obj->getSurn();
				$spfx     = (string) $name_obj->getSpfx();
				$nickname = (string) $name_obj->getNick();
				$title    = (string) $name_obj->getNpfx();
				$suffix   = (string) $name_obj->getNsfx();

				// Fall back to parsing the full name string if structured parts are empty.
				$firstname = $givn ?: wp_strip_all_tags( trim( str_replace( array( '/', '\\' ), array( ' ', '' ), (string) $name_obj->getName() ) ) );
				$lastname  = $surn;
				$lnprefix  = $spfx;
			}

			// Sex.
			$sex = strtoupper( (string) $indi->getSex() );
			if ( ! in_array( $sex, array( 'M', 'F' ), true ) ) {
				$sex = 'U';
			}

			// First parent family reference.
			$famc_first = '';
			$famc_list  = $indi->getFamc();
			if ( ! empty( $famc_list ) ) {
				// getFamc() returns an array of Indi\Famc objects.
				// getFamc() on each object returns the family ID string.
				$first_famc = $famc_list[0];
				if ( is_object( $first_famc ) ) {
					$famc_first = (string) $first_famc->getFamc();
				} elseif ( is_string( $first_famc ) ) {
					$famc_first = $first_famc;
				}
			}

			// Extract key dates from events.
			$birthdate   = '';
			$birthdatetr  = null;
			$birthplace  = '';
			$deathdate   = '';
			$deathdatetr  = null;
			$deathplace  = '';
			$burialdate  = '';
			$burialdatetr = null;
			$burialplace = '';
			$baptdate    = '';
			$baptdatetr  = null;
			$baptplace   = '';
			$confdate    = '';
			$confdatetr  = null;
			$confplace   = '';

			$even_list = $indi->getEven();
			if ( ! empty( $even_list ) ) {
				foreach ( $even_list as $eve ) {
					// Detect event type: use getType() first, fall back to class name.
					$type = strtoupper( trim( (string) $eve->getType() ) );
					if ( ! $type ) {
						$cls  = get_class( $eve );
						$type = strtoupper( substr( $cls, strrpos( $cls, '\\' ) + 1 ) );
					}
					$date = trim( (string) $eve->getDate() );
					$plac = $eve->getPlac() ? trim( (string) $eve->getPlac()->getPlac() ) : '';

					switch ( $type ) {
						case 'BIRT':
							if ( ! $birthdate ) {
								$birthdate   = $date;
								$birthdatetr = Tables::normalise_date( $date );
								$birthplace  = $plac;
							}
							break;
						case 'DEAT':
							if ( ! $deathdate ) {
								$deathdate   = $date;
								$deathdatetr = Tables::normalise_date( $date );
								$deathplace  = $plac;
							}
							break;
						case 'BURI':
						case 'CREM':
							if ( ! $burialdate ) {
								$burialdate   = $date;
								$burialdatetr = Tables::normalise_date( $date );
								$burialplace  = $plac;
							}
							break;
						case 'BAPM':
						case 'CHR':
							// Baptism/christening -> baptism columns (the exporter
							// emits BAPM from these, so round-trips are faithful).
							if ( ! $baptdate ) {
								$baptdate   = $date;
								$baptdatetr = Tables::normalise_date( $date );
								$baptplace  = $plac;
							}
							break;
						case 'CONF':
							if ( ! $confdate ) {
								$confdate   = $date;
								$confdatetr = Tables::normalise_date( $date );
								$confplace  = $plac;
							}
							break;
					}
				}
			}

			// Determine if likely living.
			$living = 0;
			if ( ! $deathdate ) {
				if ( $birthdatetr ) {
					$birth_year = (int) substr( $birthdatetr, 0, 4 );
					if ( $birth_year > ( (int) gmdate( 'Y' ) - $living_cutoff ) ) {
						$living = 1;
					}
				} else {
					$living = 1; // No dates - conservatively treat as potentially living.
				}
			}

			// Insert the person.
			$person_id = People_Table::insert( array(
				'gedcom'       => $gedcom,
				'personID'     => $personID,
				'firstname'    => sanitize_text_field( $firstname ),
				'lastname'     => sanitize_text_field( $lastname ),
				'lnprefix'     => sanitize_text_field( $lnprefix ),
				'nickname'     => sanitize_text_field( $nickname ),
				'title'        => sanitize_text_field( $title ),
				'suffix'       => sanitize_text_field( $suffix ),
				'prefix'       => sanitize_text_field( $prefix ),
				'sex'          => $sex,
				'famc'         => sanitize_text_field( $famc_first ),
				'birthdate'    => sanitize_text_field( $birthdate ),
				'birthdatetr'  => $birthdatetr,
				'birthplace'   => sanitize_text_field( $birthplace ),
				'deathdate'    => sanitize_text_field( $deathdate ),
				'deathdatetr'  => $deathdatetr,
				'deathplace'   => sanitize_text_field( $deathplace ),
				'burialdate'   => sanitize_text_field( $burialdate ),
				'burialdatetr' => $burialdatetr,
				'burialplace'  => sanitize_text_field( $burialplace ),
				'baptdate'     => sanitize_text_field( $baptdate ),
				'baptdatetr'   => $baptdatetr,
				'baptplace'    => sanitize_text_field( $baptplace ),
				'confdate'     => sanitize_text_field( $confdate ),
				'confdatetr'   => $confdatetr,
				'confplace'    => sanitize_text_field( $confplace ),
				'metaphone'    => $lastname ? metaphone( $lastname ) : '',
				'living'       => $living,
			) );

			if ( ! $person_id ) {
				continue;
			}

			// Import all events for this person into apft_events.
			$event_obje = array(); // Event-level OBJE refs, flattened to the person AFTER its own OBJE list (0.9.0).
			if ( ! empty( $even_list ) ) {
				foreach ( $even_list as $eve ) {
					$type = strtoupper( trim( (string) $eve->getType() ) );
					if ( ! $type ) {
						$cls  = get_class( $eve );
						$type = strtoupper( substr( $cls, strrpos( $cls, '\\' ) + 1 ) );
					}
					$date = trim( (string) $eve->getDate() );
					$plac = $eve->getPlac() ? trim( (string) $eve->getPlac()->getPlac() ) : '';

					// Skip if no usable data.
					if ( ! $date && ! $plac && ! $type ) {
						continue;
					}

					$event_id = 'E' . self::$event_seq;
					$inserted = Events_Table::insert( array(
						'gedcom'      => $gedcom,
						'eventID'     => $event_id,
						'persfamID'   => $personID,
						'eventtypeID' => $type ?: 'EVEN',
						'eventdate'   => sanitize_text_field( $date ),
						'eventdatetr' => Tables::normalise_date( $date ),
						'eventplace'  => sanitize_text_field( $plac ),
					) );
					if ( $inserted ) {
						self::$event_seq++;
					} else {
						$event_id = ''; // No event row - flatten its citations to the person.
					}

					if ( $plac ) {
						Places_Table::get_or_insert( $gedcom, $plac );
					}

					// Event-level source citations, attached to the minted eventID (0.8.0).
					try {
						self::import_citations( $eve->getSour(), $gedcom, $personID, $event_id );
					} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
						// Citations are best-effort.
					}

					// Collect event-level OBJE refs for flattening below (0.9.0):
					// after the person's own OBJE list, so an event image never
					// outranks the portrait for the defphoto.
					try {
						$eve_obje = $eve->getObje();
						if ( ! empty( $eve_obje ) && is_array( $eve_obje ) ) {
							$event_obje = array_merge( $event_obje, $eve_obje );
						}
					} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
						// Media links are best-effort.
					}
				}
			}

			// Person-level source citations (0.7.0).
			try {
				self::import_citations( $indi->getSour(), $gedcom, $personID );
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Citations are best-effort.
			}

			// Person-level OBJE media links first, then the flattened
			// event-level ones (0.9.0).
			try {
				self::import_media_links( $indi->getObje(), $gedcom, 'person', $personID );
				self::import_media_links( $event_obje, $gedcom, 'person', $personID );
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Media links are best-effort.
			}

			// Import notes - handle both object and string note formats.
			try {
				$notes = $indi->getNote();
				if ( ! empty( $notes ) ) {
					foreach ( $notes as $note_item ) {
						// php-gedcom's NoteRef exposes getNote() via __call magic,
						// so method_exists() is false - use is_callable(). Inline
						// notes carry their text; @ref@ notes resolve to the
						// top-level NOTE record.
						$note_text = self::resolve_note_text( $note_item, $top_notes );
						if ( ! $note_text ) {
							continue;
						}
						$note_ref = 'N' . $note_seq++;
						Notes_Table::insert( array(
							'gedcom' => $gedcom,
							'noteID' => $note_ref,
							'note'   => sanitize_textarea_field( $note_text ),
						) );
						Note_Links_Table::insert( array(
							'gedcom'    => $gedcom,
							'persfamID' => $personID,
							'noteID'    => $note_ref,
						) );
					}
				}
			} catch ( \Exception $e ) {
				// Notes are non-critical - skip if unsupported by this GEDCOM version.
			}

			$count++;
		}

		return $count;
	}

	/**
	 * Import GEDCOM family records into apft_families + apft_children.
	 *
	 * @since  2.0.0
	 * @param  array  $families Array of PhpGedcom Record\Fam objects.
	 * @param  string $gedcom   Tree identifier.
	 * @return int              Number of families imported.
	 */
	private static function import_families( $families, $gedcom ) {
		if ( empty( $families ) ) {
			return 0;
		}

		$count = 0;

		foreach ( $families as $fam ) {
			$familyID = (string) $fam->getId();
			if ( ! $familyID ) {
				continue;
			}

			$husband = trim( (string) $fam->getHusb() );
			$wife    = trim( (string) $fam->getWife() );

			// Extract marriage and divorce from family events.
			// php-gedcom stores MARR, DIV etc. as typed class instances in getEven().
			// Detect type from the class name, not from getType() (which holds GEDCOM TYPE sub-tag).
			$marrdate   = '';
			$marrdatetr = null;
			$marrplace  = '';
			$marrtype   = '';
			$divdate    = '';
			$divdatetr  = null;
			$divplace   = '';

			$even_list = $fam->getEven();
			if ( ! empty( $even_list ) ) {
				foreach ( $even_list as $eve ) {
					// Use getType() - php-gedcom stores MARR/DIV/etc. in the _type
					// property via setType(), not in the class name. get_class() always
					// returns Fam\Even regardless of the actual event tag.
					$type = strtoupper( trim( (string) $eve->getType() ) );
					$date = trim( (string) $eve->getDate() );
					$plac = $eve->getPlac() ? trim( (string) $eve->getPlac()->getPlac() ) : '';

					if ( 'MARR' === $type && ! $marrdate ) {
						$marrdate   = $date;
						$marrdatetr = Tables::normalise_date( $date );
						$marrplace  = $plac;
						$marrtype   = 'MARR';
					} elseif ( in_array( $type, array( 'DIV', 'DIVF' ), true ) && ! $divdate ) {
						$divdate   = $date;
						$divdatetr = Tables::normalise_date( $date );
						$divplace  = $plac;
					}

					// Store place.
					if ( $plac ) {
						Places_Table::get_or_insert( $gedcom, $plac );
					}
				}
			}

			// Determine if family is likely living.
			$living = 0;
			if ( $husband ) {
				$husb_row = People_Table::get_by_ref( $gedcom, $husband );
				if ( $husb_row && $husb_row->living ) {
					$living = 1;
				}
			}
			if ( ! $living && $wife ) {
				$wife_row = People_Table::get_by_ref( $gedcom, $wife );
				if ( $wife_row && $wife_row->living ) {
					$living = 1;
				}
			}

			// Insert the family record.
			$family_db_id = Families_Table::insert( array(
				'gedcom'     => $gedcom,
				'familyID'   => $familyID,
				'husband'    => sanitize_text_field( $husband ),
				'wife'       => sanitize_text_field( $wife ),
				'marrdate'   => sanitize_text_field( $marrdate ),
				'marrdatetr' => $marrdatetr,
				'marrplace'  => sanitize_text_field( $marrplace ),
				'marrtype'   => sanitize_text_field( $marrtype ),
				'divdate'    => sanitize_text_field( $divdate ),
				'divdatetr'  => $divdatetr,
				'divplace'   => sanitize_text_field( $divplace ),
				'living'     => $living,
			) );

			if ( ! $family_db_id ) {
				continue;
			}

			// Family-level source citations (0.7.0). Event-level citations are
			// handled in the events loop below: attached to the minted eventID
			// where an events-table row exists, flattened to the family where
			// none does (MARR/DIV live on the family record itself) - 0.8.0.
			try {
				self::import_citations( $fam->getSour(), $gedcom, $familyID );
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Citations are best-effort.
			}

			// Import children.
			// getChil() returns an array of plain person ID strings, not objects.
			$order     = 0;
			$chil_list = $fam->getChil();
			if ( ! empty( $chil_list ) ) {
				foreach ( $chil_list as $child_ref ) {
					// $child_ref is a plain string (the GEDCOM person ID e.g. "I0001").
					$child_person_id = trim( (string) $child_ref );
					if ( ! $child_person_id ) {
						continue;
					}

					// Check if this child has their own families (haskids).
					$child_fams = Families_Table::get_for_spouse( $gedcom, $child_person_id );
					$haskids    = ! empty( $child_fams ) ? 1 : 0;

					Children_Table::insert( array(
						'gedcom'   => $gedcom,
						'familyID' => $familyID,
						'personID' => sanitize_text_field( $child_person_id ),
						'ordernum' => $order,
						'haskids'  => $haskids,
					) );

					$order++;
				}
			}

			// Store any other family events not already captured, and import
			// each event's citations (0.8.0: attached to the minted eventID;
			// flattened to the family when no events-table row exists).
			$event_obje = array(); // Event-level OBJE refs, flattened to the family after its own (0.9.0).
			if ( ! empty( $even_list ) ) {
				foreach ( $even_list as $eve ) {
					$cls  = get_class( $eve );
					$type = strtoupper( trim( (string) $eve->getType() ) );
					$date = trim( (string) $eve->getDate() );
					$plac = $eve->getPlac() ? trim( (string) $eve->getPlac()->getPlac() ) : '';

					$event_id = '';
					if ( ! in_array( $type, array( 'MARR', 'DIV', 'DIVF' ), true ) && ( $date || $plac ) ) {
						$event_id = 'E' . self::$event_seq;
						$inserted = Events_Table::insert( array(
							'gedcom'      => $gedcom,
							'eventID'     => $event_id,
							'persfamID'   => $familyID,
							'eventtypeID' => $type ?: 'EVEN',
							'eventdate'   => sanitize_text_field( $date ),
							'eventdatetr' => Tables::normalise_date( $date ),
							'eventplace'  => sanitize_text_field( $plac ),
						) );
						if ( $inserted ) {
							self::$event_seq++;
						} else {
							$event_id = '';
						}
					}

					try {
						self::import_citations( $eve->getSour(), $gedcom, $familyID, $event_id );
					} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
						// Citations are best-effort.
					}

					// Collect event-level OBJE refs for flattening below (0.9.0).
					try {
						$eve_obje = $eve->getObje();
						if ( ! empty( $eve_obje ) && is_array( $eve_obje ) ) {
							$event_obje = array_merge( $event_obje, $eve_obje );
						}
					} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
						// Media links are best-effort.
					}
				}
			}

			// Family-level OBJE media links first, then the flattened
			// event-level ones (0.9.0).
			try {
				self::import_media_links( $fam->getObje(), $gedcom, 'family', $familyID );
				self::import_media_links( $event_obje, $gedcom, 'family', $familyID );
			} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
				// Media links are best-effort.
			}

			$count++;
		}

		return $count;
	}

	/**
	 * Generate a URL-safe gedcom slug from a tree name.
	 *
	 * @since  2.0.0
	 * @param  string $name Tree name.
	 * @return string       Unique gedcom slug.
	 */
	public static function make_gedcom_slug( $name ) {
		$slug = sanitize_title( $name );
		if ( ! $slug ) {
			$slug = 'tree-' . time();
		}
		$base    = $slug;
		$counter = 1;
		while ( Trees_Table::get_by_gedcom( $slug ) ) {
			$slug = $base . '-' . $counter;
			$counter++;
		}
		return $slug;
	}

	/**
	 * Build a map of top-level NOTE records: identifier => note text.
	 *
	 * Used to resolve @ref@ note pointers on individuals to their text.
	 *
	 * @since  0.3.0
	 * @param  \AncestorPress\Vendor\PhpGedcom\Gedcom $gedcom_obj Parsed GEDCOM object.
	 * @return array<string,string>
	 */
	private static function build_note_map( $gedcom_obj ) {
		$map = array();
		if ( ! is_object( $gedcom_obj ) || ! is_callable( array( $gedcom_obj, 'getNote' ) ) ) {
			return $map;
		}
		$notes = $gedcom_obj->getNote();
		if ( empty( $notes ) ) {
			return $map;
		}
		foreach ( $notes as $key => $note ) {
			if ( is_object( $note ) && is_callable( array( $note, 'getNote' ) ) ) {
				$id         = is_callable( array( $note, 'getId' ) ) ? (string) $note->getId() : (string) $key;
				$map[ $id ] = (string) $note->getNote();
			} elseif ( is_string( $note ) ) {
				$map[ (string) $key ] = $note;
			}
		}
		return $map;
	}

	/**
	 * Resolve a note item (inline or reference) to its text.
	 *
	 * php-gedcom's NoteRef exposes getNote()/getIsReference() via __call magic,
	 * so method_exists() returns false for them; is_callable() is correct.
	 *
	 * @since  0.3.0
	 * @param  mixed $note_item php-gedcom NoteRef object or raw string.
	 * @param  array $top_notes Map of top-level note id => text.
	 * @return string           Note text, or '' if none.
	 */
	private static function resolve_note_text( $note_item, $top_notes ) {
		if ( is_string( $note_item ) ) {
			return trim( $note_item );
		}
		if ( ! is_object( $note_item ) || ! is_callable( array( $note_item, 'getNote' ) ) ) {
			return '';
		}
		$value = trim( (string) $note_item->getNote() );
		if ( is_callable( array( $note_item, 'getIsReference' ) ) && $note_item->getIsReference() ) {
			// The value is a pointer to a top-level NOTE record.
			return isset( $top_notes[ $value ] ) ? trim( (string) $top_notes[ $value ] ) : '';
		}
		return $value;
	}

	/**
	 * Register the php-gedcom PSR-0 autoloader.
	 *
	 * @since  2.0.0
	 * @return void
	 */
	public static function register_autoloader() {
		// php-gedcom is bundled inside the core library (ancp-core/lib/),
		// vendor-scoped under AncestorPress\Vendor to avoid collisions with any
		// other plugin bundling the same library. The on-disk layout keeps the
		// library's own PhpGedcom/ tree, so the vendor segment is stripped
		// before mapping class to path.
		$path   = dirname( __DIR__ ) . '/lib/php-gedcom/library/';
		$vendor = 'AncestorPress\\Vendor\\';
		spl_autoload_register(
			static function ( $class ) use ( $path, $vendor ) {
				$class = ltrim( $class, '\\' );
				if ( 0 !== strpos( $class, $vendor . 'PhpGedcom' ) ) {
					return;
				}
				$rel        = substr( $class, strlen( $vendor ) );
				$class_path = str_replace( '\\', DIRECTORY_SEPARATOR, $rel ) . '.php';
				if ( file_exists( $path . $class_path ) ) {
					require_once $path . $class_path;
				}
			}
		);
	}
}
