<?php
/**
 * Plugin Name: NeccoSpeak – AI Chatbot & Voice Chat
 * Description: Lightweight, self-contained AI chat & voice widget powered by your own OpenAI API key — no external SaaS server required.
 * Version: 0.3.0
 * Requires at least: 6.4
 * Requires PHP: 8.0
 * Author: neccos
 * Author URI: https://neccos.jp
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: neccospeak
 * Domain Path: /languages
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/**
 * Constant definitions.
 */
define( 'NECCOSPEAK_VERSION', '0.3.0' );
define( 'NECCOSPEAK_DIR', plugin_dir_path( __FILE__ ) );
define( 'NECCOSPEAK_URL', plugin_dir_url( __FILE__ ) );

/**
 * Load the bundled translations (e.g. languages/neccospeak-ja.mo).
 *
 * The plugin ships its own translations, so we register the plugin's own
 * /languages directory. Hooked on 'init' (not earlier) to avoid the WordPress
 * 6.7 "translation loading triggered too early" notice. When a WordPress.org
 * language pack exists it takes precedence over the bundled file automatically.
 *
 * @return void
 */
function neccospeak_load_textdomain() {
	load_plugin_textdomain( 'neccospeak', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- deliberate: bundled translations must also load on WP 6.4-6.6; automatic loading of the in-plugin /languages dir requires WP 6.7+.
}
add_action( 'init', 'neccospeak_load_textdomain' );

/**
 * Load dependent modules (prompt composer, settings, REST).
 *
 * prompt.php has no load-time dependency on settings.php (it only reads
 * options at call time, not while the file is being required), so it is
 * required first; rest.php calls neccospeak_compose_system_prompt() while
 * handling a request and must be able to see it.
 */
require_once NECCOSPEAK_DIR . 'includes/prompt.php';
require_once NECCOSPEAK_DIR . 'includes/settings.php';
if ( file_exists( NECCOSPEAK_DIR . 'includes/agent-ui.php' ) ) {
	require_once NECCOSPEAK_DIR . 'includes/agent-ui.php';
}

/**
 * Phase 6 (design phase6 §14.1): includes/http.php must load BEFORE the files
 * that call it (page-context.php), and page-context/tools/rag must load
 * BEFORE rest.php so that rest.php's function_exists() guards see them on the
 * very first request after an upgrade. Every one is file_exists()-guarded,
 * matching the includes/db.php precedent below — each is a separate agent's
 * (impl-http / impl-context / impl-tools / impl-rag) own deliverable that may
 * not have landed on disk yet during parallel Phase 6 development.
 */
if ( file_exists( NECCOSPEAK_DIR . 'includes/http.php' ) ) {
	require_once NECCOSPEAK_DIR . 'includes/http.php';
}
if ( file_exists( NECCOSPEAK_DIR . 'includes/page-context.php' ) ) {
	require_once NECCOSPEAK_DIR . 'includes/page-context.php';
}
if ( file_exists( NECCOSPEAK_DIR . 'includes/tools.php' ) ) {
	require_once NECCOSPEAK_DIR . 'includes/tools.php';
}
if ( file_exists( NECCOSPEAK_DIR . 'includes/rag.php' ) ) {
	require_once NECCOSPEAK_DIR . 'includes/rag.php';
}

require_once NECCOSPEAK_DIR . 'includes/rest.php';
/**
 * Front-end asset wiring (design rev.2 §2.2 / §3.4): the window.neccoSpeak
 * config builder + the optional feature-module enqueue list. Owner:
 * impl-split — explicitly excluded from includes/settings.php's option
 * schema and includes/rest.php's REST surface.
 */
require_once NECCOSPEAK_DIR . 'includes/frontend-config.php';
/**
 * Phase 5 (design phase5 §2/§3): custom-table schema, activation/upgrade/
 * uninstall lifecycle, pruning, price/budget/breaker logic. Owner: impl-db.
 * file_exists() guarded like the includes/admin-*.php requires below (design
 * phase5 §1.1's Phase 5a/5b split means this file is impl-db's own
 * deliverable, landed before Phase 5b, but the guard costs nothing and keeps
 * this require_once from fataling site-wide on a partial checkout).
 */
if ( file_exists( NECCOSPEAK_DIR . 'includes/db.php' ) ) {
	require_once NECCOSPEAK_DIR . 'includes/db.php';
}

/**
 * 0.3.0 (docs/transplant-plan.md §2 / §6.3, "static copy transplant" of
 * the production widget): the widget-frame delivery route + the frame
 * document/config/i18n builders (neccospeak_frame_maybe_render(),
 * neccospeak_frame_html(), neccospeak_frame_client_config(),
 * neccospeak_frame_launcher_config(), neccospeak_frame_i18n(), …). Required
 * after includes/settings.php / includes/frontend-config.php / includes/db.php
 * above so every optional cross-file helper it calls via function_exists()
 * guards (design doc's own "never a hard dependency on another file having
 * landed yet" discipline, matching includes/frontend-config.php's own) is
 * already available. Owner: band A (transplant-plan.md §6.1). file_exists()
 * guarded like every other conditional require in this chain, so a partial
 * checkout never fatals site-wide.
 */
if ( file_exists( NECCOSPEAK_DIR . 'includes/widget-frame.php' ) ) {
	require_once NECCOSPEAK_DIR . 'includes/widget-frame.php';
}

/**
 * Ensure the neccospeak_options row exists from first activation (design
 * rev.2 §0.2.1 layer L4 — defense in depth against the double-sanitize bug:
 * when the row already exists, update_option() never falls through to
 * add_option(), so sanitize_option() only ever runs once per save).
 *
 * neccospeak_bootstrap_option() is a design rev.2 §0.2.1 L4 / Appendix A
 * contract owned by includes/settings.php. Guarded with function_exists() so
 * plugin activation never fatals while settings.php has not yet shipped that
 * function; once it lands this becomes an unconditional registration.
 */
if ( function_exists( 'neccospeak_bootstrap_option' ) ) {
	register_activation_hook( __FILE__, 'neccospeak_bootstrap_option' );
}

/**
 * Phase 5 (design phase5 §3.1 / §0.4): wire the custom-table lifecycle to
 * WordPress's activation/deactivation hooks. neccospeak_db_activate()'s
 * signature ($network_wide = false) matches exactly what core passes to an
 * "activate_{$plugin}" callback, so no wrapper is needed.
 * neccospeak_db_deactivate() only clears the cron schedule -- the tables
 * themselves are intentionally left in place (uninstall.php is the only
 * place that drops them, per WP.org guidelines). Both are guarded with
 * function_exists() so activation/deactivation never fatals while
 * includes/db.php has not shipped these functions yet.
 */
if ( function_exists( 'neccospeak_db_activate' ) ) {
	register_activation_hook( __FILE__, 'neccospeak_db_activate' );
}
if ( function_exists( 'neccospeak_db_deactivate' ) ) {
	register_deactivation_hook( __FILE__, 'neccospeak_db_deactivate' );
}

/**
 * Determine whether the widget should be displayed for this request.
 *
 * Phase 6 (design phase6 §0.3 D2 / §4.6): thin wrapper around
 * neccospeak_should_display_base() (the unchanged, verbatim 0.2.0-dev.5
 * body) so includes/page-context.php (impl-context, G15) can apply a
 * per-page show/hide override without this file having any dependency on
 * that agent's option schema — a callback that forces `true` must re-check
 * the hard preconditions itself (see the 'neccospeak_should_display' filter's
 * own docblock below), so this filter can only ever narrow visibility that
 * neccospeak_should_display_base() would already allow, or widen it back to
 * exactly that same hard-precondition boundary (never further).
 *
 * @return bool
 */
function neccospeak_should_display() {
	$display = neccospeak_should_display_base();
	/**
	 * Filters whether the widget is displayed for this request.
	 *
	 * Phase 6 (G15): includes/page-context.php uses this to apply a per-page
	 * show/hide override. A callback that forces `true` MUST re-check the
	 * hard preconditions itself (enabled + working API key + a front-end
	 * request) — this filter runs after them, so blindly returning true would
	 * show a widget with no working API key.
	 *
	 * @param bool $display Result of the built-in checks.
	 */
	return (bool) apply_filters( 'neccospeak_should_display', $display );
}

/**
 * The built-in display checks (unchanged from 0.2.0-dev.5, verbatim — design
 * phase6 §4.6 requires this to stay a byte-for-byte behavioral no-op so
 * INV-1 holds for every site that never touches G15's new options).
 *
 * Returns true when the plugin is enabled, openai_api_key is non-empty, and the
 * URL rules (display_rule / included / excluded) match. When unconfigured
 * (disabled or no key) it returns false and nothing is loaded at all.
 *
 * @return bool True if the widget should be displayed.
 */
function neccospeak_should_display_base() {
	// Do not display if the plugin is disabled.
	if ( ! neccospeak_option( 'enabled', true ) ) {
		return false;
	}

	// If the API key (stored encrypted) is unset or cannot be decrypted, load
	// nothing at all — meaning zero external communication. Verifying that the
	// decrypted key is non-empty avoids the mismatch of "the widget is shown but
	// /chat returns 503" caused by a broken key (only show the widget when the
	// key actually works).
	$encrypted_key = neccospeak_option( 'openai_api_key', '' );
	if ( ! is_string( $encrypted_key ) || '' === trim( $encrypted_key ) ) {
		return false;
	}
	if ( '' === trim( (string) neccospeak_decrypt( $encrypted_key ) ) ) {
		return false;
	}

	// Do not display outside the front end, e.g. admin, feeds, REST, AJAX, or cron.
	if ( is_admin() || is_feed() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || wp_doing_ajax() || wp_doing_cron() ) {
		return false;
	}

	$rule = neccospeak_option( 'display_rule', 'all' );
	if ( ! in_array( $rule, array( 'all', 'include', 'exclude' ), true ) ) {
		$rule = 'all';
	}

	if ( 'all' === $rule ) {
		return true;
	}

	$current = neccospeak_current_url();

	if ( 'include' === $rule ) {
		$patterns = neccospeak_url_list( neccospeak_option( 'included_urls', '' ) );
		// For the include rule, display only when at least one pattern matches.
		return neccospeak_url_matches_any( $current, $patterns );
	}

	if ( 'exclude' === $rule ) {
		$patterns = neccospeak_url_list( neccospeak_option( 'excluded_urls', '' ) );
		// For the exclude rule, display only when no pattern matches.
		return ! neccospeak_url_matches_any( $current, $patterns );
	}

	return true;
}

/**
 * Get the current request URL (scheme + host + path + query).
 *
 * @return string The current URL, or an empty string if it cannot be determined.
 */
function neccospeak_current_url() {
	$host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
	$uri  = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';

	if ( '' === $host ) {
		return '';
	}

	$scheme = is_ssl() ? 'https://' : 'http://';

	return $scheme . $host . $uri;
}

/**
 * Convert a newline-separated URL setting string into a trimmed array.
 *
 * @param string $raw The newline-separated string.
 * @return string[] The array of patterns.
 */
function neccospeak_url_list( $raw ) {
	if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
		return array();
	}

	$lines = preg_split( '/[\r\n]+/', $raw );
	$out   = array();

	foreach ( (array) $lines as $line ) {
		$line = trim( $line );
		if ( '' !== $line ) {
			$out[] = $line;
		}
	}

	return $out;
}

/**
 * Determine whether the current URL matches any pattern in the array
 * (substring match / wildcard).
 *
 * A "*" in a pattern is a simple wildcard that matches any string.
 * When a pattern contains no "*", a substring match (stripos) is used.
 *
 * @param string   $url      The URL to test.
 * @param string[] $patterns The array of patterns.
 * @return bool True if any pattern matches.
 */
function neccospeak_url_matches_any( $url, $patterns ) {
	if ( '' === $url || empty( $patterns ) ) {
		return false;
	}

	foreach ( $patterns as $pattern ) {
		if ( false !== strpos( $pattern, '*' ) ) {
			$regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . '$#i';
			if ( preg_match( $regex, $url ) ) {
				return true;
			}
		} elseif ( false !== stripos( $url, $pattern ) ) {
			return true;
		}
	}

	return false;
}

/**
 * Register and enqueue front-end assets. Only when should_display is true.
 *
 * 0.3.0 (docs/transplant-plan.md §2.5, Phase A): the front end now
 * mounts the transplanted widget-frame iframe via a single loader script
 * instead of the pre-0.3 `assets/widget.css` + `assets/js/widget-core.js` +
 * optional-module stack. Per §2.5:
 *   - The old `neccospeak_widget_modules()` enqueue loop is deliberately NOT
 *     called from here (the function itself is untouched and still exists —
 *     INV-T6). Later in the same 0.3.0 cycle, its last consumer, includes/admin-preview.php's
 *     admin-only live preview, was deleted together with the legacy
 *     `assets/widget.css` + `assets/js/widget-*.js` stack it loaded, so the
 *     registry now has no callers at all in shipped code. The one module
 *     asset still on disk, `assets/js/widget-cf7.js`, is enqueued directly by
 *     includes/frontend-config.php's neccospeak_enqueue_cf7_receiver()
 *     (wp_enqueue_scripts priority 11), never through this registry.
 *   - `wp_localize_script( 'neccospeak', 'neccoSpeak', … )` is likewise not
 *     emitted on the front end in Phase A. `neccospeak_widget_config()`
 *     itself is untouched (INV-T6) and is still used elsewhere as the
 *     material for the frame's own config builders
 *     (includes/widget-frame.php's neccospeak_frame_client_config() reuses
 *     several of its sibling helpers, e.g. neccospeak_widget_home_chips()).
 *
 * @return void
 */
function neccospeak_enqueue() {
	if ( ! neccospeak_should_display() ) {
		return;
	}
	$opts = neccospeak_get_options();

	wp_enqueue_script(
		'neccospeak-frame-loader',
		NECCOSPEAK_URL . 'assets/widget-frame/js/widget-loader.js',
		array(),
		NECCOSPEAK_VERSION,
		true
	);

	// widget-loader.js reads window.NECCOSPEAK_LAUNCHER (not data-* attributes
	// — design doc §2.5's own note: wp_localize_script() cannot set data-*
	// attributes on the <script> tag it emits, so the ported loader was
	// changed, §2.6 L1, to read this global instead).
	//
	// function_exists() guarded (matching every other cross-file call in
	// this bootstrap file, e.g. neccospeak_bootstrap_option() above) so a
	// partial checkout that has not yet landed includes/widget-frame.php
	// never fatals here.
	if ( function_exists( 'neccospeak_frame_launcher_config' ) ) {
		wp_localize_script(
			'neccospeak-frame-loader',
			'NECCOSPEAK_LAUNCHER',
			neccospeak_frame_launcher_config( $opts )
		);
	}
}
add_action( 'wp_enqueue_scripts', 'neccospeak_enqueue' );

/**
 * Register and enqueue admin assets. Only on the plugin settings page.
 *
 * The hook_suffix for add_options_page( …, 'neccospeak', … ) is
 * 'settings_page_neccospeak'. Nothing is loaded on any other admin page
 * (completely separate from the front-end neccospeak_enqueue).
 *
 * assets/admin.js wires the "Fetch available models" button to the
 * administrator-only REST route neccospeak/v1/models. The API key is never
 * passed to the browser; fetching happens only on the server side (localize
 * passes only restUrl / nonce / display strings).
 *
 * @param string $hook_suffix The hook_suffix of the current admin page.
 * @return void
 */
function neccospeak_admin_enqueue( $hook_suffix ) {
	// Do nothing on pages other than the plugin settings page.
	//
	// neccospeak_is_admin_page() is a design rev.2 §14 (S0/Appendix A)
	// contract owned by includes/settings.php — it decouples this check from
	// the exact add_options_page()/add_menu_page() hook_suffix string (§3.1
	// switches to add_menu_page(), which changes that string).
	// function_exists() keeps this enqueue a safe no-op (instead of a fatal)
	// until settings.php ships that helper; the string fallback reproduces
	// today's exact behavior in the meantime.
	$is_admin_page = function_exists( 'neccospeak_is_admin_page' )
		? neccospeak_is_admin_page( $hook_suffix )
		: ( 'settings_page_' . NECCOSPEAK_SETTINGS_SLUG === $hook_suffix );
	if ( ! $is_admin_page ) {
		return;
	}

	wp_enqueue_script(
		'neccospeak-admin',
		NECCOSPEAK_URL . 'assets/admin.js',
		array(),
		NECCOSPEAK_VERSION,
		true
	);

	// admin.css (design rev.2 §14.1 S0 / Appendix A). file_exists() guards
	// against enqueuing an asset that does not exist yet (S0 stub pending) —
	// design rev.2 §14.4 requires every enqueued asset to actually be present.
	if ( file_exists( NECCOSPEAK_DIR . 'assets/admin.css' ) ) {
		wp_enqueue_style(
			'neccospeak-admin',
			NECCOSPEAK_URL . 'assets/admin.css',
			array(),
			NECCOSPEAK_VERSION
		);
	}

	// Settings passed to the admin JS. The API key is never passed (only restUrl / nonce / display strings).
	//
	// homeUrl (design rev.2 §10.1 C4 / Appendix A): consumed by admin.js's
	// Referrer-Policy diagnostic; safe to add unconditionally (admin.js falls
	// back to window.location.origin + '/' when it is absent).
	//
	// i18n: neccospeak_admin_i18n() is a design rev.2 §14 (S0/Appendix A)
	// contract owned by includes/settings.php (it grows with the G4/G18 admin
	// strings). function_exists() keeps this enqueue safe until settings.php
	// ships that helper; the literal fallback array below reproduces today's
	// exact keys/strings in the meantime.
	$admin_i18n = function_exists( 'neccospeak_admin_i18n' )
		? neccospeak_admin_i18n()
		: array(
			'fetching'       => esc_html__( 'Fetching…', 'neccospeak' ),
			'fetched'        => esc_html__( 'Model list fetched.', 'neccospeak' ),
			'failed'         => esc_html__( 'Failed to fetch the model list.', 'neccospeak' ),
			'noKey'          => esc_html__( 'No API key is configured.', 'neccospeak' ),
			'setKeyFirst'    => esc_html__( 'Please save your API key first.', 'neccospeak' ),
			/* translators: 1: number of chat models fetched, 2: number of realtime models fetched. */
			'fetchedSummary' => esc_html__( '(chat %1$s / realtime %2$s)', 'neccospeak' ),
		);

	wp_localize_script(
		'neccospeak-admin',
		'neccoSpeakAdmin',
		array(
			'restUrl' => esc_url_raw( rest_url( 'neccospeak/v1/' ) ),
			'nonce'   => wp_create_nonce( 'wp_rest' ),
			'homeUrl' => esc_url_raw( home_url( '/' ) ),
			// Phase 6 (design phase6 §6.3/§11.4): the Content tab's "Rebuild
			// index" button POSTs to admin-ajax.php (action=neccospeak_rag_sync,
			// impl-rag) rather than the REST API, so assets/admin.js needs its
			// own URL for that — restUrl above only ever points at /wp-json/.
			'ajaxUrl' => esc_url_raw( admin_url( 'admin-ajax.php' ) ),
			'i18n'    => $admin_i18n,
		)
	);
}
add_action( 'admin_enqueue_scripts', 'neccospeak_admin_enqueue' );

/**
 * Output the widget's mount root in wp_footer. Only when should_display is true.
 *
 * @return void
 */
function neccospeak_footer_mount() {
	if ( ! neccospeak_should_display() ) {
		return;
	}

	echo '<div id="neccospeak-root"></div>';
}
add_action( 'wp_footer', 'neccospeak_footer_mount' );
