<?php

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

/**
 * Durable vector/search store for founder/admin session transcript chunks.
 */
class Niwa_AI_Session_Memory_Store {
	const TABLE_NAME = 'niwa_ai_session_chunks';
	const CHUNK_SIZE = 800;
	const CHUNK_MIN  = 80;

	/** @var object|null */
	private $embedding_client;
	/** @var object|null */
	private $workspace;
	/** @var array */
	private $last_search_meta = array();

	public function __construct( $embedding_client = null, $workspace = null ) {
		$this->embedding_client = $embedding_client;
		$this->workspace        = $workspace;
	}

	public function table_name() {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_NAME;
	}

	public function ensure_table_schema() {
		global $wpdb;

		$table           = $this->table_name();
		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$table} (
			chunk_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
			session_id VARCHAR(64) NOT NULL,
			turn_id VARCHAR(64) NOT NULL,
			role VARCHAR(20) NOT NULL,
			channel VARCHAR(32) NOT NULL DEFAULT '',
			user_role VARCHAR(32) DEFAULT '',
			user_id BIGINT UNSIGNED DEFAULT 0,
			chunk_index SMALLINT UNSIGNED NOT NULL DEFAULT 0,
			text TEXT NOT NULL,
			embedding_json LONGTEXT,
			embedding_hash CHAR(40) DEFAULT '',
			token_count SMALLINT UNSIGNED DEFAULT 0,
			created_at INT UNSIGNED NOT NULL,
			PRIMARY KEY  (chunk_id),
			UNIQUE KEY uniq_turn_chunk (session_id, turn_id, role, chunk_index),
			KEY idx_session (session_id, created_at),
			KEY idx_role_channel (role, channel),
			KEY idx_created_at (created_at),
			FULLTEXT KEY ft_text (text)
		) ENGINE=InnoDB {$charset_collate};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
	}

	public function record_exchange( $session_id, $user_text, $assistant_text, $context = array() ) {
		$session_id = $this->normalize_session_id( $session_id );
		if ( '' === $session_id ) {
			return array(
				'success'  => false,
				'recorded' => 0,
				'turn_id'  => '',
				'error'    => 'empty_session_id',
			);
		}

		$context = is_array( $context ) ? $context : array();
		$turn_id = $this->derive_turn_id( $session_id, $user_text, $assistant_text, $context );
		$count   = 0;

		if ( '' !== trim( (string) $user_text ) ) {
			$count += $this->record_turn( $session_id, $turn_id, 'user', $user_text, $context );
		}
		if ( '' !== trim( (string) $assistant_text ) ) {
			$count += $this->record_turn( $session_id, $turn_id, 'assistant', $assistant_text, $context );
		}

		return array(
			'success'  => true,
			'recorded' => $count,
			'turn_id'  => $turn_id,
		);
	}

	public function record_turn( $session_id, $turn_id, $role, $text, $context = array() ) {
		global $wpdb;

		$session_id = $this->normalize_session_id( $session_id );
		$turn_id    = $this->normalize_turn_id( $turn_id );
		$role       = $this->normalize_role( $role );
		$text       = trim( (string) $text );
		$context    = is_array( $context ) ? $context : array();

		if ( '' === $session_id || '' === $turn_id || '' === $text ) {
			return 0;
		}

		$chunks = $this->chunk_text( $text );
		if ( empty( $chunks ) ) {
			return 0;
		}

		$embeddings = $this->embed_chunks( $chunks );
		$table      = $this->table_name();
		$channel    = $this->resolve_channel( $session_id, $context );
		$user_role  = isset( $context['user_role'] ) ? sanitize_key( (string) $context['user_role'] ) : '';
		$user_id    = isset( $context['admin_user_id'] ) ? max( 0, intval( $context['admin_user_id'] ) ) : 0;
		$created_at = isset( $context['created_at'] ) ? max( 1, intval( $context['created_at'] ) ) : time();
		$inserted   = 0;

		foreach ( $chunks as $index => $chunk ) {
			$hash      = sha1( $chunk );
			$embedding = isset( $embeddings[ $index ] ) && is_array( $embeddings[ $index ] ) ? array_values( $embeddings[ $index ] ) : array();
			$data      = array(
				'session_id'     => $session_id,
				'turn_id'        => $turn_id,
				'role'           => $role,
				'channel'        => $channel,
				'user_role'      => $user_role,
				'user_id'        => $user_id,
				'chunk_index'    => $index,
				'text'           => $chunk,
				'embedding_json' => ! empty( $embedding ) ? wp_json_encode( $embedding ) : null,
				'embedding_hash' => $hash,
				'token_count'    => $this->estimate_tokens( $chunk ),
				'created_at'     => $created_at,
			);

			$values = array(
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%d',
				'%d',
				'%s',
				'%s',
				'%s',
				'%d',
				'%d',
			);

			$columns = array_keys( $data );
			$placeholders = implode( ', ', $values );
			$sql = "INSERT INTO {$table} (" . implode( ', ', $columns ) . ") VALUES ({$placeholders})
				ON DUPLICATE KEY UPDATE
					text = VALUES(text),
					channel = VALUES(channel),
					user_role = VALUES(user_role),
					user_id = VALUES(user_id),
					embedding_json = IF(VALUES(embedding_hash) = embedding_hash AND embedding_json IS NOT NULL AND embedding_json <> '', embedding_json, VALUES(embedding_json)),
					embedding_hash = VALUES(embedding_hash),
					token_count = VALUES(token_count),
					created_at = VALUES(created_at)";

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
			if ( false !== $wpdb->query( $wpdb->prepare( $sql, array_values( $data ) ) ) ) {
				$inserted++;
			}
		}

		return $inserted;
	}

	public function search( $query, $limit = 4, $options = array() ) {
		global $wpdb;

		$this->last_search_meta = array( 'archive_scan' => $this->empty_archive_scan_meta() );
		$query = trim( (string) $query );
		if ( '' === $query ) {
			return array();
		}

		$limit   = max( 1, min( 8, intval( $limit ) ) );
		$options = is_array( $options ) ? $options : array();
		$table   = $this->table_name();
		$params  = array();
		$where   = array( "role IN ('user','assistant')" );

		if ( ! empty( $options['exclude_session_id'] ) ) {
			$where[]  = 'session_id <> %s';
			$params[] = $this->normalize_session_id( $options['exclude_session_id'] );
		}

		$where_sql = implode( ' AND ', $where );
		$scan      = max( 40, min( 300, $limit * 40 ) );
		$rows      = array();

		if ( empty( $options['archive_cursor'] ) ) {
			$ft_sql = "SELECT chunk_id, session_id, turn_id, role, channel, user_role, user_id, chunk_index, text, embedding_json, token_count, created_at,
				MATCH(text) AGAINST (%s IN NATURAL LANGUAGE MODE) AS _lexical_score
			FROM {$table}
			WHERE {$where_sql} AND MATCH(text) AGAINST (%s IN NATURAL LANGUAGE MODE)
			ORDER BY _lexical_score DESC, created_at DESC
			LIMIT {$scan}";
			$ft_params = array_merge( array( $query ), $params, array( $query ) );
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
			$ft_rows = $wpdb->get_results( $wpdb->prepare( $ft_sql, $ft_params ), ARRAY_A );
			if ( is_array( $ft_rows ) ) {
				$rows = $ft_rows;
			}

			$like_rows = $this->like_search( $query, $scan, $options );
			if ( ! empty( $like_rows ) ) {
				$rows = $this->merge_rows( $rows, $like_rows );
			}

			$query_vec = $this->embed_query( $query );
			if ( ! empty( $query_vec ) ) {
				$vector_rows = $this->vector_scan( $query_vec, $scan, $options );
				$rows        = $this->merge_rows( $rows, $vector_rows );
				$rows        = $this->rerank_rows( $rows, $query_vec );
			} else {
				$rows = $this->normalize_lexical_scores( $rows );
			}
		}

		// Archive files stay cold during normal turns. They are streamed only
		// when an explicit session recall/search reaches this method.
		$rows = $this->merge_rows( $rows, $this->search_workspace_archives( $query, $scan, $options ) );

		usort(
			$rows,
			function ( $left, $right ) {
				$ls = isset( $left['_score'] ) ? floatval( $left['_score'] ) : 0.0;
				$rs = isset( $right['_score'] ) ? floatval( $right['_score'] ) : 0.0;
				if ( $ls === $rs ) {
					$lt = isset( $left['created_at'] ) ? intval( $left['created_at'] ) : 0;
					$rt = isset( $right['created_at'] ) ? intval( $right['created_at'] ) : 0;
					return $rt <=> $lt;
				}
				return $rs <=> $ls;
			}
		);

		// The same exchange can be chunked twice (live capture + transcript
		// backfill use different turn ids); identical texts would waste the
		// few result slots, so keep only the best-scored copy of each text.
		$deduped = array();
		$seen    = array();
		foreach ( $rows as $row ) {
			$key = sha1( $this->normalize_text( isset( $row['text'] ) ? (string) $row['text'] : '' ) );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$deduped[]    = $row;
			if ( count( $deduped ) >= $limit ) {
				break;
			}
		}

		return $deduped;
	}

	public function get_last_search_meta() {
		return $this->last_search_meta;
	}

	public function build_tool_results( $rows ) {
		$out = array();
		foreach ( (array) $rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$out[] = array(
				'session_id' => isset( $row['session_id'] ) ? (string) $row['session_id'] : '',
				'turn_id'    => isset( $row['turn_id'] ) ? (string) $row['turn_id'] : '',
				'role'       => isset( $row['role'] ) ? (string) $row['role'] : '',
				'channel'    => isset( $row['channel'] ) ? (string) $row['channel'] : '',
				'user_role'  => isset( $row['user_role'] ) ? (string) $row['user_role'] : '',
				'created_at' => isset( $row['created_at'] ) ? intval( $row['created_at'] ) : 0,
				'score'      => isset( $row['_score'] ) ? round( floatval( $row['_score'] ), 3 ) : 0.0,
				'text'       => $this->clip_text( isset( $row['text'] ) ? (string) $row['text'] : '', 900 ),
				'archived'   => ! empty( $row['archived'] ),
			);
		}

		return $out;
	}

	public function backfill_from_workspace( $workspace, $limit = 500 ) {
		if ( ! $workspace || ! method_exists( $workspace, 'get_paths' ) ) {
			return array(
				'imported' => 0,
				'sources'  => array(),
			);
		}

		$limit    = max( 1, min( 2000, intval( $limit ) ) );
		$imported = 0;
		$sources  = array();

		if ( method_exists( $workspace, 'list_session_manifests' ) && method_exists( $workspace, 'read_session_transcript' ) ) {
			foreach ( (array) $workspace->list_session_manifests( 60 ) as $manifest ) {
				if ( $imported >= $limit || ! is_array( $manifest ) || empty( $manifest['session_id'] ) ) {
					continue;
				}
				$session_id = (string) $manifest['session_id'];
				foreach ( (array) $workspace->read_session_transcript( $session_id, 200 ) as $record ) {
					if ( $imported >= $limit || ! is_array( $record ) ) {
						break;
					}
					$type = isset( $record['type'] ) ? (string) $record['type'] : '';
					if ( 'turn_start' === $type && ! empty( $record['prompt'] ) ) {
						$imported += $this->record_turn( $session_id, $this->backfill_turn_id( $session_id, $record, 'user' ), 'user', (string) $record['prompt'], $this->backfill_context( $record ) );
					} elseif ( 'turn_end' === $type && ! empty( $record['reply'] ) ) {
						$imported += $this->record_turn( $session_id, $this->backfill_turn_id( $session_id, $record, 'assistant' ), 'assistant', (string) $record['reply'], $this->backfill_context( $record ) );
					}
				}
				$sources[] = 'sessions:' . $session_id;
			}
		}

		$paths = (array) $workspace->get_paths();
		$memory_dir = isset( $paths['memory'] ) ? (string) $paths['memory'] : '';
		if ( '' !== $memory_dir && is_dir( $memory_dir ) && $imported < $limit ) {
			$patterns = array(
				trailingslashit( $memory_dir ) . 'companion/diary.md',
				trailingslashit( $memory_dir ) . 'companion/diary/*.md',
				trailingslashit( $memory_dir ) . 'diary/current/*.md',
				trailingslashit( $memory_dir ) . 'diary/archived/*.md',
			);
			foreach ( $patterns as $pattern ) {
				foreach ( (array) glob( $pattern ) as $path ) {
					if ( $imported >= $limit || ! is_file( $path ) ) {
						break 2;
					}
					$text = file_get_contents( $path );
					if ( ! is_string( $text ) || '' === trim( $text ) ) {
						continue;
					}
					$context = array(
						'channel'    => 'workspace_backfill',
						'user_role'  => 'system',
						'created_at' => filemtime( $path ) ? filemtime( $path ) : time(),
					);
					$imported += $this->record_turn( 'workspace-memory-diary', 'diary-' . substr( sha1( $path ), 0, 40 ), 'assistant', $text, $context );
					$sources[] = 'diary:' . basename( $path );
				}
			}
		}

		return array(
			'imported' => $imported,
			'sources'  => array_values( array_unique( $sources ) ),
		);
	}

	private function like_search( $query, $limit, $options ) {
		global $wpdb;

		$terms = preg_split( '/\s+/u', $this->normalize_text( $query ) );
		$terms = array_values( array_filter( (array) $terms ) );
		if ( empty( $terms ) ) {
			return array();
		}

		$table       = $this->table_name();
		$where_parts = array( "role IN ('user','assistant')" );
		$score_parts = array();
		$score_params = array();
		$where_params = array();

		if ( ! empty( $options['exclude_session_id'] ) ) {
			$where_parts[] = 'session_id <> %s';
			$where_params[] = $this->normalize_session_id( $options['exclude_session_id'] );
		}

		foreach ( $terms as $term ) {
			if ( $this->text_strlen( $term ) < 3 ) {
				continue;
			}
			$like          = '%' . $wpdb->esc_like( $term ) . '%';
			$where_parts[] = 'text LIKE %s';
			$score_parts[] = 'CASE WHEN text LIKE %s THEN 1 ELSE 0 END';
			$score_params[] = $like;
			$where_params[] = $like;
		}

		if ( empty( $score_parts ) ) {
			return array();
		}

		$score_sql = implode( ' + ', $score_parts );
		$where_sql = implode( ' AND ', $where_parts );
		$sql       = "SELECT chunk_id, session_id, turn_id, role, channel, user_role, user_id, chunk_index, text, embedding_json, token_count, created_at, ({$score_sql}) AS _lexical_score
			FROM {$table}
			WHERE {$where_sql}
			HAVING _lexical_score > 0
			ORDER BY _lexical_score DESC, created_at DESC
			LIMIT " . max( 1, intval( $limit ) );

		$params = array_merge( $score_params, $where_params );
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$rows = $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A );
		return is_array( $rows ) ? $rows : array();
	}

	private function vector_scan( $query_vec, $limit, $options ) {
		global $wpdb;

		$table  = $this->table_name();
		$where  = array( "role IN ('user','assistant')", "embedding_json IS NOT NULL", "embedding_json <> ''" );
		$params = array();
		if ( ! empty( $options['exclude_session_id'] ) ) {
			$where[]  = 'session_id <> %s';
			$params[] = $this->normalize_session_id( $options['exclude_session_id'] );
		}

		$where_sql = implode( ' AND ', $where );
		$sql = "SELECT chunk_id, session_id, turn_id, role, channel, user_role, user_id, chunk_index, text, embedding_json, token_count, created_at, 0 AS _lexical_score
			FROM {$table}
			WHERE {$where_sql}
			ORDER BY created_at DESC
			LIMIT " . max( 100, min( 1000, intval( $limit ) * 5 ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$rows = ! empty( $params ) ? $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A ) : $wpdb->get_results( $sql, ARRAY_A );
		if ( ! is_array( $rows ) ) {
			return array();
		}

		foreach ( $rows as &$row ) {
			$row['_vector_score'] = $this->cosine_similarity( $query_vec, $this->decode_embedding( isset( $row['embedding_json'] ) ? $row['embedding_json'] : '' ) );
			$row['_score']        = $row['_vector_score'];
		}
		unset( $row );

		usort(
			$rows,
			function ( $left, $right ) {
				return floatval( isset( $right['_vector_score'] ) ? $right['_vector_score'] : 0 ) <=> floatval( isset( $left['_vector_score'] ) ? $left['_vector_score'] : 0 );
			}
		);

		return array_slice( $rows, 0, max( 1, intval( $limit ) ) );
	}

	private function rerank_rows( $rows, $query_vec ) {
		$lex_max = 0.0;
		$vec_max = 0.0;

		foreach ( $rows as &$row ) {
			$lex = isset( $row['_lexical_score'] ) ? floatval( $row['_lexical_score'] ) : 0.0;
			$vec = isset( $row['_vector_score'] ) ? floatval( $row['_vector_score'] ) : null;
			if ( null === $vec ) {
				$vec = $this->cosine_similarity( $query_vec, $this->decode_embedding( isset( $row['embedding_json'] ) ? $row['embedding_json'] : '' ) );
			}
			$row['_vector_score'] = $vec;
			$lex_max = max( $lex_max, $lex );
			$vec_max = max( $vec_max, $vec );
		}
		unset( $row );

		foreach ( $rows as &$row ) {
			$lex = isset( $row['_lexical_score'] ) ? floatval( $row['_lexical_score'] ) : 0.0;
			$vec = isset( $row['_vector_score'] ) ? floatval( $row['_vector_score'] ) : 0.0;
			$lex_n = $lex_max > 0 ? $lex / $lex_max : 0.0;
			$vec_n = $vec_max > 0 ? $vec / $vec_max : 0.0;
			$row['_score'] = ( 0.45 * $lex_n ) + ( 0.55 * $vec_n );
		}
		unset( $row );

		return $rows;
	}

	private function normalize_lexical_scores( $rows ) {
		$max = 0.0;
		foreach ( $rows as $row ) {
			$max = max( $max, isset( $row['_lexical_score'] ) ? floatval( $row['_lexical_score'] ) : 0.0 );
		}
		foreach ( $rows as &$row ) {
			$lex = isset( $row['_lexical_score'] ) ? floatval( $row['_lexical_score'] ) : 0.0;
			$row['_score'] = $max > 0 ? $lex / $max : 0.0;
		}
		unset( $row );
		return $rows;
	}

	private function merge_rows( $a, $b ) {
		$merged = array();
		foreach ( array_merge( (array) $a, (array) $b ) as $row ) {
			if ( ! is_array( $row ) || empty( $row['chunk_id'] ) ) {
				continue;
			}
			$key = (string) $row['chunk_id'];
			if ( ! isset( $merged[ $key ] ) ) {
				$merged[ $key ] = $row;
				continue;
			}
			$existing = isset( $merged[ $key ]['_lexical_score'] ) ? floatval( $merged[ $key ]['_lexical_score'] ) : 0.0;
			$current  = isset( $row['_lexical_score'] ) ? floatval( $row['_lexical_score'] ) : 0.0;
			if ( $current > $existing ) {
				$merged[ $key ] = $row;
			}
		}

		return array_values( $merged );
	}

	private function search_workspace_archives( $query, $limit, $options ) {
		if ( ! $this->workspace || ! method_exists( $this->workspace, 'get_paths' ) ) {
			$this->last_search_meta['archive_scan'] = $this->empty_archive_scan_meta();
			return array();
		}

		$paths       = (array) $this->workspace->get_paths();
		$sessions    = isset( $paths['sessions'] ) ? rtrim( (string) $paths['sessions'], "\\/" ) : '';
		$archive_dir = '' !== $sessions ? $sessions . '/.archive' : '';
		$query_text  = $this->normalize_text( $query );
		if ( '' === $archive_dir || ! is_dir( $archive_dir ) || '' === $query_text ) {
			$this->last_search_meta['archive_scan'] = $this->empty_archive_scan_meta();
			return array();
		}

		$terms = array_values( array_unique( array_filter( preg_split( '/\s+/u', $query_text ) ?: array() ) ) );
		if ( empty( $terms ) ) {
			$this->last_search_meta['archive_scan'] = $this->empty_archive_scan_meta();
			return array();
		}

		$options     = is_array( $options ) ? $options : array();
		$max_files  = isset( $options['archive_max_files'] ) ? intval( $options['archive_max_files'] ) : intval( apply_filters( 'niwa_ai_session_recall_archive_max_files', 24 ) );
		$max_bytes  = isset( $options['archive_max_bytes'] ) ? intval( $options['archive_max_bytes'] ) : intval( apply_filters( 'niwa_ai_session_recall_archive_max_bytes', 8 * 1024 * 1024 ) );
		$max_records = isset( $options['archive_max_records'] ) ? intval( $options['archive_max_records'] ) : intval( apply_filters( 'niwa_ai_session_recall_archive_max_records', 12000 ) );
		$max_files   = max( 1, min( 200, $max_files ) );
		$max_bytes   = max( 65536, min( 64 * 1024 * 1024, $max_bytes ) );
		$max_records = max( 100, min( 50000, $max_records ) );
		$cursor      = $this->decode_archive_cursor( isset( $options['archive_cursor'] ) ? (string) $options['archive_cursor'] : '' );
		$seen        = array();
		foreach ( isset( $cursor['seen'] ) && is_array( $cursor['seen'] ) ? array_slice( $cursor['seen'], -256 ) : array() as $identity ) {
			$seen[ (string) $identity ] = true;
		}

		$stem_sessions = array();
		if ( method_exists( $this->workspace, 'list_session_manifests' ) && method_exists( $this->workspace, 'get_session_transcript_path' ) ) {
			foreach ( (array) $this->workspace->list_session_manifests( 200 ) as $manifest ) {
				$session_id = is_array( $manifest ) && ! empty( $manifest['session_id'] ) ? (string) $manifest['session_id'] : '';
				if ( '' === $session_id ) {
					continue;
				}
				$stem = preg_replace( '/\.jsonl$/i', '', basename( (string) $this->workspace->get_session_transcript_path( $session_id ) ) );
				if ( '' !== $stem ) {
					$stem_sessions[ $stem ] = $session_id;
				}
			}
		}

		$files = array_values( array_filter( (array) glob( $archive_dir . '/*.jsonl' ), 'is_file' ) );
		usort(
			$files,
			function ( $left, $right ) {
				$modified = intval( @filemtime( $right ) ) <=> intval( @filemtime( $left ) );
				return 0 !== $modified ? $modified : strcmp( basename( $right ), basename( $left ) );
			}
		);
		if ( empty( $files ) ) {
			$this->last_search_meta['archive_scan'] = $this->empty_archive_scan_meta();
			return array();
		}

		$start_index = 0;
		$start_file  = isset( $cursor['file'] ) ? basename( (string) $cursor['file'] ) : '';
		if ( '' !== $start_file ) {
			foreach ( $files as $index => $path ) {
				if ( $start_file === basename( $path ) ) {
					$start_index = $index;
					break;
				}
			}
		}

		$rows            = array();
		$files_scanned   = 0;
		$bytes_scanned   = 0;
		$records_scanned = 0;
		$next_file       = '';
		$next_offset     = 0;
		$max_results     = max( 40, min( 1000, intval( $limit ) * 10 ) );
		for ( $file_index = $start_index; $file_index < count( $files ); $file_index++ ) {
			$archive_path = $files[ $file_index ];
			if ( $files_scanned >= $max_files || $bytes_scanned >= $max_bytes || $records_scanned >= $max_records ) {
				$next_file = basename( $archive_path );
				break;
			}
			if ( ! is_file( $archive_path ) || ! is_readable( $archive_path ) ) {
				continue;
			}
			$files_scanned++;
			$filename   = basename( $archive_path );
			$session_id = '';
			foreach ( $stem_sessions as $stem => $mapped_session_id ) {
				if ( 0 === strpos( $filename, $stem . '.' ) ) {
					$session_id = $mapped_session_id;
					break;
				}
			}
			if ( ! empty( $options['exclude_session_id'] ) && $this->normalize_session_id( $options['exclude_session_id'] ) === $this->normalize_session_id( $session_id ) ) {
				continue;
			}

			$handle = @fopen( $archive_path, 'rb' );
			if ( ! $handle ) {
				continue;
			}
			$offset = $file_index === $start_index && $filename === $start_file ? max( 0, intval( $cursor['offset'] ?? 0 ) ) : 0;
			if ( $offset > 0 ) {
				@fseek( $handle, $offset );
			}
			$file_size = intval( @filesize( $archive_path ) );
			while ( $bytes_scanned < $max_bytes && $records_scanned < $max_records && false !== ( $line = fgets( $handle ) ) ) {
				$bytes_scanned += strlen( $line );
				$records_scanned++;
				$record = json_decode( trim( $line ), true );
				if ( ! is_array( $record ) || $this->is_internal_transcript_record( $record ) ) {
					continue;
				}
				$type = isset( $record['type'] ) ? (string) $record['type'] : '';
				if ( 'turn_start' === $type ) {
					$role = 'user';
					$text = isset( $record['prompt'] ) ? (string) $record['prompt'] : '';
				} elseif ( 'turn_end' === $type ) {
					$role = 'assistant';
					$text = isset( $record['reply'] ) ? (string) $record['reply'] : '';
				} else {
					continue;
				}
				$normalized = $this->normalize_text( $text );
				if ( '' === $normalized ) {
					continue;
				}
				$matched = 0;
				foreach ( $terms as $term ) {
					if ( false !== strpos( $normalized, $term ) ) {
						$matched++;
					}
				}
				if ( 0 === $matched ) {
					continue;
				}
				$record_session = ! empty( $record['session_id'] ) ? (string) $record['session_id'] : $session_id;
				if ( ! empty( $options['exclude_session_id'] ) && $this->normalize_session_id( $options['exclude_session_id'] ) === $this->normalize_session_id( $record_session ) ) {
					continue;
				}
				$meta           = isset( $record['meta'] ) && is_array( $record['meta'] ) ? $record['meta'] : array();
				$score          = min( 1, ( $matched / count( $terms ) ) * 0.8 + ( false !== strpos( $normalized, $query_text ) ? 0.2 : 0 ) );
				$turn_id        = ! empty( $record['turn_id'] ) ? (string) $record['turn_id'] : 'archive-' . substr( sha1( $line ), 0, 40 );
				$identity       = sha1( $record_session . '|' . $turn_id . '|' . $type . '|' . $text );
				if ( isset( $seen[ $identity ] ) ) {
					continue;
				}
				$seen[ $identity ] = true;
				$rows[]         = array(
					'chunk_id'      => 'archive-' . $identity,
					'session_id'    => $record_session,
					'turn_id'       => $turn_id,
					'role'          => $role,
					'channel'       => ! empty( $meta['channel'] ) ? sanitize_key( (string) $meta['channel'] ) : $this->resolve_channel( $record_session, array() ),
					'user_role'     => ! empty( $meta['user_role'] ) ? sanitize_key( (string) $meta['user_role'] ) : '',
					'user_id'       => ! empty( $meta['admin_user_id'] ) ? intval( $meta['admin_user_id'] ) : 0,
					'chunk_index'   => 0,
					'text'          => $text,
					'embedding_json' => '',
					'token_count'   => $this->estimate_tokens( $text ),
					'created_at'    => ! empty( $record['ts'] ) ? intval( $record['ts'] ) : intval( @filemtime( $archive_path ) ),
					'_lexical_score' => $score,
					'_score'        => $score,
					'archived'      => true,
				);
				if ( count( $rows ) > $max_results * 2 ) {
					usort( $rows, function ( $left, $right ) { return floatval( $right['_score'] ?? 0 ) <=> floatval( $left['_score'] ?? 0 ); } );
					$rows = array_slice( $rows, 0, $max_results );
				}
			}
			$position = intval( @ftell( $handle ) );
			@fclose( $handle );
			if ( $position < $file_size ) {
				$next_file   = $filename;
				$next_offset = $position;
				break;
			}
			if ( $files_scanned >= $max_files && isset( $files[ $file_index + 1 ] ) ) {
				$next_file = basename( $files[ $file_index + 1 ] );
				break;
			}
		}

		usort(
			$rows,
			function ( $left, $right ) {
				return floatval( $right['_score'] ?? 0 ) <=> floatval( $left['_score'] ?? 0 );
			}
		);

		$truncated   = '' !== $next_file;
		$seen_cursor = array_slice( array_keys( $seen ), -256 );
		$next_cursor = $truncated ? $this->encode_archive_cursor( $next_file, $next_offset, $seen_cursor ) : '';
		$this->last_search_meta['archive_scan'] = array(
			'truncated'      => $truncated,
			'next_cursor'    => $next_cursor,
			'files_scanned'  => $files_scanned,
			'bytes_scanned'  => $bytes_scanned,
			'records_scanned' => $records_scanned,
		);

		return array_slice( $rows, 0, max( 1, intval( $limit ) ) );
	}

	private function empty_archive_scan_meta() {
		return array(
			'truncated'       => false,
			'next_cursor'     => '',
			'files_scanned'   => 0,
			'bytes_scanned'   => 0,
			'records_scanned' => 0,
		);
	}

	private function encode_archive_cursor( $file, $offset, $seen ) {
		$json = wp_json_encode(
			array(
				'file'   => basename( (string) $file ),
				'offset' => max( 0, intval( $offset ) ),
				'seen'   => array_values( array_slice( (array) $seen, -256 ) ),
			)
		);
		return is_string( $json ) ? rtrim( strtr( base64_encode( $json ), '+/', '-_' ), '=' ) : '';
	}

	private function decode_archive_cursor( $cursor ) {
		$cursor = trim( (string) $cursor );
		if ( '' === $cursor ) {
			return array();
		}
		$padding = strlen( $cursor ) % 4;
		if ( $padding > 0 ) {
			$cursor .= str_repeat( '=', 4 - $padding );
		}
		$raw     = base64_decode( strtr( $cursor, '-_', '+/' ), true );
		$decoded = is_string( $raw ) ? json_decode( $raw, true ) : array();
		return is_array( $decoded ) ? $decoded : array();
	}

	private function is_internal_transcript_record( $record ) {
		$meta       = isset( $record['meta'] ) && is_array( $record['meta'] ) ? $record['meta'] : array();
		$visibility = isset( $meta['visibility'] ) ? sanitize_key( (string) $meta['visibility'] ) : '';
		if ( in_array( $visibility, array( 'internal', 'hidden' ), true ) ) {
			return true;
		}

		$internal = $meta['internal'] ?? false;
		return true === $internal || '1' === (string) $internal || 'true' === strtolower( (string) $internal );
	}

	private function embed_chunks( $chunks ) {
		if ( ! $this->embedding_client || ! method_exists( $this->embedding_client, 'embed_texts' ) ) {
			return array();
		}
		$texts = array_map(
			function ( $chunk ) {
				return $this->clip_text( $chunk, Niwa_AI_Constants::EMBEDDING_CLIP_CHARS );
			},
			(array) $chunks
		);
		$embeddings = $this->embedding_client->embed_texts( $texts );
		return is_array( $embeddings ) ? $embeddings : array();
	}

	private function embed_query( $query ) {
		if ( ! $this->embedding_client || ! method_exists( $this->embedding_client, 'embed_text' ) ) {
			return array();
		}
		$vec = $this->embedding_client->embed_text( $query );
		return is_array( $vec ) ? $vec : array();
	}

	private function decode_embedding( $json ) {
		if ( '' === trim( (string) $json ) ) {
			return array();
		}
		$decoded = json_decode( (string) $json, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	private function cosine_similarity( $a, $b ) {
		if ( empty( $a ) || empty( $b ) ) {
			return 0.0;
		}
		$len = min( count( $a ), count( $b ) );
		if ( $len <= 0 ) {
			return 0.0;
		}
		$dot = 0.0;
		$na  = 0.0;
		$nb  = 0.0;
		for ( $i = 0; $i < $len; $i++ ) {
			$av = floatval( $a[ $i ] );
			$bv = floatval( $b[ $i ] );
			$dot += $av * $bv;
			$na  += $av * $av;
			$nb  += $bv * $bv;
		}
		return $na > 0 && $nb > 0 ? $dot / ( sqrt( $na ) * sqrt( $nb ) ) : 0.0;
	}

	private function chunk_text( $text ) {
		$text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
		if ( '' === $text ) {
			return array();
		}
		if ( $this->text_strlen( $text ) <= self::CHUNK_SIZE ) {
			return array( $text );
		}

		$chunks = array();
		$offset = 0;
		$length = $this->text_strlen( $text );
		while ( $offset < $length ) {
			$chunk = $this->text_substr( $text, $offset, self::CHUNK_SIZE );
			$cut   = $this->text_strrpos( $chunk, '. ' );
			if ( false === $cut || $cut < self::CHUNK_MIN ) {
				$cut = $this->text_strrpos( $chunk, ' ' );
			}
			if ( false !== $cut && $cut >= self::CHUNK_MIN && $offset + self::CHUNK_SIZE < $length ) {
				$chunk = $this->text_substr( $chunk, 0, $cut + 1 );
			}
			$chunk = trim( $chunk );
			if ( '' !== $chunk ) {
				$chunks[] = $chunk;
			}
			$offset += max( 1, $this->text_strlen( $chunk ) );
		}

		return $chunks;
	}

	private function derive_turn_id( $session_id, $user_text, $assistant_text, $context ) {
		if ( ! empty( $context['turn_id'] ) ) {
			return $this->normalize_turn_id( $context['turn_id'] );
		}
		if ( ! empty( $context['turn_job_id'] ) ) {
			return $this->normalize_turn_id( $context['turn_job_id'] );
		}
		if ( ! empty( $context['message_id'] ) ) {
			return $this->normalize_turn_id( $session_id . '-' . intval( $context['message_id'] ) );
		}
		return 'exchange-' . substr( sha1( $session_id . "\n" . (string) $user_text . "\n" . (string) $assistant_text ), 0, 40 );
	}

	private function backfill_turn_id( $session_id, $record, $role ) {
		$turn_id = isset( $record['turn_id'] ) ? (string) $record['turn_id'] : '';
		if ( '' !== trim( $turn_id ) ) {
			return $this->normalize_turn_id( $turn_id );
		}
		return 'backfill-' . substr( sha1( $session_id . wp_json_encode( $record ) . $role ), 0, 40 );
	}

	private function backfill_context( $record ) {
		$record = is_array( $record ) ? $record : array();
		$meta   = isset( $record['meta'] ) && is_array( $record['meta'] ) ? $record['meta'] : array();
		return array(
			'channel'    => ! empty( $meta['channel'] ) ? sanitize_key( (string) $meta['channel'] ) : 'workspace_backfill',
			'user_role'  => ! empty( $meta['user_role'] ) ? sanitize_key( (string) $meta['user_role'] ) : '',
			'created_at' => isset( $record['ts'] ) ? intval( $record['ts'] ) : time(),
		);
	}

	private function resolve_channel( $session_id, $context ) {
		if ( ! empty( $context['channel'] ) ) {
			return sanitize_key( (string) $context['channel'] );
		}
		if ( 0 === strpos( $session_id, 'admin-telegram-' ) ) {
			return 'telegram';
		}
		if ( 0 === strpos( $session_id, 'admin-browser-' ) ) {
			return 'admin_ui';
		}
		if ( false !== strpos( $session_id, ':subagent:' ) ) {
			return 'subagent';
		}
		return 'admin';
	}

	private function normalize_role( $role ) {
		$role = sanitize_key( (string) $role );
		if ( 'bot' === $role ) {
			return 'assistant';
		}
		return in_array( $role, array( 'user', 'assistant', 'tool', 'system' ), true ) ? $role : 'system';
	}

	private function normalize_session_id( $session_id ) {
		return substr( sanitize_text_field( (string) $session_id ), 0, 64 );
	}

	private function normalize_turn_id( $turn_id ) {
		return substr( sanitize_text_field( (string) $turn_id ), 0, 64 );
	}

	private function normalize_text( $text ) {
		$text = $this->text_strtolower( (string) $text );
		$text = preg_replace( '/[^\p{L}\p{N}\s]/u', ' ', $text );
		$text = preg_replace( '/\s{2,}/u', ' ', $text );
		return trim( $text );
	}

	private function estimate_tokens( $text ) {
		return max( 1, (int) ceil( $this->text_strlen( (string) $text ) / 4 ) );
	}

	private function clip_text( $text, $limit ) {
		$text  = trim( (string) $text );
		$limit = max( 40, intval( $limit ) );
		if ( $this->text_strlen( $text ) <= $limit ) {
			return $text;
		}
		return rtrim( $this->text_substr( $text, 0, $limit - 3 ) ) . '...';
	}

	private function text_strlen( $text ) {
		return function_exists( 'mb_strlen' ) ? mb_strlen( (string) $text ) : strlen( (string) $text );
	}

	private function text_substr( $text, $start, $length = null ) {
		if ( function_exists( 'mb_substr' ) ) {
			return null === $length ? mb_substr( (string) $text, $start ) : mb_substr( (string) $text, $start, $length );
		}
		return null === $length ? substr( (string) $text, $start ) : substr( (string) $text, $start, $length );
	}

	private function text_strrpos( $haystack, $needle ) {
		return function_exists( 'mb_strrpos' ) ? mb_strrpos( (string) $haystack, (string) $needle ) : strrpos( (string) $haystack, (string) $needle );
	}

	private function text_strtolower( $text ) {
		return function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $text ) : strtolower( (string) $text );
	}
}
