<?php
/**
 * NeccoSpeak Phase 6 — G16 function-calling tool registry (design phase6
 * §2.3 / §5).
 *
 * Owns the read-only tool registry the /chat tool loop (includes/rest.php,
 * impl-chat) drives, and the two built-in tools: site_search, get_page_info.
 *
 * 2026-08 撤去記録: a third built-in tool, `current_datetime`, was removed in
 * 0.3.0-dev.60 — the current date/time is now supplied unconditionally by
 * includes/prompt.php's `current_datetime` prompt section (both modes, no
 * setting), so the tool (text-mode-only, needed two toggles, and switching it
 * on silently disabled SSE for every reply) no longer serves a purpose. See
 * this file's registry (§5.2) and `neccospeak_tool_current_datetime()`'s old
 * location for what was removed.
 *
 * INV-13 (read-only): every callback in this file uses only WP_Query /
 * get_post() and their read-side neighbours (get_permalink(),
 * get_the_title(), get_the_excerpt(), get_post_meta(), url_to_postid(),
 * is_post_type_viewable()). No option is written, no
 * post is written, no file I/O, no outbound HTTP.
 *
 * INV-14 (untrusted text): neccospeak_tools_execute() always returns
 * wp_json_encode()'d content — a post excerpt embedded in a JSON string
 * literal cannot start a line with "#", so it cannot forge a prompt
 * section heading when the tool result is later placed into the `tool`
 * role message. See T4 below.
 *
 * Owner: impl-tools (Phase 6).
 * Public contract (design phase6 §2.3 / §A.1):
 *   neccospeak_tools_registry() : array
 *   neccospeak_tools_enabled( $opts ) : bool
 *   neccospeak_tools_specs( $opts ) : array
 *   neccospeak_tools_execute( $name, $arguments_json, $opts, $budget_ms = 1500 ) : array
 *   neccospeak_tools_max_rounds() : int
 *   neccospeak_tools_instruction_fit( $opts, $mode ) : array
 *   neccospeak_tools_section( $opts, $mode ) : array|null
 *   neccospeak_tools_names( $opts ) : string[]
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/* -----------------------------------------------------------------------
 * §5.1 constants
 * -------------------------------------------------------------------- */

if ( ! defined( 'NECCOSPEAK_TOOL_MAX_ROUNDS' ) ) {
	define( 'NECCOSPEAK_TOOL_MAX_ROUNDS', 2 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_MAX_CALLS_PER_ROUND' ) ) {
	define( 'NECCOSPEAK_TOOL_MAX_CALLS_PER_ROUND', 3 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_RESULT_MAX_BYTES' ) ) {
	define( 'NECCOSPEAK_TOOL_RESULT_MAX_BYTES', 2000 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_ARGS_MAX_BYTES' ) ) {
	define( 'NECCOSPEAK_TOOL_ARGS_MAX_BYTES', 1000 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_EXEC_BUDGET_MS' ) ) {
	define( 'NECCOSPEAK_TOOL_EXEC_BUDGET_MS', 4000 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_EXEC_CALL_MS' ) ) {
	define( 'NECCOSPEAK_TOOL_EXEC_CALL_MS', 1500 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_SEARCH_MAX_RESULTS' ) ) {
	define( 'NECCOSPEAK_TOOL_SEARCH_MAX_RESULTS', 5 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_SEARCH_EXCERPT_CHARS' ) ) {
	define( 'NECCOSPEAK_TOOL_SEARCH_EXCERPT_CHARS', 160 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_INSTRUCTIONS_MAX' ) ) {
	define( 'NECCOSPEAK_TOOL_INSTRUCTIONS_MAX', 800 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_HINT_BYTES_TEXT' ) ) {
	define( 'NECCOSPEAK_TOOL_HINT_BYTES_TEXT', 1200 );
}

/* -----------------------------------------------------------------------
 * §5.2 registry
 * -------------------------------------------------------------------- */

/**
 * Build the read-only tool registry (design phase6 §2.3 T1 / §5.2).
 *
 * Third-party tools may be added via the 'neccospeak_tools_registry' filter;
 * the result is always run through neccospeak_tools_normalize_registry()
 * afterwards, so a malformed addition (bad name, non-callable callback,
 * non-array schema) is dropped rather than fataling the tool loop.
 *
 * @return array<string,array{label:string,option:string,schema:array,callback:callable,budget_ms:int}>
 */
function neccospeak_tools_registry() {
	$registry = array(
		'site_search'      => array(
			'label'     => __( 'Search this site', 'neccospeak' ),
			'option'    => 'tools_site_search',
			'budget_ms' => NECCOSPEAK_TOOL_EXEC_CALL_MS,
			'callback'  => 'neccospeak_tool_site_search',
			'schema'    => array(
				'name'        => 'site_search',
				'description' => 'Search this website for published posts and pages matching a keyword. Returns titles, URLs and short excerpts. Use it before answering questions about this site\'s own content.',
				'parameters'  => array(
					'type'                 => 'object',
					'properties'           => array(
						'query' => array(
							'type'        => 'string',
							'description' => 'Search keywords, in the visitor\'s language.',
						),
						'limit' => array(
							'type'        => 'integer',
							'description' => 'How many results to return (1-5).',
							'minimum'     => 1,
							'maximum'     => 5,
						),
					),
					'required'             => array( 'query' ),
					'additionalProperties' => false,
				),
			),
		),
		'get_page_info'    => array(
			'label'     => __( 'Look up a page', 'neccospeak' ),
			'option'    => 'tools_get_page_info',
			'budget_ms' => NECCOSPEAK_TOOL_EXEC_CALL_MS,
			'callback'  => 'neccospeak_tool_get_page_info',
			'schema'    => array(
				'name'        => 'get_page_info',
				'description' => 'Look up details (title, URL, excerpt, last-modified date) for a single published page or post on this site, given its URL or numeric ID. Returns found:false when it does not exist or is not publicly viewable.',
				'parameters'  => array(
					'type'                 => 'object',
					'properties'           => array(
						'url'     => array(
							'type'        => 'string',
							'description' => 'The full URL of the page, if known.',
						),
						'post_id' => array(
							'type'        => 'integer',
							'description' => 'The numeric ID of the page, if known.',
						),
					),
					'required'             => array(),
					'additionalProperties' => false,
				),
			),
		),
		// 2026-08 撤去記録: 'current_datetime' registry entry removed here
		// (0.3.0-dev.60) — see this file's top-of-file note.
	);

	$registry = apply_filters( 'neccospeak_tools_registry', $registry );

	return neccospeak_tools_normalize_registry( $registry );
}

/**
 * Allow-list normalization pass applied to the registry after the
 * 'neccospeak_tools_registry' filter runs (design phase6 §2.3 T1).
 *
 * A row is dropped entirely (never partially kept) when its key is not a
 * valid tool name, its callback is not callable, or its schema is not an
 * array. This is the only place third-party registry rows are validated —
 * everything downstream (specs(), execute()) trusts the result.
 *
 * @param mixed $registry Raw registry, normally an array keyed by tool name.
 * @return array<string,array{label:string,option:string,schema:array,callback:callable,budget_ms:int}>
 */
function neccospeak_tools_normalize_registry( $registry ) {
	$out = array();
	if ( ! is_array( $registry ) ) {
		return $out;
	}
	foreach ( $registry as $name => $def ) {
		if ( ! is_string( $name ) || ! preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $name ) ) {
			continue;
		}
		if ( ! is_array( $def ) ) {
			continue;
		}
		if ( empty( $def['callback'] ) || ! is_callable( $def['callback'] ) ) {
			continue;
		}
		if ( ! isset( $def['schema'] ) || ! is_array( $def['schema'] ) ) {
			continue;
		}
		$out[ $name ] = array(
			'label'     => isset( $def['label'] ) ? (string) $def['label'] : $name,
			'option'    => isset( $def['option'] ) ? (string) $def['option'] : '',
			'schema'    => $def['schema'],
			'callback'  => $def['callback'],
			'budget_ms' => isset( $def['budget_ms'] ) ? (int) $def['budget_ms'] : NECCOSPEAK_TOOL_EXEC_CALL_MS,
		);
	}
	return $out;
}

/**
 * Post types site_search() queries against (design phase6 §5.2). Mirrors
 * G26's `rag_post_types` option (default ['post','page']) without a hard
 * dependency on includes/rag.php — this file only reads $opts, it never
 * calls a rag.php function.
 *
 * @param array $opts neccospeak_get_options().
 * @return string[] Non-empty list of registered post type slugs.
 */
function neccospeak_tools_search_post_types( $opts ) {
	$opts    = is_array( $opts ) ? $opts : array();
	$default = array( 'post', 'page' );

	$raw = isset( $opts['rag_post_types'] ) ? $opts['rag_post_types'] : null;
	if ( is_string( $raw ) ) {
		$raw = array_filter( array_map( 'trim', explode( ',', $raw ) ) );
	}
	if ( ! is_array( $raw ) || empty( $raw ) ) {
		return $default;
	}

	$types = array();
	foreach ( $raw as $t ) {
		$t = is_string( $t ) ? sanitize_key( $t ) : '';
		if ( '' === $t ) {
			continue;
		}
		if ( function_exists( 'post_type_exists' ) && ! post_type_exists( $t ) ) {
			continue;
		}
		$types[] = $t;
	}

	return $types ? array_values( array_unique( $types ) ) : $default;
}

/* -----------------------------------------------------------------------
 * §2.3 T2/T3/T5/T8 — enabled / specs / max_rounds / names
 * -------------------------------------------------------------------- */

/**
 * Whether tools are usable at all: master switch ON and at least one
 * individual tool toggle ON (design phase6 §2.3 T2).
 *
 * @param array $opts neccospeak_get_options().
 * @return bool
 */
function neccospeak_tools_enabled( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	if ( empty( $opts['tools_enabled'] ) ) {
		return false;
	}
	foreach ( neccospeak_tools_registry() as $def ) {
		$option = isset( $def['option'] ) ? $def['option'] : '';
		if ( '' !== $option && ! empty( $opts[ $option ] ) ) {
			return true;
		}
	}
	return false;
}

/**
 * OpenAI Chat Completions `tools` array for the currently-enabled tools
 * (design phase6 §2.3 T3). Declaration order follows registry order
 * (registry declaration, then any filter-added rows), which is stable
 * across calls for a fixed $opts.
 *
 * @param array $opts neccospeak_get_options().
 * @return array[] [{type:'function',function:{name,description,parameters}}]
 */
function neccospeak_tools_specs( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	if ( ! neccospeak_tools_enabled( $opts ) ) {
		return array();
	}

	$specs = array();
	foreach ( neccospeak_tools_registry() as $def ) {
		$option = isset( $def['option'] ) ? $def['option'] : '';
		if ( '' === $option || empty( $opts[ $option ] ) ) {
			continue;
		}
		$schema = isset( $def['schema'] ) ? $def['schema'] : array();
		if ( empty( $schema['name'] ) ) {
			continue;
		}
		$specs[] = array(
			'type'     => 'function',
			'function' => array(
				'name'        => (string) $schema['name'],
				'description' => isset( $schema['description'] ) ? (string) $schema['description'] : '',
				'parameters'  => isset( $schema['parameters'] ) && is_array( $schema['parameters'] )
					? $schema['parameters']
					: array(
						'type'                 => 'object',
						'properties'           => new stdClass(),
						'additionalProperties' => false,
					),
			),
		);
	}
	return $specs;
}

/**
 * Enabled tool names, in declared order (design phase6 §2.3 T8 — used by
 * Diagnostics and by the `tools_hint` section's "Available tools:" line).
 *
 * @param array $opts neccospeak_get_options().
 * @return string[]
 */
function neccospeak_tools_names( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	if ( ! neccospeak_tools_enabled( $opts ) ) {
		return array();
	}
	$names = array();
	foreach ( neccospeak_tools_registry() as $name => $def ) {
		$option = isset( $def['option'] ) ? $def['option'] : '';
		if ( '' !== $option && ! empty( $opts[ $option ] ) ) {
			$names[] = $name;
		}
	}
	return $names;
}

/**
 * Tool-round upper bound the /chat loop may use (design phase6 §2.3 T5 /
 * §0.3 D6): default 2, filterable, clamped to 0-3 so a misconfigured filter
 * can never make the loop unbounded (SM-3).
 *
 * @return int
 */
function neccospeak_tools_max_rounds() {
	$rounds = apply_filters( 'neccospeak_tools_max_rounds', NECCOSPEAK_TOOL_MAX_ROUNDS );
	$rounds = (int) $rounds;
	if ( $rounds < 0 ) {
		$rounds = 0;
	} elseif ( $rounds > 3 ) {
		$rounds = 3;
	}
	return $rounds;
}

/* -----------------------------------------------------------------------
 * §2.3 T4 — execute
 * -------------------------------------------------------------------- */

/**
 * Build a failure result (design phase6 §2.3 T4's error shape) with the
 * elapsed time measured from $started.
 *
 * @param string $error   Short machine-readable error code.
 * @param float  $started microtime(true) at the start of neccospeak_tools_execute().
 * @return array{ok:bool,content:string,error:string,ms:int,truncated:bool}
 */
function neccospeak_tools_execute_fail( $error, $started ) {
	return array(
		'ok'        => false,
		'content'   => wp_json_encode( array( 'error' => $error ) ),
		'error'     => $error,
		'ms'        => (int) round( ( microtime( true ) - $started ) * 1000 ),
		'truncated' => false,
	);
}

/**
 * Run one tool call (design phase6 §2.3 T4 / INV-13 / INV-14).
 *
 * Never throws: any error inside the tool's own callback is caught and
 * turned into an `ok:false` result so the /chat tool loop can always
 * continue (design phase6 §5.6 L2).
 *
 * @param string $name           Tool name (OpenAI's `function.name`).
 * @param string $arguments_json Raw JSON string of the model-supplied arguments.
 * @param array  $opts           neccospeak_get_options().
 * @param int    $budget_ms      Advisory time budget for this call; falls
 *                                back to the tool's own default when <= 0.
 * @return array{ok:bool,content:string,error:string,ms:int,truncated:bool}
 */
function neccospeak_tools_execute( $name, $arguments_json, $opts, $budget_ms = 1500 ) {
	$started = microtime( true );

	$opts = is_array( $opts ) ? $opts : array();
	$name = is_string( $name ) ? $name : '';
	if ( '' === $name || ! preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $name ) ) {
		return neccospeak_tools_execute_fail( 'unknown_tool', $started );
	}

	$registry = neccospeak_tools_registry();
	if ( ! isset( $registry[ $name ] ) ) {
		return neccospeak_tools_execute_fail( 'unknown_tool', $started );
	}
	$def    = $registry[ $name ];
	$option = isset( $def['option'] ) ? $def['option'] : '';

	if ( empty( $opts['tools_enabled'] ) || '' === $option || empty( $opts[ $option ] ) ) {
		return neccospeak_tools_execute_fail( 'tool_disabled', $started );
	}

	$callback = $def['callback'];
	if ( ! is_callable( $callback ) ) {
		return neccospeak_tools_execute_fail( 'tool_unavailable', $started );
	}

	$arguments_json = is_string( $arguments_json ) ? $arguments_json : '';
	if ( strlen( $arguments_json ) > NECCOSPEAK_TOOL_ARGS_MAX_BYTES ) {
		return neccospeak_tools_execute_fail( 'arguments_too_large', $started );
	}

	$args    = array();
	$trimmed = trim( $arguments_json );
	if ( '' !== $trimmed ) {
		$decoded = json_decode( $trimmed, true );
		if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
			return neccospeak_tools_execute_fail( 'bad_arguments', $started );
		}
		$args = $decoded;
	}

	try {
		$result = call_user_func( $callback, $args, $opts );
	} catch ( \Throwable $e ) {
		return neccospeak_tools_execute_fail( 'tool_error', $started );
	}

	if ( ! is_array( $result ) ) {
		$result = array();
	}

	$encoded = wp_json_encode( $result );
	if ( ! is_string( $encoded ) ) {
		$encoded = '{}';
	}
	$truncated = false;
	if ( strlen( $encoded ) > NECCOSPEAK_TOOL_RESULT_MAX_BYTES ) {
		$encoded   = neccospeak_strcut( $encoded, NECCOSPEAK_TOOL_RESULT_MAX_BYTES );
		$truncated = true;
	}

	return array(
		'ok'        => true,
		'content'   => $encoded,
		'error'     => '',
		'ms'        => (int) round( ( microtime( true ) - $started ) * 1000 ),
		'truncated' => $truncated,
	);
}

/* -----------------------------------------------------------------------
 * §5.2 tool callbacks (read-only — INV-13)
 * -------------------------------------------------------------------- */

/**
 * `site_search` tool callback (design phase6 §5.2).
 *
 * @param array $args {query:string, limit?:int}
 * @param array $opts neccospeak_get_options().
 * @return array{results:array,count:int}
 */
function neccospeak_tool_site_search( $args, $opts ) {
	$args = is_array( $args ) ? $args : array();
	$opts = is_array( $opts ) ? $opts : array();

	$query = ( isset( $args['query'] ) && is_scalar( $args['query'] ) ) ? (string) $args['query'] : '';
	$query = trim( $query );
	$query = neccospeak_strcut( $query, 240 );
	if ( '' === $query ) {
		return array(
			'results' => array(),
			'count'   => 0,
		);
	}

	$limit = NECCOSPEAK_TOOL_SEARCH_MAX_RESULTS;
	if ( isset( $args['limit'] ) && is_numeric( $args['limit'] ) ) {
		$limit = (int) $args['limit'];
	}
	if ( $limit < 1 ) {
		$limit = 1;
	} elseif ( $limit > NECCOSPEAK_TOOL_SEARCH_MAX_RESULTS ) {
		$limit = NECCOSPEAK_TOOL_SEARCH_MAX_RESULTS;
	}

	$q = new WP_Query(
		array(
			's'                      => $query,
			'post_type'              => neccospeak_tools_search_post_types( $opts ),
			'post_status'            => 'publish',
			'has_password'           => false,
			'posts_per_page'         => $limit,
			'no_found_rows'          => true,
			'ignore_sticky_posts'    => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'fields'                 => 'ids',
			'orderby'                => 'relevance',
		)
	);

	$results = array();
	$ids     = ( isset( $q->posts ) && is_array( $q->posts ) ) ? $q->posts : array();
	foreach ( $ids as $id ) {
		$id = (int) $id;
		if ( $id <= 0 ) {
			continue;
		}
		// Design phase6 §5.2: posts flagged _neccospeak_rag_exclude are left
		// out of results with a single meta read (not a meta_query — the
		// exclusion flag is G26's, this file only respects it).
		if ( '1' === (string) get_post_meta( $id, '_neccospeak_rag_exclude', true ) ) {
			continue;
		}
		$post = get_post( $id );
		if ( ! $post ) {
			continue;
		}

		$excerpt_src = get_the_excerpt( $id );
		if ( '' === trim( (string) $excerpt_src ) && isset( $post->post_content ) ) {
			$excerpt_src = $post->post_content;
		}
		$excerpt_src = strip_shortcodes( (string) $excerpt_src );
		$excerpt     = wp_strip_all_tags( $excerpt_src, true );
		$excerpt     = function_exists( 'mb_substr' )
			? mb_substr( $excerpt, 0, NECCOSPEAK_TOOL_SEARCH_EXCERPT_CHARS )
			: substr( $excerpt, 0, NECCOSPEAK_TOOL_SEARCH_EXCERPT_CHARS );

		$results[] = array(
			'title'   => wp_strip_all_tags( get_the_title( $id ) ),
			'url'     => (string) get_permalink( $id ),
			'excerpt' => $excerpt,
		);
	}

	return array(
		'results' => $results,
		'count'   => count( $results ),
	);
}

/**
 * Local (this-file-only) fallback for validating a post is safe to expose
 * to `get_page_info` when includes/page-context.php (impl-context) has not
 * loaded yet — cross-agent contract rule (design phase6 §1.3 rule 1).
 * Mirrors INV-5's substance (published, not password-protected, a publicly
 * viewable post type) without depending on impl-context's richer checks.
 *
 * @param int $post_id Candidate post ID.
 * @return int The same ID if it passes, 0 otherwise.
 */
function neccospeak_tools_local_resolve_post( $post_id ) {
	$post_id = (int) $post_id;
	if ( $post_id <= 0 ) {
		return 0;
	}
	$post = get_post( $post_id );
	if ( ! $post ) {
		return 0;
	}
	if ( ! isset( $post->post_status ) || 'publish' !== $post->post_status ) {
		return 0;
	}
	if ( ! empty( $post->post_password ) ) {
		return 0;
	}
	$type = isset( $post->post_type ) ? $post->post_type : '';
	if ( '' === $type || ! is_post_type_viewable( $type ) ) {
		return 0;
	}
	return $post_id;
}

/**
 * `get_page_info` tool callback (design phase6 §5.2). Never reveals *why*
 * a page was not found (draft vs. missing vs. wrong type are all
 * indistinguishable from the outside) — that is deliberate (INV-5): the
 * model must not be able to infer the existence of unpublished content.
 *
 * @param array $args {url?:string, post_id?:int}
 * @param array $opts neccospeak_get_options().
 * @return array{found:bool,title?:string,url?:string,excerpt?:string,modified?:string}
 */
function neccospeak_tool_get_page_info( $args, $opts ) {
	$args = is_array( $args ) ? $args : array();

	$post_id = 0;
	if ( isset( $args['post_id'] ) && is_scalar( $args['post_id'] ) ) {
		$post_id = (int) $args['post_id'];
	} elseif ( isset( $args['url'] ) && is_string( $args['url'] ) && '' !== trim( $args['url'] ) ) {
		$post_id = (int) url_to_postid( $args['url'] );
	}

	if ( $post_id > 0 ) {
		$post_id = function_exists( 'neccospeak_page_context_resolve' )
			? neccospeak_page_context_resolve( $post_id )
			: neccospeak_tools_local_resolve_post( $post_id );
	}

	if ( $post_id <= 0 ) {
		return array( 'found' => false );
	}

	$post = get_post( $post_id );
	if ( ! $post ) {
		return array( 'found' => false );
	}

	$excerpt_src = get_the_excerpt( $post_id );
	if ( '' === trim( (string) $excerpt_src ) && isset( $post->post_content ) ) {
		$excerpt_src = $post->post_content;
	}
	$excerpt_src = strip_shortcodes( (string) $excerpt_src );
	$excerpt     = wp_strip_all_tags( $excerpt_src, true );
	$excerpt     = function_exists( 'mb_substr' )
		? mb_substr( $excerpt, 0, NECCOSPEAK_TOOL_SEARCH_EXCERPT_CHARS )
		: substr( $excerpt, 0, NECCOSPEAK_TOOL_SEARCH_EXCERPT_CHARS );

	// post_modified is already stored in the site's local time (not GMT), so
	// slicing the first 10 characters avoids double-applying a timezone
	// offset the way strtotime()+gmdate() would.
	$modified = isset( $post->post_modified ) ? substr( (string) $post->post_modified, 0, 10 ) : '';

	return array(
		'found'    => true,
		'title'    => wp_strip_all_tags( get_the_title( $post_id ) ),
		'url'      => (string) get_permalink( $post_id ),
		'excerpt'  => $excerpt,
		'modified' => $modified,
	);
}

// 2026-08 撤去記録: `neccospeak_tool_current_datetime()` callback removed here
// (0.3.0-dev.60) — see this file's top-of-file note.

/* -----------------------------------------------------------------------
 * §5.7 tools_hint section
 * -------------------------------------------------------------------- */

/**
 * `tools_hint` prompt section (design phase6 §5.7 / §2.9 priority 55).
 * `null` unless: 'text' mode, tools are enabled, and `tools_instructions`
 * is non-empty. `tools_instructions` is admin-authored (manage_options),
 * so — unlike page_context/nav/rag_context — it does not need INV-14(b)'s
 * untrusted-text hardening.
 *
 * @param array  $opts neccospeak_get_options().
 * @param string $mode 'text' | 'realtime'.
 * @return array{key:string,label:string,content:string,priority:int,required:bool}|null
 */
function neccospeak_tools_section( $opts, $mode ) {
	$opts = is_array( $opts ) ? $opts : array();
	$mode = is_string( $mode ) ? $mode : 'text';

	if ( 'text' !== $mode ) {
		return null;
	}
	if ( ! neccospeak_tools_enabled( $opts ) ) {
		return null;
	}

	$instructions = isset( $opts['tools_instructions'] ) ? trim( (string) $opts['tools_instructions'] ) : '';
	if ( '' === $instructions ) {
		return null;
	}
	$instructions = neccospeak_strcut( $instructions, NECCOSPEAK_TOOL_INSTRUCTIONS_MAX );

	$label = __( 'Tools', 'neccospeak' );
	$body  = "## {$label}\n{$instructions}";

	$names = neccospeak_tools_names( $opts );
	if ( ! empty( $names ) ) {
		$body .= "\nAvailable tools: " . implode( ', ', $names );
	}

	$body = neccospeak_strcut( $body, NECCOSPEAK_TOOL_HINT_BYTES_TEXT );
	if ( '' === trim( $body ) ) {
		return null;
	}

	return array(
		'key'      => 'tools_hint',
		'label'    => $label,
		'content'  => $body,
		'priority' => 55,
		'required' => false,
	);
}

/**
 * Fit-report for the `tools_hint` section (design phase6 §2.3 T6 / INV-6),
 * same {total,used,bytes} shape as every other Phase 6 *_fit() function.
 *
 * @param array  $opts neccospeak_get_options().
 * @param string $mode 'text' | 'realtime'.
 * @return array{total:int,used:int,bytes:int}
 */
function neccospeak_tools_instruction_fit( $opts, $mode ) {
	$opts = is_array( $opts ) ? $opts : array();

	$instructions = isset( $opts['tools_instructions'] ) ? trim( (string) $opts['tools_instructions'] ) : '';
	$result       = array(
		'total' => strlen( $instructions ),
		'used'  => 0,
		'bytes' => 0,
	);

	$section = neccospeak_tools_section( $opts, $mode );
	if ( null === $section ) {
		return $result;
	}

	$bytes            = strlen( $section['content'] );
	$result['bytes']  = $bytes;
	$result['used']   = min( $bytes, $result['total'] );

	return $result;
}

/**
 * Escape "content that might look like a prompt-section heading" the same
 * way includes/page-context.php's neccospeak_page_context_untrusted() and
 * includes/rag.php's neccospeak_rag_untrusted() do (design phase6 §2.2
 * P18). Duplicated rather than shared because no common file exists to
 * hold it and three files defining the same function name would fatal on
 * load. Not currently on any call path in this file (T4's
 * wp_json_encode() already makes a leading "#" structurally impossible
 * once a tool result becomes a `tool`-role message — see this file's
 * top-of-file note) but kept as part of the file's public contract so a
 * future tool callback that injects raw (non-JSON) text into a prompt
 * section has it available.
 *
 * @param string $text Untrusted text.
 * @return string
 */
function neccospeak_tools_untrusted( $text ) {
	$text = is_string( $text ) ? $text : '';
	$text = str_replace( "\r", '', $text );
	$text = preg_replace( '/^#/m', '\\#', $text );
	return trim( $text );
}

/* -----------------------------------------------------------------------
 * §1.3 rule 4 — filter registration (file-scope, always registers; the
 * section itself only ever appears when neccospeak_tools_section() decides
 * to return non-null, which is options-driven, not request-store-driven —
 * unlike page-context/rag, tools_hint needs no per-request state).
 * -------------------------------------------------------------------- */

add_filter( 'neccospeak_prompt_sections', 'neccospeak_tools_sections_filter', 14, 3 );

/**
 * 'neccospeak_prompt_sections' filter callback (design phase6 §2.3, "登録するフック").
 *
 * @param array  $sections Sections accumulated so far.
 * @param array  $opts     neccospeak_get_options().
 * @param string $mode     'text' | 'realtime'.
 * @return array
 */
function neccospeak_tools_sections_filter( $sections, $opts, $mode ) {
	$sections = is_array( $sections ) ? $sections : array();
	$section  = neccospeak_tools_section( is_array( $opts ) ? $opts : array(), $mode );
	if ( null !== $section ) {
		$sections[] = $section;
	}
	return $sections;
}
