package store_test

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"testing"

	embeddedpostgres "github.com/fergusstrange/embedded-postgres"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/stretchr/testify/require"

	"git.d-ma.be/mathias/tapir/internal/adapters/store"
	"git.d-ma.be/mathias/tapir/internal/domain"
	"git.d-ma.be/mathias/tapir/internal/ports"
)

// Static check: Store satisfies the Sink port.
var _ ports.Sink = (*store.Store)(nil)

// dsn points at the in-process Postgres started in TestMain. Tests run against
// real SQL (constraints, ON CONFLICT, jsonb) — not a mock — without docker or
// live-cluster credentials (embedded-postgres downloads its own PG binary).
var dsn string

func TestMain(m *testing.M) {
	// Port + runtime/data dirs are per-process (PID-derived) so two concurrent
	// `go test` invocations — e.g. a push-run and a tag-run firing together in CI —
	// don't collide on a fixed port or a shared data dir (which silently failed
	// both runs). CachePath is shared so the PG archive is downloaded once, not
	// per process. Base 54000 keeps this package's range distinct from web's.
	port := uint32(54000 + os.Getpid()%1000)
	dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)

	rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-store-%d", os.Getpid()))
	pg := embeddedpostgres.NewDatabase(
		embeddedpostgres.DefaultConfig().
			Port(port).
			RuntimePath(rt).
			DataPath(filepath.Join(rt, "data")).
			BinariesPath(filepath.Join(rt, "bin")).
			CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")),
	)
	if err := pg.Start(); err != nil {
		fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
		os.Exit(1)
	}

	code := m.Run()

	if err := pg.Stop(); err != nil {
		fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err)
	}
	_ = os.RemoveAll(rt)
	os.Exit(code)
}

// uuids — fixed so tests are deterministic. user_id/video_id are UUID columns.
const (
	userA  = "11111111-1111-1111-1111-111111111111"
	userB  = "22222222-2222-2222-2222-222222222222"
	videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
	videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)

func newStore(t *testing.T) *store.Store {
	t.Helper()
	s, err := store.New(context.Background(), dsn)
	require.NoError(t, err, "migrations must apply clean and the pool must connect")
	t.Cleanup(s.Close)
	return s
}

// rawPool is a direct connection for test introspection (counts, truncation),
// kept out of the production Store API. Closed via t.Cleanup.
func rawPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	p, err := pgxpool.New(context.Background(), dsn)
	require.NoError(t, err)
	t.Cleanup(p.Close)
	return p
}

// resetDB truncates between tests so each starts from a known state. Schema is
// shared across the run (migrations are idempotent via New).
func resetDB(t *testing.T, p *pgxpool.Pool) {
	t.Helper()
	_, err := p.Exec(context.Background(),
		`TRUNCATE login_events, summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
	require.NoError(t, err)
}

func summary(userID, videoID, text string) domain.Summary {
	return domain.Summary{
		UserID:     userID,
		VideoID:    videoID,
		Summary:    text,
		Highlights: []string{"h1", "h2"},
		Takeaways:  []string{"t1"},
		AIProvider: "local",
		AIModel:    "qwen",
	}
}

func TestName(t *testing.T) {
	require.Equal(t, "store", newStore(t).Name())
}

func TestDeliverInsertsSummary(t *testing.T) {
	ctx := context.Background()
	s := newStore(t)
	resetDB(t, rawPool(t))

	require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "first")))

	ok, err := s.HasSummary(ctx, userA, videoX)
	require.NoError(t, err)
	require.True(t, ok)
}

func TestDeliverIsIdempotentOnUserVideo(t *testing.T) {
	ctx := context.Background()
	s := newStore(t)
	p := rawPool(t)
	resetDB(t, p)

	require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "first")))
	// Re-deliver the same (user, video): must update in place, not duplicate or error.
	require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "second")))

	var count int
	require.NoError(t, p.QueryRow(ctx,
		`SELECT count(*) FROM summaries WHERE user_id = $1 AND video_id = $2`,
		userA, videoX).Scan(&count))
	require.Equal(t, 1, count, "second delivery must update, not duplicate")

	var text string
	require.NoError(t, p.QueryRow(ctx,
		`SELECT summary FROM summaries WHERE user_id = $1 AND video_id = $2`,
		userA, videoX).Scan(&text))
	require.Equal(t, "second", text, "second delivery must overwrite the summary text")

	// Exactly one store-delivery row for the summary (ON CONFLICT update).
	var deliveries int
	require.NoError(t, p.QueryRow(ctx,
		`SELECT count(*) FROM sink_deliveries d
		   JOIN summaries m ON m.id = d.summary_id
		  WHERE m.user_id = $1 AND m.video_id = $2 AND d.sink = 'store'`,
		userA, videoX).Scan(&deliveries))
	require.Equal(t, 1, deliveries)
}

func TestSeenVideoIDsReturnsUsersSet(t *testing.T) {
	ctx := context.Background()
	s := newStore(t)
	resetDB(t, rawPool(t))

	require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "x")))
	require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "y")))

	seen, err := s.SeenVideoIDs(ctx, userA)
	require.NoError(t, err)
	require.Equal(t, map[string]bool{videoX: true, videoY: true}, seen)
}

func TestPerUserIsolation(t *testing.T) {
	ctx := context.Background()
	s := newStore(t)
	resetDB(t, rawPool(t))

	require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-owns-this")))

	// User B must not see user A's video, by either dedup read.
	seenB, err := s.SeenVideoIDs(ctx, userB)
	require.NoError(t, err)
	require.Empty(t, seenB, "user B must not see user A's videos")

	hasB, err := s.HasSummary(ctx, userB, videoX)
	require.NoError(t, err)
	require.False(t, hasB, "the same video_id under another user must be invisible")

	hasA, err := s.HasSummary(ctx, userA, videoX)
	require.NoError(t, err)
	require.True(t, hasA)
}
