using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Diplomas;
using BigRegister.Domain.Documents;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
    c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" }));
builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(o =>
{
    o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});

const string SpaCors = "spa";
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
    p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(SpaCors);

// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII.
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" }));

// Versioned prefix: additive changes (new fields) stay on v1 — the generated client
// + FE parse* boundary absorb them; a breaking change introduces /api/v2 alongside.
var api = app.MapGroup("/api/v1");

// --- GET: screen-shaped reads. Decisions are computed here, never on the client. ---

api.MapGet("/dashboard-view", () =>
{
    var reg = SeedData.Registration;
    var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
    return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
        new HerregistratieDecisionsDto(eligible, reason));
});

api.MapGet("/notes", () =>
    SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList());

// BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded
// citizen has one.
api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto()));

api.MapGet("/duo/diplomas", () => new DuoLookupDto(
    SeedData.Diplomas.Select(d => d.ToDto()).ToList(),
    new ManualDiplomaPolicyDto(
        DiplomaRules.ManualProfessions(),
        DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList())));

api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold));

// --- POST: submits. The server is the authority; it re-validates and decides. ---

api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
    Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);

api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) =>
    Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);

api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) =>
    Submit(ctx, "intake", SubmissionRules.RejectZeroUren(req.Uren)))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);

api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
    Submit(ctx, "adreswijziging", SubmissionRules.RejectChangeRequest(req.Straat, req.Postcode)))
.Produces<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);

// --- Document upload ---

// Server-owned category config per wizard. The FE renders these; it never hardcodes.
api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
    new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList()));

// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
// and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request) =>
{
    if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
    var form = await request.ReadFormAsync();
    var file = form.Files.GetFile("file");
    string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
    if (file is null || categoryId == "" || localId == "" || wizardId == "")
        return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);

    var category = DocumentRules.Find(wizardId, categoryId);
    var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
    if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);

    using var ms = new MemoryStream();
    await file.CopyToAsync(ms);
    var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
    return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
})
.ExcludeFromDescription();

// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
// for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
{
    var doc = DocumentStore.Get(documentId);
    if (doc is null) return Results.NotFound();
    var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
    return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
})
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

// Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) =>
{
    var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
    var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
    var results = ids.Select(id => found.TryGetValue(id, out var d)
        ? new UploadStatusItemDto(id, "complete", d.DocumentId)
        : new UploadStatusItemDto(id, "unknown", null)).ToList();
    return new UploadStatusDto(results);
});

// User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId) =>
    DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
    {
        DocumentStore.DeleteResult.Ok => Results.NoContent(),
        DocumentStore.DeleteResult.Linked => Results.Problem(
            detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
            statusCode: StatusCodes.Status409Conflict),
        _ => Results.NotFound(),
    })
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);

// Admin delete (seam): a real system requires an admin role; here an X-Admin header
// stands in. Bypasses ownership, unlinks, and flags the submission for review.
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) =>
    !IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden)
        : DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);

// --- Applications (aanvragen): the system of record the dashboard reads. ---

api.MapGet("/applications", () =>
{
    var now = DateTimeOffset.UtcNow;
    return ApplicationStore.List(DocumentStore.DemoOwner)
        .OrderByDescending(a => a.UpdatedAt)
        .Select(a => a.ToSummaryDto(now)).ToList();
});

api.MapGet("/applications/{id}", (string id) =>
    ApplicationStore.Get(id, DocumentStore.DemoOwner) is { } a
        ? Results.Ok(a.ToDetailDto(DateTimeOffset.UtcNow))
        : Results.NotFound())
.Produces<ApplicationDetailDto>()
.Produces(StatusCodes.Status404NotFound);

api.MapPost("/applications", (CreateApplicationRequest req) =>
{
    var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
    return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
})
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created);

// Draft sync per step — idempotent; keep it debounced on the client (it is chatty).
api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
    ApplicationStore.SyncDraft(id, DocumentStore.DemoOwner, req.Draft, req.StepIndex, req.StepCount, req.DocumentIds)
        ? Results.NoContent() : Results.NotFound())
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);

// Cancel a Concept (cascades to its unlinked documents). Submitted aanvragen cannot
// be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id) =>
{
    var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
    if (a is null) return Results.NotFound();
    if (a.Submitted)
        return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
    ApplicationStore.Delete(id, DocumentStore.DemoOwner);
    return Results.NoContent();
})
.Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);

// Submit runs the server-owned rules, sets autoApprovable, and transitions the
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
{
    var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
    if (existing is null) return Results.NotFound();
    if (existing.Submitted)
        return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);

    // Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
    (string? reject, bool autoApprovable) = existing.Type switch
    {
        "registratie" => (null, req.DiplomaHerkomst == "duo"),
        _ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
    };

    var docs = req.Documents;
    if (docs is not null)
        DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
    var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();

    var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
    if (submitted is null) return Results.Conflict();

    app.Logger.LogInformation(
        "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
        id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
    return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
})
.Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound);

// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. ---

api.MapGet("/brief", () =>
{
    var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
    return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
})
.Produces<BriefViewDto>();

api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
    var (_, isDrafter) = BriefRole(ctx);
    return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);

api.MapPost("/brief/submit", (HttpContext ctx) =>
{
    var (_, isDrafter) = BriefRole(ctx);
    var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
    LogBrief("submit", r);
    return BriefResult(r, "Alleen de opsteller mag indienen.");
})
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);

api.MapPost("/brief/approve", (HttpContext ctx) =>
{
    var (acting, _) = BriefRole(ctx);
    var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
    LogBrief("approve", r);
    return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);

api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{
    var (acting, _) = BriefRole(ctx);
    var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
    LogBrief("reject", r);
    return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status409Conflict);

api.MapPost("/brief/send", () =>
{
    // Send-time placeholder linting is FE-authoritative in this slice (no C# parity
    // port); the backend only guards the approved→sent transition.
    var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
    LogBrief("send", r);
    return BriefResult(r, "Versturen kan niet in deze status.");
})
.Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status409Conflict);

api.MapPost("/brief/reset", () =>
{
    // Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
    var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
    return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
})
.WithName("briefReset")
.Produces<BriefViewDto>();

app.Run();

static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";

static string Now() => DateTimeOffset.UtcNow.ToString("o");

// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
// else (default) acts as the drafter.
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
{
    var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
    return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
}

IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
{
    BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
    BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
    _ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
};

void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
    app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
        action, r.outcome, r.entity?.Status.Tag ?? "-");

// Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store).
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{
    var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
        ? v.ToString()
        : "none";

    if (reject is not null)
    {
        app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
        return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
    }

    if (documents is not null)
    {
        // Link digital documents (blocks later user delete) and record post-delivery
        // intent so a caseworker knows to expect the physical document.
        DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
        foreach (var d in documents.Where(d => d.Channel == "post"))
            DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
    }

    var reference = SubmissionRules.NewReference();
    app.Logger.LogInformation(
        "submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
        kind, reference, cid, DateTimeOffset.UtcNow);
    return Results.Ok(new ReferentieResponse(reference));
}

// Exposed so the integration tests can spin up the app with WebApplicationFactory.
public partial class Program { }
