/**
 * Search Query Functions
 *
 * Programmatic API for searching content using FTS5.
 */

import type { Kysely } from "kysely";
import { sql } from "kysely";

import type { Database } from "../database/types.js";
import { validateIdentifier } from "../database/validate.js";
import { getDb } from "../loader.js";
import { FTSManager } from "./fts-manager.js";
import type {
	SearchOptions,
	CollectionSearchOptions,
	SearchResult,
	SearchResponse,
	SuggestOptions,
	Suggestion,
	SearchStats,
} from "./types.js";

/** Pattern to split on whitespace for query term extraction */
const WHITESPACE_SPLIT_PATTERN = /\s+/;
const FTS_OPERATORS_PATTERN = /\b(AND|OR|NOT|NEAR)\b/i;
const DOUBLE_QUOTE_PATTERN = /"/g;

/**
 * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5
 * error fingerprints rather than a broad "fts5" / "syntax error" filter
 * (which would also swallow internal table-corruption errors). The two
 * fingerprints we care about are:
 *
 *  - "fts5: syntax error near …" — unbalanced quotes, stray operators,
 *    other malformed user input
 *  - "unknown special query: …" — bare special tokens like `^*` that
 *    parse but don't resolve to a real FTS5 directive
 */
function isFts5SyntaxError(error: unknown): boolean {
	if (!(error instanceof Error)) return false;
	const message = error.message.toLowerCase();
	return message.includes("fts5: syntax error") || message.includes("unknown special query");
}

/**
 * Search across multiple collections
 *
 * Public API that auto-injects the database.
 *
 * @param query - Search query (FTS5 syntax supported)
 * @param options - Search options
 * @returns Search results with pagination
 *
 * @example
 * ```typescript
 * import { search } from "emdash";
 *
 * const results = await search("hello world", {
 *   collections: ["posts", "pages"],
 *   limit: 20
 * });
 * ```
 */
export async function search(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
	const db = await getDb();
	return searchWithDb(db, query, options);
}

/**
 * Search across multiple collections (with explicit db)
 *
 * @internal Use `search()` in templates. This variant is for admin routes
 * that already have a database handle.
 *
 * @param db - Kysely database instance
 * @param query - Search query (FTS5 syntax supported)
 * @param options - Search options
 * @returns Search results with pagination
 */
export async function searchWithDb(
	db: Kysely<Database>,
	query: string,
	options: SearchOptions = {},
): Promise<SearchResponse> {
	const ftsManager = new FTSManager(db);
	const limit = options.limit ?? 20;
	const status = options.status ?? "published";

	// Get searchable collections
	let collections = options.collections;
	if (!collections || collections.length === 0) {
		collections = await getSearchableCollections(db);
	}

	if (collections.length === 0) {
		return { items: [] };
	}

	// Search each collection and merge results
	const allResults: SearchResult[] = [];

	for (const collection of collections) {
		const config = await ftsManager.getSearchConfig(collection);
		if (!config?.enabled) {
			continue;
		}

		const collectionResults = await searchSingleCollection(
			db,
			collection,
			query,
			{
				status,
				locale: options.locale,
				limit: limit * 2, // Get extra for merging
			},
			config.weights,
		);

		allResults.push(...collectionResults);
	}

	// Sort by score descending
	allResults.sort((a, b) => b.score - a.score);

	// Apply limit
	const items = allResults.slice(0, limit);

	return { items };
}

/**
 * Search within a single collection
 *
 * @param db - Kysely database instance
 * @param collection - Collection slug
 * @param query - Search query (FTS5 syntax supported)
 * @param options - Search options
 * @returns Search results with pagination
 *
 * @example
 * ```typescript
 * const results = await searchCollection(db, "posts", "hello world", {
 *   limit: 10
 * });
 * ```
 */
export async function searchCollection(
	db: Kysely<Database>,
	collection: string,
	query: string,
	options: CollectionSearchOptions = {},
): Promise<SearchResponse> {
	const ftsManager = new FTSManager(db);
	const config = await ftsManager.getSearchConfig(collection);

	if (!config?.enabled) {
		return { items: [] };
	}

	const items = await searchSingleCollection(db, collection, query, options, config.weights);

	return { items };
}

/**
 * Internal function to search a single collection
 */
async function searchSingleCollection(
	db: Kysely<Database>,
	collection: string,
	query: string,
	options: CollectionSearchOptions,
	weights?: Record<string, number>,
): Promise<SearchResult[]> {
	// Validate before any raw SQL interpolation
	validateIdentifier(collection, "collection slug");

	const ftsManager = new FTSManager(db);
	const ftsTable = ftsManager.getFtsTableName(collection);
	const contentTable = ftsManager.getContentTableName(collection);
	const limit = options.limit ?? 20;
	const status = options.status ?? "published";
	const locale = options.locale;

	// Check if FTS table exists
	if (!(await ftsManager.ftsTableExists(collection))) {
		return [];
	}

	// Escape the query for FTS5
	const escapedQuery = escapeQuery(query);
	if (!escapedQuery) {
		return [];
	}

	// Get searchable fields for snippet generation
	const searchableFields = await ftsManager.getSearchableFields(collection);

	// Build weight string for bm25 if weights provided
	// Format: bm25(table, weight1, weight2, ...)
	// First two weights are for 'id' and 'locale' columns (UNINDEXED, so 0)
	let bm25Args = "";
	if (weights && searchableFields.length > 0) {
		const weightValues = ["0", "0"]; // id column, locale column
		for (const field of searchableFields) {
			weightValues.push(String(weights[field] ?? 1));
		}
		bm25Args = weightValues.join(", ");
	}

	// Build and execute the search query
	// Using raw SQL because Kysely doesn't have FTS5 support
	const bm25Expr = bm25Args ? `bm25("${ftsTable}", ${bm25Args})` : `bm25("${ftsTable}")`;

	// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)
	let results;
	try {
		results = await sql<{
			id: string;
			slug: string | null;
			locale: string;
			title: string | null;
			snippet: string | null;
			score: number;
		}>`
		SELECT 
			c.id,
			c.slug,
			c.locale,
			c.title,
			snippet("${sql.raw(ftsTable)}", 2, '<mark>', '</mark>', '...', 32) as snippet,
			${sql.raw(bm25Expr)} as score
		FROM "${sql.raw(ftsTable)}" f
		JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
		WHERE "${sql.raw(ftsTable)}" MATCH ${escapedQuery}
		AND c.status = ${status}
		AND c.deleted_at IS NULL
		${locale ? sql`AND c.locale = ${locale}` : sql``}
		ORDER BY score
		LIMIT ${limit}
	`.execute(db);
	} catch (error) {
		// FTS5 returns syntax errors for queries with unbalanced quotes,
		// stray operators, or other malformed input. Treat these as
		// "no matches" so the user gets an empty result rather than an
		// internals-leaking error. Other errors (table missing, IO) still
		// propagate. Intentionally not logged: any anonymous client can
		// trigger this path, and the underlying error message embeds the
		// raw query, so logging would be both noisy and a log-injection
		// vector.
		if (isFts5SyntaxError(error)) {
			return [];
		}
		throw error;
	}

	return results.rows.map((row) => ({
		collection,
		id: row.id,
		slug: row.slug,
		locale: row.locale,
		title: row.title ?? undefined,
		// SQLite's snippet() returns NULL when the targeted column is
		// NULL for that row — even if the row matched via a different
		// searchable column. Skip sanitization in that case so we don't
		// throw on `null.replace`. The SearchResult.snippet field is
		// already optional, so omitting it is the documented contract.
		snippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),
		score: Math.abs(row.score), // bm25 returns negative scores
	}));
}

// Module-scope regexes so the engine doesn't recompile per call —
// snippet sanitization runs on every search result.
const SNIPPET_AMP_RE = /&/g;
const SNIPPET_LT_RE = /</g;
const SNIPPET_GT_RE = />/g;
const SNIPPET_QUOT_RE = /"/g;
const SNIPPET_APOS_RE = /'/g;

/**
 * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.
 *
 * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`
 * markers around matched terms but does not escape the surrounding
 * source text. Posts that legitimately contain `<`, `>`, `&`, `"` or
 * `'` would render as broken markup, and a `<script>` literal in a
 * title (or any other indexed field) would execute when displayed.
 *
 * The fix: HTML-escape the whole string, which turns the markers into
 * `&lt;mark&gt;` / `&lt;/mark&gt;`. Then restore those two patterns to
 * their original tag form. The result is "the indexed text with all
 * HTML metacharacters escaped, plus a small set of literal `<mark>`
 * highlight tags around matched terms" — which matches the API's
 * documented contract.
 */
function sanitizeSnippet(snippet: string): string {
	return snippet
		.replace(SNIPPET_AMP_RE, "&amp;")
		.replace(SNIPPET_LT_RE, "&lt;")
		.replace(SNIPPET_GT_RE, "&gt;")
		.replace(SNIPPET_QUOT_RE, "&quot;")
		.replace(SNIPPET_APOS_RE, "&#39;")
		.replaceAll("&lt;mark&gt;", "<mark>")
		.replaceAll("&lt;/mark&gt;", "</mark>");
}

/**
 * Get search suggestions for autocomplete
 *
 * @param db - Kysely database instance
 * @param query - Partial search query
 * @param options - Suggestion options
 * @returns Array of suggestions
 */
export async function getSuggestions(
	db: Kysely<Database>,
	query: string,
	options: SuggestOptions = {},
): Promise<Suggestion[]> {
	const limit = options.limit ?? 5;
	const locale = options.locale;

	// Get searchable collections
	let collections = options.collections;
	if (!collections || collections.length === 0) {
		collections = await getSearchableCollections(db);
	}

	if (collections.length === 0) {
		return [];
	}

	const suggestions: Suggestion[] = [];

	for (const collection of collections) {
		const ftsManager = new FTSManager(db);
		const config = await ftsManager.getSearchConfig(collection);
		if (!config?.enabled) {
			continue;
		}

		// Validate before raw SQL interpolation
		validateIdentifier(collection, "collection slug");

		const ftsTable = ftsManager.getFtsTableName(collection);
		const contentTable = ftsManager.getContentTableName(collection);

		// Use prefix search for autocomplete. `escapeQuery` already appends `*`
		// to each term for prefix matching, so we must not append another one.
		const prefixQuery = escapeQuery(query);
		if (!prefixQuery) {
			continue;
		}

		let results;
		try {
			results = await sql<{
				id: string;
				title: string;
			}>`
				SELECT 
					c.id,
					c.title
				FROM "${sql.raw(ftsTable)}" f
				JOIN "${sql.raw(contentTable)}" c ON f.id = c.id
				WHERE "${sql.raw(ftsTable)}" MATCH ${prefixQuery}
				AND c.status = 'published'
				AND c.deleted_at IS NULL
				AND c.title IS NOT NULL
				${locale ? sql`AND c.locale = ${locale}` : sql``}
				ORDER BY bm25("${sql.raw(ftsTable)}")
				LIMIT ${limit}
			`.execute(db);
		} catch (error) {
			// Same swallow as searchSingleCollection: malformed prefix
			// queries should yield no suggestions, not surface DB errors.
			// Intentionally not logged (anonymous-triggerable, echoes
			// user input -- see searchSingleCollection for rationale).
			if (isFts5SyntaxError(error)) {
				continue;
			}
			throw error;
		}

		for (const row of results.rows) {
			suggestions.push({
				collection,
				id: row.id,
				title: row.title,
			});
		}
	}

	return suggestions.slice(0, limit);
}

/**
 * Get search statistics for all collections
 */
export async function getSearchStats(db: Kysely<Database>): Promise<SearchStats> {
	const ftsManager = new FTSManager(db);
	const collections = await getSearchableCollections(db);
	const stats: SearchStats = { collections: {} };

	for (const collection of collections) {
		const collectionStats = await ftsManager.getIndexStats(collection);
		if (collectionStats) {
			stats.collections[collection] = collectionStats;
		}
	}

	return stats;
}

/**
 * Get list of collections with search enabled
 */
async function getSearchableCollections(db: Kysely<Database>): Promise<string[]> {
	const results = await db
		.selectFrom("_emdash_collections")
		.select(["slug", "search_config"])
		.execute();

	return results
		.filter((r) => {
			if (!r.search_config) return false;
			try {
				const config = JSON.parse(r.search_config);
				return config.enabled === true;
			} catch {
				return false;
			}
		})
		.map((r) => r.slug);
}

/**
 * Escape a query string for FTS5
 *
 * Handles special characters and prevents injection.
 */
function escapeQuery(query: string): string {
	if (!query || typeof query !== "string") {
		return "";
	}

	// Trim whitespace
	query = query.trim();

	if (query.length === 0) {
		return "";
	}

	// If already a quoted phrase, escape only interior quotes and preserve phrase syntax
	if (query.startsWith('"') && query.endsWith('"') && query.length >= 2) {
		const inner = query.slice(1, -1);
		return `"${inner.replace(DOUBLE_QUOTE_PATTERN, '""')}"`;
	}

	// Escape any existing quotes
	const escaped = query.replace(DOUBLE_QUOTE_PATTERN, '""');

	// If the query contains FTS5 operators (AND, OR, NOT, NEAR),
	// pass through with quotes escaped but operators preserved
	if (FTS_OPERATORS_PATTERN.test(query)) {
		return escaped;
	}

	// For simple queries, wrap each word to handle special chars
	const terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);
	if (terms.length === 0) {
		return "";
	}

	// Join with implicit AND, add prefix matching (*) to all terms
	// This allows "hel wor" to match "hello world"
	return terms.map((t) => `"${t}"*`).join(" ");
}
