Editor/Tools/ApiAndReferenceTools.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Sandbox;

namespace Editor.Mcp
{
	public sealed class CodeSearchQuery
	{
		public string Query { get; init; } = "";
		public string? Kind { get; init; }
		public string? Type { get; init; }
		public string? Extension { get; init; }
		public int? Year { get; init; }
		public string? Package { get; init; }
		public int Page { get; init; } = 1;
		public int Limit { get; init; } = 20;
	}

	public sealed class CodeSearchResult
	{
		public string PackageIdent { get; init; } = "";
		public string PackageTitle { get; init; } = "";
		public string PackageType { get; init; } = "";
		public string FilePath { get; init; } = "";
		public string Snippet { get; init; } = "";
		public string PackageUrl { get; init; } = "";
		public string FileUrl { get; init; } = "";
		public bool PublicOpenSource { get; init; } = true;
	}

	public sealed class CodeSearchPage
	{
		public CodeSearchQuery Query { get; init; } = new();
		public int Total { get; init; }
		public int Page { get; init; }
		public int Limit { get; init; }
		public bool HasMore { get; init; }
		public string Transport { get; init; } = "";
		public IReadOnlyList<CodeSearchResult> Items { get; init; } = Array.Empty<CodeSearchResult>();
	}

	public static partial class SboxMcpAssistant
	{
		private static readonly HttpClient SchemaClient = new() { Timeout = TimeSpan.FromSeconds(15) };
		private static JsonDocument? _cachedSchema;
		private static DateTime _schemaFetched;
		private static string _schemaSource = "unknown";

		[McpTool("pocketknife_query_schema")]
		public static async Task<string> QuerySchema([Description("Type or method name to search for")] string query, [Description("Maximum results")] int limit = 10)
		{
			var started = DateTime.UtcNow;
			var normalizedQuery = (query ?? "").Trim();
			if (normalizedQuery.Length > 200) return McpJson.Write(ToolOutcome<object>.Failure(new { QueryLength = normalizedQuery.Length, MaximumQueryLength = 200 }, ToolStatus.Failed, "schema.query_too_long", "Schema query must be 200 characters or fewer."));
			try
			{
				var root = await LoadSchemaAsync();
				if (root is null) return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Unavailable, "schema.unavailable", "The official schema and local cache were unavailable.", remediation: "Set SBOX_SCHEMA_ENDPOINT or retry the current official schema release."));
				var results = new List<JsonElement>();
				if (root.Value.TryGetProperty("Types", out var types) && types.ValueKind == JsonValueKind.Array)
					foreach (var type in types.EnumerateArray())
					{
						var text = type.ToString();
						if (text.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)) { results.Add(type.Clone()); if (results.Count >= Math.Clamp(limit, 1, 100)) break; }
					}
				var schemaTypeCount = root.Value.TryGetProperty("Types", out var schemaTypes) && schemaTypes.ValueKind == JsonValueKind.Array ? schemaTypes.GetArrayLength() : 0;
				var value = new { Query = normalizedQuery, Count = results.Count, Items = results, Source = _schemaSource, SchemaTypeCount = schemaTypeCount, CachedAt = _schemaFetched };
				return McpJson.Write(new ToolOutcome<object> { Success = results.Count > 0, Status = results.Count > 0 ? ToolStatus.Ready : ToolStatus.Degraded, Value = value, DurationMs = (long)(DateTime.UtcNow - started).TotalMilliseconds, Warnings = results.Count == 0 ? new List<string> { "No matching schema types were found." } : new() });
			}
			catch (Exception ex) { return McpJson.Write(ToolOutcome<object>.Failure(null, ToolStatus.Failed, "schema.search_failed", "Schema search failed.", ex)); }
		}

		[McpTool("pocketknife_search_local_references")]
		public static string SearchLocalReferences(
			[Description("Search query")] string query,
			[Description("Maximum results, 1-100")] int limit = 20,
			[Description("Zero-based result offset")] int offset = 0,
			[Description("Optional project filter")] string project = "",
			[Description("Optional extension filter")] string extension = "",
			[Description("Optional source label filter")] string source = "")
		{
			try
			{
				var normalized = (query ?? "").Trim();
				if (normalized.Length > 200) return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Failure(null, ToolStatus.Failed, "references.query_too_long", "Reference query must be 200 characters or fewer."));
				var page = ReferenceIndexService.Search(new ReferenceSearchQuery { Query = normalized, Limit = Math.Clamp(limit, 1, 100), Offset = Math.Max(offset, 0), Project = project, Extension = extension, Source = source });
				return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Ready(page));
			}
			catch (FileNotFoundException) { return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Failure(null, ToolStatus.Unavailable, "references.catalog_missing", "The configured reference catalog was not found.", remediation: "Set SBOX_REFERENCE_ROOT or install the local reference catalog.")); }
			catch (Exception ex) { return McpJson.Write(ToolOutcome<ReferenceSearchPage>.Failure(null, ToolStatus.Failed, "references.search_failed", "Indexed reference search failed.", ex)); }
		}

		[McpTool("pocketknife_rebuild_local_reference_index")]
		public static string RebuildLocalReferenceIndex([Description("Force a complete rebuild")] bool force = false)
		{
			try { return McpJson.Write(ToolOutcome<ReferenceIndexStatus>.Ready(ReferenceIndexService.Rebuild(force: force))); }
			catch (FileNotFoundException) { return McpJson.Write(ToolOutcome<ReferenceIndexStatus>.Failure(null, ToolStatus.Unavailable, "references.catalog_missing", "The configured reference catalog was not found.")); }
			catch (Exception ex) { return McpJson.Write(ToolOutcome<ReferenceIndexStatus>.Failure(null, ToolStatus.Failed, "references.rebuild_failed", "Reference index rebuild failed.", ex)); }
		}

		[McpTool("pocketknife_get_local_reference")]
		public static string GetLocalReference([Description("Stable reference result ID")] string id)
		{
			try
			{
				var item = ReferenceIndexService.Get(id);
				return item is null ? McpJson.Write(ToolOutcome<ReferenceSearchItem>.Failure(null, ToolStatus.Degraded, "references.not_found", $"No indexed reference exists for '{id}'.")) : McpJson.Write(ToolOutcome<ReferenceSearchItem>.Ready(item));
			}
			catch (Exception ex) { return McpJson.Write(ToolOutcome<ReferenceSearchItem>.Failure(null, ToolStatus.Failed, "references.get_failed", "Reference lookup failed.", ex)); }
		}

		[McpTool("pocketknife_search_live_codesearch")]
		public static async Task<string> SearchLiveCodeSearch(
			[Description("Search text")] string query,
			[Description("Package kind, e.g. Game or Library")] string kind = "",
			[Description("Package type filter")] string type = "",
			[Description("File extension, e.g. cs")] string extension = "cs",
			[Description("One-based result page")] int page = 1,
			[Description("Results per page, 1-50")] int limit = 20)
		{
			var normalizedQuery = (query ?? "").Trim();
			if (normalizedQuery.Length > 200) return McpJson.Write(ToolOutcome<CodeSearchPage>.Failure(null, ToolStatus.Failed, "codesearch.query_too_long", "Code Search query must be 200 characters or fewer."));
			var request = new CodeSearchQuery { Query = normalizedQuery, Kind = kind, Type = type, Extension = extension, Page = Math.Max(1, page), Limit = Math.Clamp(limit, 1, 100) };
			try { return McpJson.Write(await OfficialCodeSearchClient.SearchAsync(request)); }
			catch (Exception ex) { return McpJson.Write(ToolOutcome<CodeSearchPage>.Failure(null, ToolStatus.Unavailable, "codesearch.transport_unavailable", "The official s&box Code Search transport could not be reached or parsed.", ex, "Install/start the local Playwright code-search worker, or retry when sbox.game is available.")); }
		}

		private static async Task<JsonElement?> LoadSchemaAsync()
		{
			if (_cachedSchema is not null && (DateTime.UtcNow - _schemaFetched).TotalHours < 24) return _cachedSchema.RootElement.Clone();
			var path = Path.Combine(Project.Current?.GetRootPath() ?? Environment.CurrentDirectory, ".sbox", "schema_cache.json");

			// Keep infrastructure endpoints out of the package. A configured Hearth endpoint
			// is accepted only over HTTPS or loopback HTTP, then local/public fallbacks are tried.
			var configuredEndpoint = Environment.GetEnvironmentVariable("HEARTH_SCHEMA_ENDPOINT")
				?? Environment.GetEnvironmentVariable("SBOX_SCHEMA_ENDPOINT");
			var releaseEndpoint = Environment.GetEnvironmentVariable("SBOX_SCHEMA_RELEASE_ENDPOINT") ?? "https://cdn.sbox.game/releases/2026-09-14-22-27-43.zip.json";
			var endpoints = new[]
			{
				configuredEndpoint,
				releaseEndpoint,
				"http://127.0.0.1:5509/api/schema",
				"https://sbox.game/api/schema"
			}
			.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint))
			.Select(endpoint => endpoint!.Trim())
			.Where(IsAllowedSchemaEndpoint)
			.Distinct(StringComparer.OrdinalIgnoreCase)
			.ToArray();

			foreach (var url in endpoints)
			{
				try
				{
					using var response = await SchemaClient.GetAsync(url);
					if (!response.IsSuccessStatusCode) continue;

					var content = await response.Content.ReadAsStringAsync();
					var trimmed = content.TrimStart();
					
					// GUARD: Reject HTML responses (e.g. sbox.game SPA fallback)
					if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith("<", StringComparison.OrdinalIgnoreCase))
					{
						continue;
					}

					Directory.CreateDirectory(Path.GetDirectoryName(path)!);
					File.WriteAllText(path, content);
					_cachedSchema = JsonDocument.Parse(content);
					_schemaSource = url;
					_schemaFetched = DateTime.UtcNow;
					return _cachedSchema.RootElement.Clone();
				}
				catch
				{
					// Try next endpoint
				}
			}

			// Fallback: Use existing valid cached file on disk if available
			try
			{
				if (File.Exists(path))
				{
					var diskContent = File.ReadAllText(path);
					var trimmed = diskContent.TrimStart();
					if (!string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith("<", StringComparison.OrdinalIgnoreCase))
					{
						_cachedSchema = JsonDocument.Parse(diskContent);
						_schemaSource = "project-cache";
						_schemaFetched = File.GetLastWriteTimeUtc(path);
						return _cachedSchema.RootElement.Clone();
					}
				}
			}
			catch
			{
				// Cache file is corrupt or unreadable
			}

			return null;
		}

		private static bool IsAllowedSchemaEndpoint(string endpoint)
		{
			if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
				!string.IsNullOrWhiteSpace(uri.UserInfo) || uri.Query.Length > 0 || uri.Fragment.Length > 0)
				return false;
			return uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ||
				(uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && uri.IsLoopback);
		}
	}

	internal static class OfficialCodeSearchClient
	{
		private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(20) };
		private static readonly HttpClient WorkerClient = new() { Timeout = TimeSpan.FromSeconds(60) };
		private static readonly Regex LinkRegex = new("href=[\\\"'](?<href>[^\\\"']+)[\\\"'][^>]*>(?<text>.*?)</a>", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);

		public static async Task<ToolOutcome<CodeSearchPage>> SearchAsync(CodeSearchQuery query)
		{
			var worker = Environment.GetEnvironmentVariable("SBOX_CODESEARCH_WORKER_URL") ?? "http://127.0.0.1:7273";
			try
			{
				using var request = new StringContent(JsonSerializer.Serialize(query, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }), Encoding.UTF8, "application/json");
				using var response = await WorkerClient.PostAsync(worker.TrimEnd('/') + "/v1/search", request);
				if (response.IsSuccessStatusCode)
				{
					var json = await response.Content.ReadAsStringAsync(); var parsed = JsonSerializer.Deserialize<ToolOutcome<CodeSearchPage>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }); if (parsed is not null) return parsed;
				}
			}
			catch { /* The worker is optional; fall through to the official page probe. */ }
			var url = BuildUrl(query); var html = await Client.GetStringAsync(url); var items = ParseVisibleLinks(html).Take(query.Limit).ToArray();
			if (items.Length == 0) return ToolOutcome<CodeSearchPage>.Degraded(new CodeSearchPage { Query = query, Page = query.Page, Limit = query.Limit, Transport = "official-page-no-static-results", Items = items }, "codesearch.blazor_results", "The official page loaded but returned no statically rendered result cards; a browser worker is required for Blazor results.", "Start the Playwright code-search worker to drive the official page.");
			return ToolOutcome<CodeSearchPage>.Ready(new CodeSearchPage { Query = query, Page = query.Page, Limit = query.Limit, HasMore = items.Length == query.Limit, Transport = "official-page", Items = items });
		}

		private static string BuildUrl(CodeSearchQuery q)
		{
			var pairs = new List<string> { "q=" + Uri.EscapeDataString(q.Query) };
			if (!string.IsNullOrWhiteSpace(q.Kind)) pairs.Add("kind=" + Uri.EscapeDataString(q.Kind));
			if (!string.IsNullOrWhiteSpace(q.Type)) pairs.Add("type=" + Uri.EscapeDataString(q.Type));
			if (!string.IsNullOrWhiteSpace(q.Extension)) pairs.Add("ext=" + Uri.EscapeDataString(q.Extension));
			if (q.Page > 1) pairs.Add("page=" + q.Page);
			return "https://sbox.game/codesearch?" + string.Join("&", pairs);
		}

		private static IEnumerable<CodeSearchResult> ParseVisibleLinks(string html)
		{
			foreach (Match match in LinkRegex.Matches(html))
			{
				var href = System.Net.WebUtility.HtmlDecode(match.Groups["href"].Value); var text = Regex.Replace(System.Net.WebUtility.HtmlDecode(match.Groups["text"].Value), "<.*?>", "").Trim();
				if (!href.Contains("/package/", StringComparison.OrdinalIgnoreCase) && !href.Contains("/code/", StringComparison.OrdinalIgnoreCase)) continue;
				var absolute = href.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? href : "https://sbox.game" + (href.StartsWith("/") ? href : "/" + href);
				yield return new CodeSearchResult { PackageTitle = ToolGuard.Clip(text, 200), FilePath = text, FileUrl = absolute, PackageUrl = absolute, Snippet = "Official Code Search result" };
			}
		}
	}
}