package migration

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/bluesky-social/indigo/atproto/atclient"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/samber/lo"
	cbg "github.com/whyrusleeping/cbor-gen"
	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/models"
	"tangled.org/core/orm"
)

func TestBackfillRecordRoundTrip(t *testing.T) {
	createdAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
	for _, tc := range []struct {
		name           string
		subject        syntax.ATURI
		value          models.StateValue
		wantCollection string
		fromRecord     func(cbg.CBORMarshaler) (models.StateRecord, error)
	}{
		{
			name:           "issue closed",
			subject:        "at://did:plc:boltless/sh.tangled.repo.issue/issue1",
			value:          models.StateClosed,
			wantCollection: tangled.RepoIssueStateNSID,
			fromRecord: func(r cbg.CBORMarshaler) (models.StateRecord, error) {
				return models.IssueStateFromRecord("did:plc:akshay", "s1", *r.(*tangled.RepoIssueState))
			},
		},
		{
			name:           "pull merged",
			subject:        "at://did:plc:boltless/sh.tangled.repo.pull/pull1",
			value:          models.StateMerged,
			wantCollection: tangled.RepoPullStatusNSID,
			fromRecord: func(r cbg.CBORMarshaler) (models.StateRecord, error) {
				return models.PullStatusFromRecord("did:plc:akshay", "s1", *r.(*tangled.RepoPullStatus))
			},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			collection, record, err := backfillRecord(db.BackfillSubject{
				Subject:   tc.subject,
				Value:     tc.value,
				CreatedAt: createdAt.Format(time.RFC3339),
			}, createdAt)
			if err != nil {
				t.Fatalf("backfillRecord: %v", err)
			}
			if collection != tc.wantCollection {
				t.Fatalf("collection = %s, want %s", collection, tc.wantCollection)
			}
			rec, err := tc.fromRecord(record)
			if err != nil {
				t.Fatalf("fromRecord: %v", err)
			}
			if rec.Value != tc.value {
				t.Fatalf("round-trip value = %q, want %q", rec.Value, tc.value)
			}
			if rec.Subject != tc.subject {
				t.Fatalf("round-trip subject = %s, want %s", rec.Subject, tc.subject)
			}
			if rec.SortMicros != createdAt.UnixMicro() {
				t.Fatalf("round-trip sort micros = %d, want %d", rec.SortMicros, createdAt.UnixMicro())
			}
		})
	}
}

func TestBackfillRecordRejects(t *testing.T) {
	for _, tc := range []struct {
		name    string
		subject syntax.ATURI
		value   models.StateValue
	}{
		{"issue subject with merged value", "at://did:plc:boltless/sh.tangled.repo.issue/issue1", models.StateMerged},
		{"non issue or pull subject", "at://did:plc:boltless/sh.tangled.feed.star/x", models.StateClosed},
	} {
		t.Run(tc.name, func(t *testing.T) {
			if _, _, err := backfillRecord(db.BackfillSubject{
				Subject: tc.subject,
				Value:   tc.value,
			}, time.Unix(0, 0)); err == nil {
				t.Fatalf("backfillRecord must reject %s", tc.name)
			}
		})
	}
}

type capturedPut struct {
	Collection string          `json:"collection"`
	Repo       string          `json:"repo"`
	Rkey       string          `json:"rkey"`
	Record     json.RawMessage `json:"record"`
}

type putRecordStore struct {
	mu   sync.Mutex
	puts []capturedPut
}

func (s *putRecordStore) add(p capturedPut) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.puts = append(s.puts, p)
}

func (s *putRecordStore) count() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.puts)
}

func (s *putRecordStore) byRkey() map[string]capturedPut {
	s.mu.Lock()
	defer s.mu.Unlock()
	return lo.SliceToMap(s.puts, func(p capturedPut) (string, capturedPut) {
		return p.Rkey, p
	})
}

func discardLogger() *slog.Logger {
	return slog.New(slog.NewTextHandler(io.Discard, nil))
}

func newPutRecordServer(t *testing.T, failRkeys map[string]bool) (*putRecordStore, *atclient.APIClient) {
	t.Helper()
	store := &putRecordStore{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !strings.HasSuffix(r.URL.Path, "/com.atproto.repo.putRecord") {
			t.Errorf("unexpected request path: %s", r.URL.Path)
			http.Error(w, "not found", http.StatusNotFound)
			return
		}
		var in capturedPut
		if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if failRkeys[in.Rkey] {
			http.Error(w, `{"error":"InternalServerError","message":"boom"}`, http.StatusInternalServerError)
			return
		}
		store.add(in)
		_ = json.NewEncoder(w).Encode(map[string]string{
			"uri": "at://" + in.Repo + "/" + in.Collection + "/" + in.Rkey,
			"cid": "bafyreigxt-test",
		})
	}))
	t.Cleanup(srv.Close)
	return store, &atclient.APIClient{Host: srv.URL, Client: srv.Client()}
}

func seedRepoForOwner(t *testing.T, d *db.DB, owner, repoDid string) {
	t.Helper()
	tx, err := d.Begin()
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}
	if err := db.AddRepo(tx, &models.Repo{
		Did:     owner,
		Name:    "anemone",
		Knot:    "knot.example",
		Rkey:    "anemone",
		RepoDid: repoDid,
	}); err != nil {
		t.Fatalf("AddRepo: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}
}

func seedIssueRow(t *testing.T, d *db.DB, repoDid, author, rkey string) *models.Issue {
	t.Helper()
	tx, err := d.Begin()
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}
	issue := &models.Issue{
		Did:     author,
		Rkey:    rkey,
		RepoDid: syntax.DID(repoDid),
		Title:   "title",
		Body:    "body",
		Open:    true,
	}
	if err := db.PutIssue(tx, issue); err != nil {
		t.Fatalf("PutIssue: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}
	return issue
}

func seedPullRow(t *testing.T, d *db.DB, repoDid, author, rkey string) *models.Pull {
	t.Helper()
	tx, err := d.Begin()
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}
	pull := &models.Pull{
		RepoDid:      syntax.DID(repoDid),
		OwnerDid:     syntax.DID(author),
		Rkey:         syntax.RecordKey(rkey),
		Title:        "title",
		Body:         "body",
		TargetBranch: "main",
		State:        models.PullOpen,
	}
	if err := db.PutPull(t.Context(), tx, pull, nil); err != nil {
		t.Fatalf("PutPull: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("Commit: %v", err)
	}
	return pull
}

func parseIssueStatePut(did, rkey string, raw json.RawMessage) (models.StateRecord, error) {
	var rec tangled.RepoIssueState
	if err := json.Unmarshal(raw, &rec); err != nil {
		return models.StateRecord{}, err
	}
	return models.IssueStateFromRecord(did, rkey, rec)
}

func parsePullStatusPut(did, rkey string, raw json.RawMessage) (models.StateRecord, error) {
	var rec tangled.RepoPullStatus
	if err := json.Unmarshal(raw, &rec); err != nil {
		return models.StateRecord{}, err
	}
	return models.PullStatusFromRecord(did, rkey, rec)
}

func assertStatePut(
	t *testing.T,
	put capturedPut,
	owner syntax.DID,
	subject syntax.ATURI,
	wantCollection string,
	want models.StateValue,
	parse func(did, rkey string, raw json.RawMessage) (models.StateRecord, error),
) {
	t.Helper()
	if put.Collection != wantCollection {
		t.Fatalf("collection = %q, want %q", put.Collection, wantCollection)
	}
	if put.Repo != owner.String() {
		t.Fatalf("repo = %q, want owner %q", put.Repo, owner)
	}
	if put.Rkey != subject.RecordKey().String() {
		t.Fatalf("rkey = %q, want subject rkey %q", put.Rkey, subject.RecordKey())
	}
	sr, err := parse(owner.String(), put.Rkey, put.Record)
	if err != nil {
		t.Fatalf("parse state record: %v", err)
	}
	if sr.Subject != subject {
		t.Fatalf("subject = %s, want %s", sr.Subject, subject)
	}
	if sr.Value != want {
		t.Fatalf("value = %q, want %q", sr.Value, want)
	}
}

func TestBackfillEntityStateWritesRecords(t *testing.T) {
	d := newTestDB(t)
	owner := syntax.DID("did:plc:akshay")
	const repoDid = "did:plc:anemone"
	seedRepoForOwner(t, d, string(owner), repoDid)

	closedIssue := seedIssueRow(t, d, repoDid, "did:plc:boltless", "issueClosed")
	seedIssueRow(t, d, repoDid, "did:plc:boltless", "issueOpen")
	mergedPull := seedPullRow(t, d, repoDid, "did:plc:boltless", "pullMerged")
	closedPull := seedPullRow(t, d, repoDid, "did:plc:boltless", "pullClosed")
	seedPullRow(t, d, repoDid, "did:plc:boltless", "pullOpen")

	if err := db.CloseIssues(d, orm.FilterEq("at_uri", closedIssue.AtUri())); err != nil {
		t.Fatalf("CloseIssues: %v", err)
	}
	if err := db.MergePulls(d, orm.FilterEq("at_uri", mergedPull.AtUri())); err != nil {
		t.Fatalf("MergePulls: %v", err)
	}
	if err := db.ClosePulls(d, orm.FilterEq("at_uri", closedPull.AtUri())); err != nil {
		t.Fatalf("ClosePulls: %v", err)
	}

	store, client := newPutRecordServer(t, nil)
	client.AccountDID = &owner
	m := &Migration{db: d, logger: discardLogger()}

	if err := m.backfillEntityState(context.Background(), client, owner, ""); err != nil {
		t.Fatalf("backfillEntityState: %v", err)
	}

	got := store.byRkey()
	if len(got) != 3 {
		t.Fatalf("wrote %d records, want 3: %+v", len(got), got)
	}
	assertStatePut(t, got["issueClosed"], owner, closedIssue.AtUri(), tangled.RepoIssueStateNSID, models.StateClosed, parseIssueStatePut)
	assertStatePut(t, got["pullMerged"], owner, mergedPull.AtUri(), tangled.RepoPullStatusNSID, models.StateMerged, parsePullStatusPut)
	assertStatePut(t, got["pullClosed"], owner, closedPull.AtUri(), tangled.RepoPullStatusNSID, models.StateClosed, parsePullStatusPut)
}

func TestBackfillEntityStateSkipsCleanOwner(t *testing.T) {
	d := newTestDB(t)
	owner := syntax.DID("did:plc:akshay")
	const repoDid = "did:plc:anemone"
	seedRepoForOwner(t, d, string(owner), repoDid)
	seedIssueRow(t, d, repoDid, "did:plc:boltless", "issueOpen")

	store, client := newPutRecordServer(t, nil)
	m := &Migration{db: d, logger: discardLogger()}

	if err := m.backfillEntityState(context.Background(), client, owner, ""); err != nil {
		t.Fatalf("backfillEntityState: %v", err)
	}
	if store.count() != 0 {
		t.Fatalf("wrote %d records for an owner with no column-only closed state, want 0", store.count())
	}
}

func TestBackfillEntityStateCreatedAt(t *testing.T) {
	created := time.Date(2024, 3, 4, 5, 6, 7, 0, time.UTC)
	for _, tc := range []struct {
		name   string
		stored string
		want   time.Time
	}{
		{"uses subject created", created.Format(time.RFC3339), created},
		{"falls back to epoch on unparseable", "not-a-timestamp", time.Unix(0, 0).UTC()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			d := newTestDB(t)
			owner := syntax.DID("did:plc:akshay")
			const repoDid = "did:plc:anemone"
			seedRepoForOwner(t, d, string(owner), repoDid)
			issue := seedIssueRow(t, d, repoDid, "did:plc:boltless", "issueClosed")
			if err := db.CloseIssues(d, orm.FilterEq("at_uri", issue.AtUri())); err != nil {
				t.Fatalf("CloseIssues: %v", err)
			}
			if _, err := d.Exec(`update issues set created = ? where at_uri = ?`, tc.stored, issue.AtUri()); err != nil {
				t.Fatalf("update created: %v", err)
			}

			store, client := newPutRecordServer(t, nil)
			client.AccountDID = &owner
			m := &Migration{db: d, logger: discardLogger()}

			if err := m.backfillEntityState(context.Background(), client, owner, ""); err != nil {
				t.Fatalf("backfillEntityState: %v", err)
			}

			var rec tangled.RepoIssueState
			if err := json.Unmarshal(store.byRkey()["issueClosed"].Record, &rec); err != nil {
				t.Fatalf("unmarshal: %v", err)
			}
			want, err := models.AsIssueStateRecord(issue.AtUri(), models.StateClosed, tc.want)
			if err != nil {
				t.Fatalf("AsIssueStateRecord: %v", err)
			}
			if rec.CreatedAt != want.CreatedAt {
				t.Fatalf("createdAt = %q, want %q", rec.CreatedAt, want.CreatedAt)
			}
		})
	}
}

func TestBackfillEntityStatePartialFailureReturnsError(t *testing.T) {
	d := newTestDB(t)
	owner := syntax.DID("did:plc:akshay")
	const repoDid = "did:plc:anemone"
	seedRepoForOwner(t, d, string(owner), repoDid)
	good := seedIssueRow(t, d, repoDid, "did:plc:boltless", "issueGood")
	bad := seedIssueRow(t, d, repoDid, "did:plc:boltless", "issueBad")
	if err := db.CloseIssues(d, orm.FilterEq("at_uri", good.AtUri())); err != nil {
		t.Fatalf("CloseIssues good: %v", err)
	}
	if err := db.CloseIssues(d, orm.FilterEq("at_uri", bad.AtUri())); err != nil {
		t.Fatalf("CloseIssues bad: %v", err)
	}

	store, client := newPutRecordServer(t, map[string]bool{"issueBad": true})
	client.AccountDID = &owner
	m := &Migration{db: d, logger: discardLogger()}

	if err := m.backfillEntityState(context.Background(), client, owner, ""); err == nil {
		t.Fatal("backfillEntityState must return an error when a subject write fails")
	}

	got := store.byRkey()
	if _, ok := got["issueGood"]; !ok {
		t.Fatal("the succeeding subject must still be written when another fails")
	}
	if _, ok := got["issueBad"]; ok {
		t.Fatal("the failing subject must not be recorded as written")
	}
}
