// src/TotalRecall.Server/Handlers/MemoryStoreHandler.cs
//
// Plan 4 Task 4.6 — first real IToolHandler. Ports the `memory_store` branch
// of src-ts/tools/memory-tools.ts (plus src-ts/memory/store.ts) to the .NET
// Server. The handler validates MCP arguments, invokes the embedder to warm
// and produce a query vector, then calls IStore.InsertWithEmbedding to
// persist the metadata row and the vec0 embedding row inside a single
// transaction.
//
// Design notes:
//
//   - The transactional insert closes the 0.6.7 window where a vec insert
//     crash (e.g. rowid collision with an orphan) could leave a content row
//     behind without an embedding. InsertWithEmbedding rolls back the
//     content insert automatically when the vec insert throws.
//
//   - IVectorSearch is still a constructor dependency for now even though
//     the handler itself no longer references it directly; the composition
//     root still wires it and future handlers may need it. Removing the
//     parameter is a follow-up cleanup.
//
//   - InputSchema is built once from a verbatim literal that mirrors the TS
//     schema in memory-tools.ts:25-44, then cloned into a static field. The
//     JsonElement is safe to hand out repeatedly because Clone() copies the
//     underlying JsonDocument buffer.
//
//   - MetadataJson for `entryType` is built by hand — a tiny one-key object —
//     because the value is drawn from a closed enum and therefore cannot
//     contain characters that would need JSON escaping. This avoids pulling
//     a new serializable record into JsonContext just for a trivial payload.
//
//   - Response payload is `{"id":"<id>"}` built the same way. The entry id
//     is generated by the SQLite store (ULIDs in production; fakes under
//     test); it never contains characters requiring JSON escaping. Matches
//     the TS `JSON.stringify({ id })` output byte-for-byte.
//
//   - ArgumentException is used for all validation failures so Task 4.10's
//     ErrorTranslator can wrap them into MCP tool-error shape. The handler
//     itself does not translate — it throws, and McpServer (or the caller)
//     decides how to render.

using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TotalRecall.Core;
using TotalRecall.Infrastructure.Embedding;
using TotalRecall.Infrastructure.Memory;
using TotalRecall.Infrastructure.Search;
using TotalRecall.Infrastructure.Storage;

namespace TotalRecall.Server.Handlers;

/// <summary>
/// MCP handler for the <c>memory_store</c> tool. Validates arguments,
/// produces an embedding for the content, inserts a new entry row, and
/// persists the embedding alongside it.
/// </summary>
public sealed class MemoryStoreHandler : IToolHandler
{
    // Mirror of src-ts/tools/memory-tools.ts:25-44. Parsed once into a
    // JsonDocument and cloned so the root element survives disposal-free
    // reuse across tools/list calls.
    private static readonly JsonElement _inputSchema = JsonDocument.Parse("""
        {
          "type": "object",
          "properties": {
            "content":     {"type":"string","description":"The content to store"},
            "tier":        {"type":"string","enum":["hot","warm","cold"],"description":"Storage tier (default: hot; use pinned:true for the pinned tier)"},
            "contentType": {"type":"string","enum":["memory","knowledge"],"description":"Content type (default: memory)"},
            "entryType":   {"type":"string","enum":["correction","preference","decision","surfaced","imported","compacted","ingested"],"description":"Entry type"},
            "project":     {"type":"string","description":"Project scope"},
            "tags":        {"type":["array","string"],"items":{"type":"string"},"description":"Tags (array, JSON-encoded array string, or comma-separated string)"},
            "source":      {"type":"string","description":"Source identifier"},
            "visibility":  {"type":"string","enum":["private","team","public"],"description":"Entry visibility: 'private' (default), 'team', or 'public'"},
            "scope":       {"type":"string","description":"Scope for this entry (e.g. user:paul, team:eng, service:bot). Uses configured default if omitted."},
            "pinned":      {"type":"boolean","description":"Store directly into the pinned tier (always injected, never decays). Mutually exclusive with tier."}
          },
          "required": ["content"]
        }
        """).RootElement.Clone();

    // Matches TS validation.ts MAX_CONTENT_LENGTH.
    private const int MaxContentLength = 100_000;

    private static readonly HashSet<string> ValidEntryTypes = new(StringComparer.Ordinal)
    {
        "correction", "preference", "decision", "surfaced",
        "imported", "compacted", "ingested",
    };

    private static readonly HashSet<string> ValidVisibilities = new(StringComparer.Ordinal)
    {
        "private", "team", "public",
    };

    private readonly IStore _store;
    private readonly IEmbedder _embedder;
    private readonly IVectorSearch _vectorSearch;
    private readonly string? _scopeDefault;
    private readonly int _pinnedMaxContentChars;

    public MemoryStoreHandler(
        IStore store,
        IEmbedder embedder,
        IVectorSearch vectorSearch,
        string? scopeDefault = null,
        int pinnedMaxContentChars = PinnedTierLimits.DefaultMaxContentChars)
    {
        _store = store ?? throw new ArgumentNullException(nameof(store));
        _embedder = embedder ?? throw new ArgumentNullException(nameof(embedder));
        _vectorSearch = vectorSearch ?? throw new ArgumentNullException(nameof(vectorSearch));
        _scopeDefault = scopeDefault;
        _pinnedMaxContentChars = pinnedMaxContentChars;
    }

    public string Name => "memory_store";

    public string Description => "Store a new memory or knowledge entry";

    public JsonElement InputSchema => _inputSchema;

    public Task<ToolCallResult> ExecuteAsync(JsonElement? arguments, CancellationToken ct)
    {
        if (!arguments.HasValue)
            throw new ArgumentException("memory_store requires arguments", nameof(arguments));

        var args = arguments.Value;
        if (args.ValueKind != JsonValueKind.Object)
            throw new ArgumentException("memory_store arguments must be a JSON object", nameof(arguments));

        var content = ReadRequiredString(args, "content");
        if (content.Length == 0)
            throw new ArgumentException("content must be a non-empty string");
        if (content.Length > MaxContentLength)
            throw new ArgumentException(
                $"Content exceeds maximum length of {MaxContentLength} characters");

        var pinned = args.TryGetProperty("pinned", out var pinnedEl)
            && pinnedEl.ValueKind == JsonValueKind.True;
        if (pinned && args.TryGetProperty("tier", out var tierEl)
            && tierEl.ValueKind != JsonValueKind.Null)
            throw new ArgumentException("specify either pinned or tier, not both");
        if (pinned && content.Length > _pinnedMaxContentChars)
            throw new ArgumentException(
                PinnedTierLimits.ContentLimitMessage(_pinnedMaxContentChars, content.Length));
        var tier = pinned ? Tier.Pinned : ReadTier(args);
        var contentType = ReadContentType(args);
        var entryType = ReadEntryType(args);
        var project = ReadOptionalString(args, "project");
        var source = ReadOptionalString(args, "source");
        var tags = ReadTags(args);
        var visibility = ReadVisibility(args);
        var scope = args.TryGetProperty("scope", out var scopeEl) && scopeEl.ValueKind == JsonValueKind.String
            ? scopeEl.GetString()
            : _scopeDefault;

        // Build the metadata JSON object by hand. All values are drawn from
        // closed enums and cannot contain characters requiring JSON escaping.
        // We omit keys with default values to keep metadata lean: entryType
        // is omitted when null, visibility is omitted when "private" (the
        // default). This avoids a generic JsonSerializer.Serialize call that
        // would trigger IL2026/IL3050 AOT warnings.
        string? metadataJson;
        if (entryType is not null && visibility != "private")
            metadataJson = $"{{\"entry_type\":\"{entryType}\",\"visibility\":\"{visibility}\"}}";
        else if (entryType is not null)
            metadataJson = $"{{\"entry_type\":\"{entryType}\"}}";
        else if (visibility != "private")
            metadataJson = $"{{\"visibility\":\"{visibility}\"}}";
        else
            metadataJson = null;

        // Idempotent: if the same content already exists in the target tier,
        // return its id without re-embedding or inserting a duplicate.
        var existingId = _store.FindByContent(tier, contentType, content);
        if (existingId is not null)
        {
            return Task.FromResult(new ToolCallResult
            {
                Content = new[] { new ToolContent { Type = "text", Text = $"{{\"id\":\"{existingId}\"}}" } },
                IsError = false,
            });
        }

        ct.ThrowIfCancellationRequested();

        // Ensures the ONNX embedder's lazy singleton is loaded on first call
        // (same semantics as TS ctx.embedder.ensureLoaded + embed).
        var vector = _embedder.Embed(content);

        ct.ThrowIfCancellationRequested();

        // PRE-EXISTING behavior: ALL memory_store inserts use EntryType.Preference
        // in the column regardless of the `entryType` arg (which lands in
        // metadata JSON only). This means a pinned:true stored entry has
        // entry_type=Preference in the column, whereas an entry moved into the
        // pinned tier via memory_pin preserves its original entry_type column
        // value. Task 8 (session-start injection) must handle both cases.
        var id = _store.InsertWithEmbedding(
            tier,
            contentType,
            new InsertEntryOpts(
                Content: content,
                Source: source,
                Project: project,
                Tags: tags,
                MetadataJson: metadataJson,
                Scope: scope,
                EntryType: EntryType.Preference),
            vector);

        // Match TS wire: content[0].text == JSON.stringify({ id }).
        // Entry ids are ULIDs (or test-supplied strings) — no JSON-unsafe chars.
        var payload = $"{{\"id\":\"{id}\"}}";

        return Task.FromResult(new ToolCallResult
        {
            Content = new[] { new ToolContent { Type = "text", Text = payload } },
            IsError = false,
        });
    }

    // ---------- argument parsing helpers ----------

    private static string ReadRequiredString(JsonElement args, string name)
    {
        if (!args.TryGetProperty(name, out var prop))
            throw new ArgumentException($"{name} is required");
        if (prop.ValueKind != JsonValueKind.String)
            throw new ArgumentException($"{name} must be a string");
        return prop.GetString() ?? throw new ArgumentException($"{name} must be a string");
    }

    private static string? ReadOptionalString(JsonElement args, string name)
    {
        if (!args.TryGetProperty(name, out var prop)) return null;
        if (prop.ValueKind == JsonValueKind.Null) return null;
        if (prop.ValueKind != JsonValueKind.String)
            throw new ArgumentException($"{name} must be a string");
        return prop.GetString();
    }

    private static Tier ReadTier(JsonElement args)
    {
        if (!args.TryGetProperty("tier", out var prop) || prop.ValueKind == JsonValueKind.Null)
            return Tier.Hot;
        if (prop.ValueKind != JsonValueKind.String)
            throw new ArgumentException("tier must be a string");
        var v = prop.GetString();
        return v switch
        {
            "hot" => Tier.Hot,
            "warm" => Tier.Warm,
            "cold" => Tier.Cold,
            "pinned" => throw new ArgumentException(
                "tier 'pinned' is not valid here; use the pinned:true flag instead"),
            _ => throw new ArgumentException($"Invalid tier: {v}. Must be hot, warm, or cold"),
        };
    }

    private static ContentType ReadContentType(JsonElement args)
    {
        if (!args.TryGetProperty("contentType", out var prop) || prop.ValueKind == JsonValueKind.Null)
            return ContentType.Memory;
        if (prop.ValueKind != JsonValueKind.String)
            throw new ArgumentException("contentType must be a string");
        var v = prop.GetString();
        return v switch
        {
            "memory" => ContentType.Memory,
            "knowledge" => ContentType.Knowledge,
            _ => throw new ArgumentException($"Invalid content type: {v}. Must be memory or knowledge"),
        };
    }

    private static string? ReadEntryType(JsonElement args)
    {
        if (!args.TryGetProperty("entryType", out var prop) || prop.ValueKind == JsonValueKind.Null)
            return null;
        if (prop.ValueKind != JsonValueKind.String)
            throw new ArgumentException("entryType must be a string");
        var v = prop.GetString();
        if (v is null || !ValidEntryTypes.Contains(v))
            throw new ArgumentException($"Invalid entry type: {v}");
        return v;
    }

    private static string ReadVisibility(JsonElement args)
    {
        if (!args.TryGetProperty("visibility", out var prop) || prop.ValueKind == JsonValueKind.Null)
            return "private";
        if (prop.ValueKind != JsonValueKind.String)
            throw new ArgumentException("visibility must be a string");
        var v = prop.GetString();
        if (v is null || !ValidVisibilities.Contains(v))
            throw new ArgumentException($"Invalid visibility: {v}. Must be private, team, or public");
        return v;
    }

    private static IReadOnlyList<string>? ReadTags(JsonElement args)
        => ArgumentParsing.ReadTags(args);
}
