using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;

namespace ROLAC.API.Services.Ai;

/// <summary>
/// Provider-independent expense-AI logic: loads the active category catalog, builds the
/// classification prompt, and validates the model's chosen ids against that catalog. Concrete
/// providers (Gemini, Claude) only implement <see cref="CallModelAsync"/> — the HTTP call plus
/// response parsing — so the catalog/prompt/validation code lives in exactly one place.
/// </summary>
public abstract class ExpenseAiServiceBase : IExpenseAiService
{
    private readonly AppDbContext _db;

    protected ExpenseAiServiceBase(AppDbContext db) => _db = db;

    /// <summary>One sub-category in the catalog passed to the model.</summary>
    protected sealed record CatalogSub(int Id, string NameEn, string? NameZh);

    /// <summary>One major category (with its sub-categories) in the catalog passed to the model.</summary>
    protected sealed record CatalogGroup(int Id, string NameEn, string? NameZh, IReadOnlyList<CatalogSub> Subs);

    /// <summary>The model's raw answer, before its ids are validated against the catalog.</summary>
    protected sealed record ModelAnswer(
        string? EnglishDescription, string? ChineseDescription, int GroupId, int SubCategoryId, double Confidence);

    public async Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default)
    {
        var catalog = await LoadCatalogAsync(ct);
        var prompt = BuildPrompt(chineseText, amount, catalog);

        var answer = await CallModelAsync(prompt, ct);
        if (answer is null) return new ExpenseAiSuggestion();

        return BuildSuggestion(answer, catalog);
    }

    /// <summary>
    /// Call the provider's API with <paramref name="prompt"/> and return its parsed answer, or null
    /// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
    /// </summary>
    protected abstract Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct);

    private async Task<List<CatalogGroup>> LoadCatalogAsync(CancellationToken ct)
    {
        return await _db.ExpenseCategoryGroups
            .AsNoTracking()
            .Where(group => group.IsActive)
            .OrderBy(group => group.SortOrder)
            .Select(group => new CatalogGroup(
                group.Id,
                group.Name_en,
                group.Name_zh,
                group.SubCategories
                    .Where(sub => sub.IsActive)
                    .OrderBy(sub => sub.SortOrder)
                    .Select(sub => new CatalogSub(sub.Id, sub.Name_en, sub.Name_zh))
                    .ToList()))
            .ToListAsync(ct);
    }

    private static string BuildPrompt(string chineseText, decimal amount, List<CatalogGroup> catalog)
    {
        var catalogJson = JsonSerializer.Serialize(catalog);
        return
            "You are a bookkeeping assistant for a church. Given an expense description (often in " +
            "Traditional Chinese) and its amount, do three things:\n" +
            "1. Correct any typos in the description and refine it into natural Traditional Chinese — " +
            "return it as chineseDescription.\n" +
            "2. Translate that into concise, natural accounting English (a short noun phrase, not a " +
            "full sentence) — return it as englishDescription.\n" +
            "3. Choose the single best matching major category (group) and sub-category from the catalog " +
            "below. You MUST pick a groupId and subCategoryId that appear in the catalog, and the " +
            "subCategoryId must belong to that groupId. If nothing fits well, choose the closest " +
            "\"Other / 其他\" option and lower your confidence.\n\n" +
            $"Expense description: {chineseText}\n" +
            $"Amount: {amount}\n\n" +
            $"Category catalog (JSON; each group has an Id, English/Chinese names, and its Subs):\n{catalogJson}";
    }

    private static ExpenseAiSuggestion BuildSuggestion(ModelAnswer answer, List<CatalogGroup> catalog)
    {
        var suggestion = new ExpenseAiSuggestion
        {
            EnglishDescription = string.IsNullOrWhiteSpace(answer.EnglishDescription)
                ? null
                : answer.EnglishDescription.Trim(),
            ChineseDescription = string.IsNullOrWhiteSpace(answer.ChineseDescription)
                ? null
                : answer.ChineseDescription.Trim(),
            Confidence = answer.Confidence,
        };

        // Re-validate the returned ids against the catalog; drop anything that doesn't line up
        // (defends against a hallucinated id, or a sub-category that doesn't belong to the group).
        var group = catalog.FirstOrDefault(candidate => candidate.Id == answer.GroupId);
        if (group is not null)
        {
            suggestion.GroupId = group.Id;
            suggestion.GroupLabel = Label(group.NameEn, group.NameZh);

            var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == answer.SubCategoryId);
            if (sub is not null)
            {
                suggestion.SubCategoryId = sub.Id;
                suggestion.SubLabel = Label(sub.NameEn, sub.NameZh);
            }
        }

        return suggestion;
    }

    /// <summary>Mirror the frontend's bilingual() convention: "English / 中文" (or just English).</summary>
    private static string Label(string nameEn, string? nameZh)
        => string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
}
