package appview

import (
	"bytes"
	"encoding/json"
	"log/slog"
	"path/filepath"
	"strings"
	"testing"
	"time"

	jmodels "github.com/bluesky-social/jetstream/pkg/models"
	"github.com/ipfs/go-cid"
	"github.com/multiformats/go-multihash"
	cbg "github.com/whyrusleeping/cbor-gen"
	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/validator"
	"tangled.org/core/orm"
)

func newStringIngester(t *testing.T) *Ingester {
	t.Helper()
	path := filepath.Join(t.TempDir(), "test.db")
	d, err := db.Make(t.Context(), path)
	if err != nil {
		t.Fatalf("db.Make: %v", err)
	}
	t.Cleanup(func() { d.Close() })
	return &Ingester{
		Db:        d,
		Logger:    slog.New(slog.DiscardHandler),
		Validator: &validator.Validator{},
	}
}

func makeStringEvent(t *testing.T, op, did, rkey string, record tangled.String) *jmodels.Event {
	t.Helper()
	raw, err := json.Marshal(record)
	if err != nil {
		t.Fatalf("marshal record: %v", err)
	}
	return &jmodels.Event{
		Did:  did,
		Kind: jmodels.EventKindCommit,
		Commit: &jmodels.Commit{
			Operation:  op,
			Collection: tangled.StringNSID,
			RKey:       rkey,
			Record:     raw,
			CID:        makeCID(t, &record),
		},
	}
}

func makeCID(t *testing.T, record cbg.CBORMarshaler) string {
	buf := new(bytes.Buffer)
	if err := record.MarshalCBOR(buf); err != nil {
		t.Fatalf("cbor marshal record: %v", err)
	}
	c, err := cid.NewPrefixV1(cid.DagCBOR, multihash.SHA2_256).Sum(buf.Bytes())
	if err != nil {
		t.Fatalf("cid: sum: %v", err)
	}
	return c.String()
}

func loadString(t *testing.T, ing *Ingester, did, rkey string) (models.String, bool) {
	t.Helper()
	rows, err := db.GetStrings(ing.Db, 0,
		orm.FilterEq("did", did),
		orm.FilterEq("rkey", rkey),
	)
	if err != nil {
		t.Fatalf("GetStrings: %v", err)
	}
	if len(rows) == 0 {
		return models.String{}, false
	}
	if len(rows) > 1 {
		t.Fatalf("expected at most one row for (%s,%s), got %d", did, rkey, len(rows))
	}
	return rows[0], true
}

func TestIngestString_CreateRoundTrip(t *testing.T) {
	ing := newStringIngester(t)
	created := time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC)

	e := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", tangled.String{
		Filename:    ptr("hello.txt"),
		Description: ptr("a greeting"),
		Contents:    ptr("hello world\n"),
		CreatedAt:   created.Format(time.RFC3339),
	})

	if err := ing.ingestString(t.Context(), e, ing.Logger); err != nil {
		t.Fatalf("ingestString: %v", err)
	}

	s, ok := loadString(t, ing, "did:plc:boltless", "rk1")
	if !ok {
		t.Fatal("row not inserted")
	}
	if s.FileName != "hello.txt" {
		t.Errorf("filename = %q, want hello.txt", s.FileName)
	}
	if s.Description == nil {
		t.Errorf("description = nil")
	}
	if *s.Description != "a greeting" {
		t.Errorf("description = %q", *s.Description)
	}
	if s.FileContent != "hello world\n" {
		t.Errorf("contents = %q", s.FileContent)
	}
	if !s.Created.Equal(created) {
		t.Errorf("created = %v, want %v (record CreatedAt must round-trip)", s.Created, created)
	}
	if s.Edited != nil {
		t.Errorf("edited = %v, want nil on create", s.Edited)
	}
}

func TestIngestString_UpdateBumpsEditedOnContentChange(t *testing.T) {
	ing := newStringIngester(t)
	created := time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC)
	base := tangled.String{
		Filename:    ptr("hello.txt"),
		Description: ptr("a greeting"),
		Contents:    ptr("hello world\n"),
		CreatedAt:   created.Format(time.RFC3339),
	}

	if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", base), ing.Logger); err != nil {
		t.Fatalf("ingestString create: %v", err)
	}

	updated := base
	updated.Contents = ptr("hello, world!\n")
	if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationUpdate, "did:plc:boltless", "rk1", updated), ing.Logger); err != nil {
		t.Fatalf("ingestString update: %v", err)
	}

	s, ok := loadString(t, ing, "did:plc:boltless", "rk1")
	if !ok {
		t.Fatal("row missing after update")
	}
	if s.FileContent != "hello, world!\n" {
		t.Errorf("contents = %q, want updated value", s.FileContent)
	}
	if !s.Created.Equal(created) {
		t.Errorf("update overwrote created: got %v, want %v", s.Created, created)
	}
	if s.Edited == nil {
		t.Fatal("edited not set after content change")
	}
}

func TestIngestString_UpdateNoChangeKeepsEditedNil(t *testing.T) {
	ing := newStringIngester(t)
	rec := tangled.String{
		Filename:    ptr("hello.txt"),
		Description: ptr("a greeting"),
		Contents:    ptr("hello world\n"),
		CreatedAt:   time.Date(2025, 9, 14, 10, 30, 0, 0, time.UTC).Format(time.RFC3339),
	}

	if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil {
		t.Fatalf("create: %v", err)
	}
	if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationUpdate, "did:plc:akshay", "rk2", rec), ing.Logger); err != nil {
		t.Fatalf("update: %v", err)
	}

	s, _ := loadString(t, ing, "did:plc:akshay", "rk2")
	if s.Edited != nil {
		t.Errorf("edited = %v, want nil when no field changed", s.Edited)
	}
}

func TestIngestString_DeleteRemovesRow(t *testing.T) {
	ing := newStringIngester(t)
	rec := tangled.String{
		Filename:  ptr("hello.txt"),
		Contents:  ptr("x"),
		CreatedAt: time.Now().UTC().Format(time.RFC3339),
	}
	if err := ing.ingestString(t.Context(), makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rk1", rec), ing.Logger); err != nil {
		t.Fatalf("create: %v", err)
	}

	del := &jmodels.Event{
		Did:  "did:plc:boltless",
		Kind: jmodels.EventKindCommit,
		Commit: &jmodels.Commit{
			Operation:  jmodels.CommitOperationDelete,
			Collection: tangled.StringNSID,
			RKey:       "rk1",
			CID:        makeCID(t, &rec),
		},
	}
	if err := ing.ingestString(t.Context(), del, ing.Logger); err != nil {
		t.Fatalf("delete: %v", err)
	}

	if _, ok := loadString(t, ing, "did:plc:boltless", "rk1"); ok {
		t.Fatal("row still present after delete")
	}
}

func TestIngestString_ValidatorRejects(t *testing.T) {
	ing := newStringIngester(t)
	now := time.Now().UTC().Format(time.RFC3339)

	cases := []struct {
		name string
		rec  tangled.String
	}{
		{"long filename", tangled.String{Filename: ptr(strings.Repeat("a", 141)), Contents: ptr("x"), CreatedAt: now}},
		{"long description", tangled.String{Filename: ptr("x"), Description: ptr(strings.Repeat("d", 281)), Contents: ptr("x"), CreatedAt: now}},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			e := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:akshay", "bad", tc.rec)
			if err := ing.ingestString(t.Context(), e, ing.Logger); err == nil {
				t.Fatal("expected validator error, got nil")
			}
			if _, ok := loadString(t, ing, "did:plc:akshay", "bad"); ok {
				t.Fatal("row inserted despite validator failure")
			}
		})
	}
}

func TestIngestString_ColdReplayPreservesCreated(t *testing.T) {
	created := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
	rec := tangled.String{
		Filename:    ptr("snippet.go"),
		Description: ptr("first cut"),
		Contents:    ptr("package main\n"),
		CreatedAt:   created.Format(time.RFC3339),
	}
	event := makeStringEvent(t, jmodels.CommitOperationCreate, "did:plc:boltless", "rkcold", rec)

	first := newStringIngester(t)
	if err := first.ingestString(t.Context(), event, first.Logger); err != nil {
		t.Fatalf("first ingest: %v", err)
	}
	live, _ := loadString(t, first, "did:plc:boltless", "rkcold")

	second := newStringIngester(t)
	if err := second.ingestString(t.Context(), event, second.Logger); err != nil {
		t.Fatalf("replay ingest: %v", err)
	}
	replayed, _ := loadString(t, second, "did:plc:boltless", "rkcold")

	if !live.Created.Equal(replayed.Created) {
		t.Fatalf("cold replay drifted: live=%v replayed=%v", live.Created, replayed.Created)
	}
	if !replayed.Created.Equal(created) {
		t.Fatalf("replay lost record CreatedAt: got %v, want %v", replayed.Created, created)
	}
}
