using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

namespace GerbilManager.Tests;

/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
///  - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
///  - Validation: an empty message is rejected with 400.
///  - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
/// </summary>
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
    private readonly ApiFactory _factory;
    public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;

    [Fact]
    public async Task Post_feedback_persists_row_with_debug_context()
    {
        var client = _factory.CreateClient();

        var gerbilId = Guid.NewGuid();
        var resp = await client.PostAsJsonAsync("/feedback", new
        {
            message = "Der Stammbaum zeigt den falschen Vater.",
            context = "stammbaum",
            gerbilId,
            litterId = (Guid?)null,
            entityName = "Krümel",
            url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
            clientTimestamp = "2026-06-22T12:00:00Z",
        });

        Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
        var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
        Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
        Assert.Equal("stammbaum", created.GetProperty("context").GetString());
        Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
        Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());

        // A freshly created ticket starts Open with no ResolvedAt.
        Assert.Equal("Open", created.GetProperty("status").GetString());
        Assert.Equal(JsonValueKind.Null, created.GetProperty("resolvedAt").ValueKind);

        // GET returns it (newest first)
        var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
        Assert.Contains(listed.EnumerateArray(),
            f => f.GetProperty("entityName").GetString() == "Krümel"
                 && f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater."
                 && f.GetProperty("status").GetString() == "Open");
    }

    [Fact]
    public async Task Feedback_crud_lifecycle_edit_resolve_reopen_delete()
    {
        var client = _factory.CreateClient();

        // Create
        var create = await client.PostAsJsonAsync("/feedback", new
        {
            message = "Gewicht wird falsch gerundet.",
            context = "gerbil-detail",
            entityName = "Balu",
        });
        Assert.Equal(HttpStatusCode.Created, create.StatusCode);
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
        Assert.False(string.IsNullOrEmpty(id));

        // List contains it
        var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
        Assert.Contains(listed.EnumerateArray(), f => f.GetProperty("id").GetString() == id);

        // Edit message
        var edit = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "Gewicht wird falsch gerundet (auf der Verlaufskurve)." });
        Assert.Equal(HttpStatusCode.OK, edit.StatusCode);
        var edited = JsonDocument.Parse(await edit.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("Gewicht wird falsch gerundet (auf der Verlaufskurve).", edited.GetProperty("message").GetString());
        Assert.Equal("Open", edited.GetProperty("status").GetString());

        // Empty message is rejected
        var empty = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "   " });
        Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode);

        // Mark resolved -> ResolvedAt is set
        var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
        Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
        var resolved = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("Resolved", resolved.GetProperty("status").GetString());
        Assert.NotEqual(JsonValueKind.Null, resolved.GetProperty("resolvedAt").ValueKind);

        // Reopen -> ResolvedAt cleared
        var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Open" });
        Assert.Equal(HttpStatusCode.OK, reopen.StatusCode);
        var reopened = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("Open", reopened.GetProperty("status").GetString());
        Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);

        // Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da.
        var del = await client.DeleteAsync($"/feedback/{id}");
        Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);

        var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
        var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
        Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind);

        // Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
        var restore = await client.PostAsync($"/feedback/{id}/restore", null);
        Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
        var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind);

        // Delete/Restore eines unbekannten Tickets -> 404.
        var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}");
        Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode);
        var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null);
        Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode);
    }

    [Fact]
    public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new
        {
            message = "Farbschlag stimmt nicht.",
            context = "gerbil-detail",
        });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        // Direkt (ohne Rückfrage) lösen …
        await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" });

        // … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage.
        var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
        {
            status = "NeedsInfo",
            question = "Bitte beschreibe, was noch fehlt.",
        });
        var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
        Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString());
        // Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt.
        Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
    }

    [Fact]
    public async Task Attachment_upload_list_serve_delete_lifecycle()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new
        {
            message = "Foto vom Fellschlag.",
            context = "gerbil-detail",
        });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        // 1×1-PNG hochladen.
        const string pngB64 =
            "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
        var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
        {
            fileName = "maus.png",
            contentType = "image/png",
            dataBase64 = pngB64,
        });
        Assert.Equal(HttpStatusCode.Created, up.StatusCode);
        var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
        var attId = att.GetProperty("id").GetString();
        Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
        Assert.True(att.GetProperty("size").GetInt32() > 0);

        // GET /feedback liefert die Metadaten (ohne Bytes).
        var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
        var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
        Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());

        // Bytes ausliefern.
        var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
        Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);

        // Löschen -> danach keine Anhänge mehr.
        var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
        Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
        var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
        var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
        Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
    }

    [Fact]
    public async Task Attachment_upload_rejects_invalid_base64()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
        var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
        {
            fileName = "x.png",
            contentType = "image/png",
            dataBase64 = "###nicht base64###",
        });
        Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
    }

    [Fact]
    public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new
        {
            message = "Welches Tier ist gemeint?",
            context = "gerbil-detail",
        });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        // Rückfrage stellen (NeedsInfo) …
        await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" });
        // … manuell lösen …
        await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
        // … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos).
        var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
        {
            status = "NeedsInfo",
            question = "Meinst du A oder B?",
        });
        var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
        Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString());
        // Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel.
        Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
    }

    [Fact]
    public async Task Put_question_sets_NeedsInfo_and_answer_sets_Answered()
    {
        var client = _factory.CreateClient();

        // Create an open ticket.
        var create = await client.PostAsJsonAsync("/feedback", new
        {
            message = "Wurf B hat ein falsches Geburtsdatum.",
            context = "litter-detail",
            entityName = "Wurf B",
        });
        Assert.Equal(HttpStatusCode.Created, create.StatusCode);
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        // Attach a clarifying question -> Status becomes NeedsInfo, question echoed back, no answer yet.
        var ask = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Welches Datum stimmt?" });
        Assert.Equal(HttpStatusCode.OK, ask.StatusCode);
        var asked = JsonDocument.Parse(await ask.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("NeedsInfo", asked.GetProperty("status").GetString());
        Assert.Equal("Welches Datum stimmt?", asked.GetProperty("question").GetString());
        Assert.Equal(JsonValueKind.Null, asked.GetProperty("answer").ValueKind);
        Assert.Equal(JsonValueKind.Null, asked.GetProperty("answeredAt").ValueKind);

        // Breeder answers -> Status becomes Answered, AnsweredAt stamped, answer echoed, question retained.
        var reply = await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der 2. Juni." });
        Assert.Equal(HttpStatusCode.OK, reply.StatusCode);
        var answered = JsonDocument.Parse(await reply.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("Answered", answered.GetProperty("status").GetString());
        Assert.Equal("Der 2. Juni.", answered.GetProperty("answer").GetString());
        Assert.Equal("Welches Datum stimmt?", answered.GetProperty("question").GetString());
        Assert.NotEqual(JsonValueKind.Null, answered.GetProperty("answeredAt").ValueKind);
    }

    [Fact]
    public async Task Put_question_on_resolved_ticket_keeps_it_resolved()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
        var put = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Noch eine Frage?" });
        var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("Resolved", dto.GetProperty("status").GetString());
        Assert.Equal("Noch eine Frage?", dto.GetProperty("question").GetString());
    }

    [Fact]
    public async Task Put_resolve_with_fixNote_stores_breeder_changelog()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new { message = "Vater falsch.", context = "stammbaum", entityName = "Yuki" });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        // Resolve the ticket together with a breeder-friendly fix note (changelog).
        var resolve = await client.PutAsJsonAsync($"/feedback/{id}", new
        {
            status = "Resolved",
            fixNote = "Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
        });
        Assert.Equal(HttpStatusCode.OK, resolve.StatusCode);
        var dto = JsonDocument.Parse(await resolve.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("Resolved", dto.GetProperty("status").GetString());
        Assert.Equal("Die Mutter von Yuki war falsch — sie ist jetzt Izumi. Bitte den Stammbaum neu laden.",
            dto.GetProperty("fixNote").GetString());
        Assert.NotEqual(JsonValueKind.Null, dto.GetProperty("resolvedAt").ValueKind);

        // The fix note survives a re-fetch via GET.
        var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
        Assert.Contains(listed.EnumerateArray(),
            f => f.GetProperty("id").GetString() == id
                 && f.GetProperty("fixNote").GetString()!.StartsWith("Die Mutter von Yuki war falsch"));
    }

    [Fact]
    public async Task Put_agentContext_round_trips_without_changing_status()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new { message = "Gewicht falsch.", context = "gerbil-detail" });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        // Setting only the internal agent context must NOT change the lifecycle status.
        var put = await client.PutAsJsonAsync($"/feedback/{id}", new
        {
            agentContext = "{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
        });
        Assert.Equal(HttpStatusCode.OK, put.StatusCode);
        var dto = JsonDocument.Parse(await put.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal("Open", dto.GetProperty("status").GetString());
        Assert.Equal("{\"rootCause\":\"rounding in WeightChart\",\"files\":[\"WeightChart.tsx\"],\"plan\":\"fix toFixed\"}",
            dto.GetProperty("agentContext").GetString());

        // It is returned by GET as well (for tooling) but the UI never renders it.
        var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
        Assert.Contains(listed.EnumerateArray(),
            f => f.GetProperty("id").GetString() == id
                 && f.GetProperty("agentContext").GetString()!.Contains("rootCause"));
    }

    [Fact]
    public async Task New_question_pushes_prior_QandA_into_thread()
    {
        var client = _factory.CreateClient();
        var create = await client.PostAsJsonAsync("/feedback", new { message = "Wurf C unklar.", context = "litter-detail", entityName = "Wurf C" });
        var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();

        // Round 1: maintainer asks, breeder answers.
        await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Wie heißt die Mutter?" });
        await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Die Mutter ist Luna." });

        // Round 2: a NEW question is asked while the prior Q&A exists.
        var ask2 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Und wer ist der Vater?" });
        Assert.Equal(HttpStatusCode.OK, ask2.StatusCode);
        var dto = JsonDocument.Parse(await ask2.Content.ReadAsStringAsync()).RootElement;

        // Current open exchange = the new question, fresh (no answer yet), status NeedsInfo.
        Assert.Equal("NeedsInfo", dto.GetProperty("status").GetString());
        Assert.Equal("Und wer ist der Vater?", dto.GetProperty("question").GetString());
        Assert.Equal(JsonValueKind.Null, dto.GetProperty("answer").ValueKind);
        Assert.Equal(JsonValueKind.Null, dto.GetProperty("answeredAt").ValueKind);

        // The prior round was archived into the thread (oldest first): maintainer Q then breeder A.
        var thread = dto.GetProperty("thread");
        Assert.Equal(JsonValueKind.Array, thread.ValueKind);
        Assert.Equal(2, thread.GetArrayLength());
        Assert.Equal("maintainer", thread[0].GetProperty("role").GetString());
        Assert.Equal("Wie heißt die Mutter?", thread[0].GetProperty("text").GetString());
        Assert.Equal("breeder", thread[1].GetProperty("role").GetString());
        Assert.Equal("Die Mutter ist Luna.", thread[1].GetProperty("text").GetString());

        // Round 2 answered, then round 3 question → thread now holds 4 entries (two full rounds).
        await client.PutAsJsonAsync($"/feedback/{id}", new { answer = "Der Vater ist Max." });
        var ask3 = await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Stimmt das Geburtsdatum?" });
        var dto3 = JsonDocument.Parse(await ask3.Content.ReadAsStringAsync()).RootElement;
        Assert.Equal(4, dto3.GetProperty("thread").GetArrayLength());
        Assert.Equal("Stimmt das Geburtsdatum?", dto3.GetProperty("question").GetString());
    }

    [Fact]
    public async Task Put_and_delete_unknown_id_return_404()
    {
        var client = _factory.CreateClient();
        var missing = Guid.NewGuid();
        Assert.Equal(HttpStatusCode.NotFound, (await client.PutAsJsonAsync($"/feedback/{missing}", new { message = "x" })).StatusCode);
        Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"/feedback/{missing}")).StatusCode);
    }

    [Fact]
    public async Task Post_feedback_rejects_empty_message()
    {
        var client = _factory.CreateClient();
        var resp = await client.PostAsJsonAsync("/feedback", new { message = "   ", context = "gerbil-detail" });
        Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
    }

    [Fact]
    public async Task Feedback_survives_ingest_wipe()
    {
        // Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
        var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(dir);
        try
        {
            var contactId = Guid.NewGuid();
            var fatherId = Guid.NewGuid();
            var motherId = Guid.NewGuid();
            var litterId = Guid.NewGuid();

            var data = new
            {
                Contacts = new[]
                {
                    new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
                },
                Litters = new[]
                {
                    new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
                },
                Gerbils = new[]
                {
                    Animal(fatherId, "Papa", "male", contactId),
                    Animal(motherId, "Mama", "female", contactId),
                },
                GerbilPhotos = Array.Empty<object>(),
            };
            File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));

            var opts = new DbContextOptionsBuilder<ApplicationContext>()
                .UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
                .Options;
            using var db = new ApplicationContext(opts);
            db.Database.EnsureCreated();

            // A feedback report referencing the gerbil + litter that the wipe will delete.
            var feedbackId = Guid.NewGuid();
            db.Feedback.Add(new Feedback
            {
                Id = feedbackId,
                Message = "Bitte prüfen.",
                Context = "gerbil-detail",
                GerbilId = fatherId,
                LitterId = litterId,
                EntityName = "Papa",
                Url = "http://localhost/rennmaeuse/papa",
                CreatedAt = DateTimeOffset.UtcNow,
                Status = "Answered",
                ResolvedAt = null,
                Question = "Welches Datum stimmt?",
                Answer = "Der 2. Juni.",
                AnsweredAt = DateTimeOffset.UtcNow,
                FixNote = "Das Geburtsdatum von Papa wurde korrigiert.",
                AgentContext = "{\"plan\":\"awaiting DOB confirmation\"}",
                Thread = "[{\"role\":\"maintainer\",\"text\":\"Erste Frage?\",\"at\":\"2026-06-20T10:00:00Z\"},{\"role\":\"breeder\",\"text\":\"Erste Antwort.\",\"at\":\"2026-06-20T11:00:00Z\"}]",
            });
            // A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
            // so it must survive the contact-table wipe just like gerbil/litter ids.
            var contactFeedbackId = Guid.NewGuid();
            db.Feedback.Add(new Feedback
            {
                Id = contactFeedbackId,
                Message = "Adresse stimmt nicht.",
                Context = "contact-detail",
                ContactId = contactId,
                EntityName = "Test Breeder",
                Url = "http://localhost/kontakte/test-breeder",
                CreatedAt = DateTimeOffset.UtcNow,
            });
            await db.SaveChangesAsync();

            var config = new ConfigurationBuilder()
                .AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
                .Build();

            // Run the ingest wipe + reload.
            var result = await new IngestResolvedService(db, config, null!).RunAsync();
            Assert.Contains("Ingestion successful!", result);

            // Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
            var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
            Assert.Equal(fatherId, survivor.GerbilId);   // loose id preserved even though the gerbil row was deleted/recreated
            Assert.Equal(litterId, survivor.LitterId);
            Assert.Equal("Papa", survivor.EntityName);
            Assert.Equal("Answered", survivor.Status);   // ticket status column survives the wipe
            // The new question/answer columns survive the wipe too.
            Assert.Equal("Welches Datum stimmt?", survivor.Question);
            Assert.Equal("Der 2. Juni.", survivor.Answer);
            Assert.NotNull(survivor.AnsweredAt);
            // The new fix-note / agent-context / thread columns survive the wipe too.
            Assert.Equal("Das Geburtsdatum von Papa wurde korrigiert.", survivor.FixNote);
            Assert.Equal("{\"plan\":\"awaiting DOB confirmation\"}", survivor.AgentContext);
            Assert.Contains("Erste Frage?", survivor.Thread);

            // The contact-scoped report also survives the contacts wipe (loose ContactId).
            var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
            Assert.Equal(contactId, contactSurvivor.ContactId);
            Assert.Equal("contact-detail", contactSurvivor.Context);
            Assert.Equal("Test Breeder", contactSurvivor.EntityName);
            Assert.Equal(2, await db.Feedback.CountAsync());
        }
        finally
        {
            try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
        }
    }

    private static object Animal(Guid id, string name, string gender, Guid contactId) => new
    {
        Id = id,
        Name = name,
        Gender = gender,
        Status = "Breeding",
        LitterId = (Guid?)null,
        OriginContactId = contactId,
        ReceiverContactId = (Guid?)null,
        EnclosureId = (Guid?)null,
        ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
        DateOfBirth = "2025-01-01",
        DateOfDeath = (string?)null,
        CauseOfDeath = (string?)null,
        GoHomeDate = (string?)null,
        Genotype = "aa CC DD EE GG PP spsp rere",
        Notes = "",
        ImportSource = "docx-export",
        ExternalRef = "ext-" + name,
        RawImportData = "{}",
        OriginBreeder = "Test Zucht",
        NameSearch = name.ToLowerInvariant(),
        CharacterTraits = Array.Empty<string>(),
        CharacterNote = (string?)null,
        IsDeaf = false,
        IsResident = true,
    };
}
