<?php
/**
 * ANCP Core - Branches + branch links (spec-02 P3; Model C decision -
 * Paul, 11 July 2026, 06-Docs-and-Specs/decision-branches-schema-model-C.md:
 * branch ENTITY + person-branch LINKS, many-to-many from day one; consumer
 * UIs may start single-select and widen later with no migration; is_primary
 * marks the one membership a person displays by default).
 *
 * A branch is a stored RULE - the TNG-familiar shape: start from one person,
 * include N generations of ancestors (agens), N of descendants (dgens), N
 * generations of descendants from each ancestor (dagens - the cousin depth),
 * spouses optionally. apply() processes the rule ("branch labeling"):
 * branchlinks gets one row per person/family membership (the source of
 * truth), and the comma-separated people/families `branch` columns are
 * rebuilt as a TNG-compatible cache.
 *
 * Labeling is an ADMIN data operation: it deliberately includes living and
 * private people (a family branch obviously contains its living members).
 * Display plugins apply their privacy floor at render time; branch scoping
 * only ever narrows what a query would have shown, never widens it.
 *
 * Branches are beyond-GEDCOM organizing data: the importer/exporter carry
 * nothing, by design.
 *
 * @package    AncestorPress\Core
 * @subpackage AncestorPress\Core\DB
 * @since      0.11.0
 */

namespace AncestorPress\Core;

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

class Branches_Table {

	/**
	 * Hard traversal bounds (rule fields are clamped to these).
	 *
	 * @var int
	 */
	const MAX_GENERATIONS = 20;

	/** @return string Branches table name. */
	public static function table() {
		return Tables::branches();
	}

	/** @return string Branch-links table name. */
	public static function links_table() {
		return Tables::branch_links();
	}

	/**
	 * Fetch a branch by tree + name.
	 *
	 * @param  string $gedcom Tree identifier.
	 * @param  string $branch Branch name.
	 * @return object|null
	 */
	public static function get_by_ref( $gedcom, $branch ) {
		global $wpdb;
		return $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE gedcom = %s AND branch = %s LIMIT 1', self::table(), $gedcom, $branch ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * All branches for a tree, by name.
	 *
	 * @param  string $gedcom Tree identifier.
	 * @return array
	 */
	public static function get_by_tree( $gedcom ) {
		global $wpdb;
		return $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE gedcom = %s ORDER BY branch ASC', self::table(), $gedcom ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Sanitise a branch name: it feeds the comma-separated cache column, so
	 * commas are stripped; length capped to the column.
	 *
	 * @param  string $branch Raw name.
	 * @return string
	 */
	public static function sanitise_name( $branch ) {
		$branch = str_replace( ',', ' ', sanitize_text_field( (string) $branch ) );
		return mb_substr( trim( preg_replace( '/\s+/', ' ', $branch ) ), 0, 100 );
	}

	/**
	 * Insert a branch rule. The name is the natural key per tree: duplicates
	 * are rejected. Rule generation fields are clamped to MAX_GENERATIONS.
	 * Does NOT label - call apply() after.
	 *
	 * @param  array $data gedcom, branch, description, personID, agens,
	 *                     dgens, dagens, inclspouses.
	 * @return int|false   New row ID or false.
	 */
	public static function insert( $data ) {
		global $wpdb;
		$gedcom = isset( $data['gedcom'] ) ? (string) $data['gedcom'] : '';
		$branch = self::sanitise_name( isset( $data['branch'] ) ? $data['branch'] : '' );
		if ( '' === $gedcom || '' === $branch || self::get_by_ref( $gedcom, $branch ) ) {
			return false;
		}
		$now  = Tables::now();
		$row  = array(
			'gedcom'             => $gedcom,
			'branch'             => $branch,
			'description'        => isset( $data['description'] ) ? sanitize_text_field( (string) $data['description'] ) : '',
			'colour'             => isset( $data['colour'] ) ? sanitize_text_field( (string) $data['colour'] ) : '',
			'personID'           => isset( $data['personID'] ) ? sanitize_text_field( (string) $data['personID'] ) : '',
			'agens'              => min( self::MAX_GENERATIONS, max( 0, (int) ( $data['agens'] ?? 0 ) ) ),
			'dgens'              => min( self::MAX_GENERATIONS, max( 0, (int) ( $data['dgens'] ?? 0 ) ) ),
			'dagens'             => min( self::MAX_GENERATIONS, max( 0, (int) ( $data['dagens'] ?? 0 ) ) ),
			'inclspouses'        => empty( $data['inclspouses'] ) ? 0 : 1,
			'created_by_user_id' => get_current_user_id(),
			'created_at'         => $now,
			'updated_at'         => $now,
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
		$result = $wpdb->insert( self::table(), $row );
		return $result ? (int) $wpdb->insert_id : false;
	}

	/**
	 * Update a branch rule by row ID. The NAME is fixed (it is the key the
	 * links and caches carry - rename = delete + recreate, the mediaID
	 * convention). Does not relabel - call apply() after changing the rule.
	 *
	 * @param  int   $id   Row ID.
	 * @param  array $data Field => value pairs (gedcom/branch changes ignored).
	 * @return int|false
	 */
	public static function update( $id, $data ) {
		global $wpdb;
		unset( $data['gedcom'], $data['branch'] );
		foreach ( array( 'agens', 'dgens', 'dagens' ) as $field ) {
			if ( isset( $data[ $field ] ) ) {
				$data[ $field ] = min( self::MAX_GENERATIONS, max( 0, (int) $data[ $field ] ) );
			}
		}
		if ( isset( $data['inclspouses'] ) ) {
			$data['inclspouses'] = empty( $data['inclspouses'] ) ? 0 : 1;
		}
		$data['updated_at'] = Tables::now();
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
		return $wpdb->update( self::table(), $data, array( 'id' => absint( $id ) ) );
	}

	/**
	 * Delete a branch, its links, and its labels from the caches.
	 *
	 * @param  int $id Row ID.
	 * @return int|false
	 */
	public static function delete_cascade( $id ) {
		global $wpdb;
		$id  = absint( $id );
		$row = $wpdb->get_row( $wpdb->prepare( 'SELECT gedcom, branch FROM %i WHERE id = %d', self::table(), $id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		if ( ! $row ) {
			return false;
		}
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
		$wpdb->delete( self::links_table(), array( 'gedcom' => $row->gedcom, 'branch' => $row->branch ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
		$result = $wpdb->delete( self::table(), array( 'id' => $id ) );
		self::rebuild_caches( $row->gedcom );
		return $result;
	}

	/**
	 * Delete all branches + links for a tree (tree-removal cascade).
	 *
	 * @param  string $gedcom Tree identifier.
	 * @return void
	 */
	public static function delete_by_gedcom( $gedcom ) {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
		$wpdb->delete( self::table(), array( 'gedcom' => $gedcom ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
		$wpdb->delete( self::links_table(), array( 'gedcom' => $gedcom ) );
	}

	// ── Labeling service ─────────────────────────────────────────────────────

	/**
	 * Apply (or re-apply) a branch rule: recompute the member set, replace
	 * the branch's links, and rebuild the caches. Idempotent.
	 *
	 * @param  string $gedcom Tree identifier.
	 * @param  string $branch Branch name.
	 * @return int|false      Number of people labeled, or false when the
	 *                        rule/start person is missing.
	 */
	public static function apply( $gedcom, $branch ) {
		global $wpdb;
		$rule = self::get_by_ref( $gedcom, $branch );
		if ( ! $rule || ! People_Table::get_by_ref( $gedcom, $rule->personID ) ) {
			return false;
		}

		$people   = self::compute_members( $gedcom, $rule );
		$families = self::member_families( $gedcom, $people );

		// Replace this branch's links (delete + recreate: always correct) -
		// EXCEPT owner-pinned rows (is_primary = 1, set through set_primary):
		// a person the owner explicitly placed here stays a member even when
		// the rule no longer reaches them, and the pin survives relabeling.
		$links = self::links_table();
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table; names cannot be placeholders. Block form covers the multi-line queries' continuation lines that a single-line ignore cannot.
		$pinned = $wpdb->get_col( $wpdb->prepare(
			"SELECT persfamID FROM %i WHERE gedcom = %s AND branch = %s AND linktype = 'person' AND is_primary = 1", $links,
			$gedcom, $branch
		) );
		$pinned = array_fill_keys( array_map( 'strval', (array) $pinned ), true );
		$wpdb->query( $wpdb->prepare(
			"DELETE FROM %i WHERE gedcom = %s AND branch = %s AND is_primary = 0", $links,
			$gedcom, $branch
		) );
		// phpcs:enable
		$now = Tables::now();
		foreach ( array( 'person' => array_keys( $people ), 'family' => $families ) as $linktype => $ids ) {
			foreach ( $ids as $ref ) {
				if ( 'person' === $linktype && isset( $pinned[ $ref ] ) ) {
					continue; // The pinned row already exists.
				}
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
				$wpdb->insert( $links, array(
					'gedcom'             => $gedcom,
					'branch'             => $branch,
					'linktype'           => $linktype,
					'persfamID'          => $ref,
					'is_primary'         => 0,
					'created_by_user_id' => get_current_user_id(),
					'created_at'         => $now,
					'updated_at'         => $now,
				) );
			}
		}

		self::rebuild_caches( $gedcom );
		return count( $people );
	}

	/**
	 * Compute a rule's person set: the start person, agens generations of
	 * ancestors, dgens of descendants, dagens of descendants from each
	 * ancestor (cousins), spouses of members when inclspouses. No privacy
	 * filtering - labeling is an admin data operation.
	 *
	 * @param  string $gedcom Tree identifier.
	 * @param  object $rule   Branch rule row.
	 * @return array          personID => true.
	 */
	private static function compute_members( $gedcom, $rule ) {
		$members = array( (string) $rule->personID => true );

		// Ancestors, remembering each one so dagens can descend from them.
		$ancestors = array();
		$frontier  = array( (string) $rule->personID );
		for ( $g = 0; $g < (int) $rule->agens && ! empty( $frontier ); $g++ ) {
			$next = array();
			foreach ( $frontier as $pid ) {
				foreach ( self::parents_of( $gedcom, $pid ) as $parent ) {
					if ( ! isset( $members[ $parent ] ) ) {
						$members[ $parent ]   = true;
						$ancestors[ $parent ] = true;
						$next[]               = $parent;
					}
				}
			}
			$frontier = $next;
		}

		// Descendants of the start person.
		self::descend( $gedcom, array( (string) $rule->personID ), (int) $rule->dgens, $members );

		// Descendants of each ancestor (the cousin net).
		if ( (int) $rule->dagens > 0 && ! empty( $ancestors ) ) {
			self::descend( $gedcom, array_keys( $ancestors ), (int) $rule->dagens, $members );
		}

		// Spouses of every member (one pass; spouses do not extend the walk).
		if ( ! empty( $rule->inclspouses ) ) {
			foreach ( array_keys( $members ) as $pid ) {
				foreach ( (array) Families_Table::get_for_spouse( $gedcom, $pid ) as $fam ) {
					$partner = ( (string) $fam->husband === $pid ) ? (string) $fam->wife : (string) $fam->husband;
					if ( '' !== $partner ) {
						$members[ $partner ] = true;
					}
				}
			}
		}

		return $members;
	}

	/**
	 * Walk descendants of a seed set, N generations, into $members (by ref).
	 *
	 * @param  string   $gedcom      Tree identifier.
	 * @param  string[] $seeds       Starting personIDs.
	 * @param  int      $generations How deep.
	 * @param  array    $members     By-ref member set (personID => true).
	 * @return void
	 */
	private static function descend( $gedcom, $seeds, $generations, &$members ) {
		$frontier = $seeds;
		for ( $g = 0; $g < $generations && ! empty( $frontier ); $g++ ) {
			$next = array();
			foreach ( $frontier as $pid ) {
				foreach ( (array) Families_Table::get_for_spouse( $gedcom, $pid ) as $fam ) {
					foreach ( (array) Children_Table::get_by_family( $gedcom, $fam->familyID ) as $link ) {
						$child = (string) $link->personID;
						if ( ! isset( $members[ $child ] ) ) {
							$members[ $child ] = true;
							$next[]            = $child;
						}
					}
				}
			}
			$frontier = $next;
		}
	}

	/**
	 * Parent personIDs of a person (children-table links, famc fallback).
	 *
	 * @param  string $gedcom   Tree identifier.
	 * @param  string $personID Person reference.
	 * @return string[]
	 */
	private static function parents_of( $gedcom, $personID ) {
		$family_ids = array();
		foreach ( (array) Children_Table::get_by_person( $gedcom, $personID ) as $link ) {
			$family_ids[] = (string) $link->familyID;
		}
		if ( empty( $family_ids ) ) {
			$row = People_Table::get_by_ref( $gedcom, $personID );
			if ( $row && ! empty( $row->famc ) ) {
				$family_ids[] = (string) $row->famc;
			}
		}
		$parents = array();
		foreach ( array_unique( $family_ids ) as $fid ) {
			$fam = Families_Table::get_by_ref( $gedcom, $fid );
			if ( ! $fam ) {
				continue;
			}
			if ( ! empty( $fam->husband ) ) {
				$parents[] = (string) $fam->husband;
			}
			if ( ! empty( $fam->wife ) ) {
				$parents[] = (string) $fam->wife;
			}
		}
		return $parents;
	}

	/**
	 * Families labeled by a member set: any family with at least one member
	 * partner (the subset-export convention).
	 *
	 * @param  string $gedcom  Tree identifier.
	 * @param  array  $members personID => true.
	 * @return string[]        familyIDs.
	 */
	private static function member_families( $gedcom, $members ) {
		$families = array();
		foreach ( array_keys( $members ) as $pid ) {
			foreach ( (array) Families_Table::get_for_spouse( $gedcom, $pid ) as $fam ) {
				$families[ (string) $fam->familyID ] = true;
			}
		}
		return array_keys( $families );
	}

	/**
	 * Rebuild the TNG-compatible comma-separated branch caches on people and
	 * families for a whole tree, from branchlinks (two set-based UPDATEs;
	 * records with no links get NULL).
	 *
	 * @param  string $gedcom Tree identifier.
	 * @return void
	 */
	public static function rebuild_caches( $gedcom ) {
		global $wpdb;
		$links    = self::links_table();
		$people   = Tables::people();
		$families = Tables::families();
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own tables; names cannot be placeholders.
		$wpdb->query( $wpdb->prepare(
			"UPDATE %i p SET p.branch = (
				SELECT GROUP_CONCAT(DISTINCT bl.branch ORDER BY bl.branch SEPARATOR ',')
				FROM %i bl
				WHERE bl.gedcom = p.gedcom AND bl.linktype = 'person' AND bl.persfamID = p.personID
			 ) WHERE p.gedcom = %s", $people, $links,
			$gedcom
		) );
		$wpdb->query( $wpdb->prepare(
			"UPDATE %i f SET f.branch = (
				SELECT GROUP_CONCAT(DISTINCT bl.branch ORDER BY bl.branch SEPARATOR ',')
				FROM %i bl
				WHERE bl.gedcom = f.gedcom AND bl.linktype = 'family' AND bl.persfamID = f.familyID
			 ) WHERE f.gedcom = %s", $families, $links,
			$gedcom
		) );
		// phpcs:enable
	}

	// ── Read API ─────────────────────────────────────────────────────────────

	/**
	 * Member persfamIDs of a branch.
	 *
	 * @param  string $gedcom   Tree identifier.
	 * @param  string $branch   Branch name.
	 * @param  string $linktype person|family.
	 * @return string[]
	 */
	public static function members( $gedcom, $branch, $linktype = 'person' ) {
		global $wpdb;
		return $wpdb->get_col( $wpdb->prepare( 'SELECT persfamID FROM %i WHERE gedcom = %s AND branch = %s AND linktype = %s ORDER BY persfamID ASC', self::links_table(), $gedcom, $branch, $linktype ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Member count of a branch (people).
	 *
	 * @param  string $gedcom Tree identifier.
	 * @param  string $branch Branch name.
	 * @return int
	 */
	public static function count_members( $gedcom, $branch ) {
		global $wpdb;
		return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE gedcom = %s AND branch = %s AND linktype = 'person'", self::links_table(), $gedcom, $branch ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Branch names a person/family belongs to, the primary one first (0.11.1).
	 *
	 * @param  string $gedcom    Tree identifier.
	 * @param  string $persfamID Record reference.
	 * @param  string $linktype  person|family.
	 * @return string[]
	 */
	public static function branches_for( $gedcom, $persfamID, $linktype = 'person' ) {
		global $wpdb;
		return $wpdb->get_col( $wpdb->prepare( 'SELECT branch FROM %i WHERE gedcom = %s AND linktype = %s AND persfamID = %s ORDER BY is_primary DESC, branch ASC', self::links_table(), $gedcom, $linktype, $persfamID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	// ── Primary branch (Model C is_primary - 0.11.1) ─────────────────────────

	/**
	 * A person's primary/display branch ('' when none is marked).
	 *
	 * @param  string $gedcom   Tree identifier.
	 * @param  string $personID Person reference.
	 * @return string
	 */
	public static function primary_branch_for( $gedcom, $personID ) {
		global $wpdb;
		return (string) $wpdb->get_var( $wpdb->prepare( "SELECT branch FROM %i WHERE gedcom = %s AND linktype = 'person' AND persfamID = %s AND is_primary = 1 LIMIT 1", self::links_table(), $gedcom, $personID ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Set (or clear) a person's primary branch - the single-select consumer
	 * surface Model C anticipates. Setting a branch ensures the membership
	 * link exists (creating an owner-pinned one when the rules had not
	 * reached the person) and moves the is_primary flag to it; membership in
	 * other branches is unchanged (branch RULES manage membership; this
	 * picks the person's main branch). '' clears the flag without removing
	 * any membership.
	 *
	 * @param  string $gedcom   Tree identifier.
	 * @param  string $personID Person reference (must exist).
	 * @param  string $branch   Branch name ('' = clear the primary flag).
	 * @return bool
	 */
	public static function set_primary( $gedcom, $personID, $branch ) {
		global $wpdb;
		if ( ! People_Table::get_by_ref( $gedcom, $personID ) ) {
			return false;
		}
		$links = self::links_table();
		$now   = Tables::now();

		// Clear the person's current primary flag(s).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
		$wpdb->update( $links, array( 'is_primary' => 0, 'updated_at' => $now ), array( 'gedcom' => $gedcom, 'linktype' => 'person', 'persfamID' => $personID, 'is_primary' => 1 ) );

		if ( '' === (string) $branch ) {
			return true;
		}
		if ( ! self::get_by_ref( $gedcom, $branch ) ) {
			return false;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table; names cannot be placeholders. Block form covers the multi-line query's continuation line.
		$existing = $wpdb->get_row( $wpdb->prepare(
			"SELECT id FROM %i WHERE gedcom = %s AND branch = %s AND linktype = 'person' AND persfamID = %s LIMIT 1", $links,
			$gedcom, $branch, $personID
		) );
		// phpcs:enable
		if ( $existing ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
			$wpdb->update( $links, array( 'is_primary' => 1, 'updated_at' => $now ), array( 'id' => (int) $existing->id ) );
		} else {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table write.
			$wpdb->insert( $links, array(
				'gedcom'             => $gedcom,
				'branch'             => $branch,
				'linktype'           => 'person',
				'persfamID'          => $personID,
				'is_primary'         => 1,
				'created_by_user_id' => get_current_user_id(),
				'created_at'         => $now,
				'updated_at'         => $now,
			) );
			self::rebuild_caches( $gedcom ); // A new membership changes the comma cache.
		}
		return true;
	}
}
