package finance

import (
	"context"
	"os"
	"strconv"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/shopspring/decimal"
)

type closeFixture struct {
	year          int16
	month         int16
	actorID       int64
	journalID     int64
	journalNumber string
}

func newCloseTestPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	url := os.Getenv("KM_ACCOUNTING_DISPOSABLE_TEST_DATABASE_URL")
	if url == "" {
		t.Skip("KM_ACCOUNTING_DISPOSABLE_TEST_DATABASE_URL is not set; concurrency tests require a disposable isolated database")
	}
	pool, err := pgxpool.New(context.Background(), url)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(pool.Close)
	return pool
}

func createCloseFixture(t *testing.T, pool *pgxpool.Pool) closeFixture {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	nonce := time.Now().UTC().UnixNano()
	year := int16(2000 + nonce%8000)
	month := int16((nonce / 8000 % 12) + 1)
	number := "CLOSE-CONC-" + strconv.FormatInt(nonce, 36)
	email := "close-concurrency-" + strconv.FormatInt(nonce, 36) + "@example.invalid"
	var f closeFixture
	f.year, f.month, f.journalNumber = year, month, number
	if err := pool.QueryRow(ctx, `INSERT INTO users(name,email,password,role) VALUES ('Close Concurrency Test',$1,'unused','admin') RETURNING id`, email).Scan(&f.actorID); err != nil {
		t.Fatal(err)
	}
	start := time.Date(int(year), time.Month(month), 1, 0, 0, 0, 0, time.UTC)
	entryDate := start.AddDate(0, 0, 14)
	if _, err := pool.Exec(ctx, `INSERT INTO accounting_periods(period_year,period_month,status) VALUES ($1,$2,'open')`, year, month); err != nil {
		_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, f.actorID)
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `INSERT INTO journal_entries(journal_number,journal_type,entry_date,status,total_debit,total_credit,created_by)
		VALUES ($1,'general',$2,'draft',10,10,$3) RETURNING id`, number, entryDate, f.actorID).Scan(&f.journalID); err != nil {
		_, _ = pool.Exec(ctx, `DELETE FROM accounting_periods WHERE period_year=$1 AND period_month=$2`, year, month)
		_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, f.actorID)
		t.Fatal(err)
	}
	var debitAccountID, creditAccountID int64
	if err := pool.QueryRow(ctx, `SELECT id FROM accounts WHERE code='1000.01.01'`).Scan(&debitAccountID); err != nil {
		t.Fatal(err)
	}
	if err := pool.QueryRow(ctx, `SELECT id FROM accounts WHERE code='4000.01'`).Scan(&creditAccountID); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(ctx, `INSERT INTO journal_lines(journal_entry_id,account_id,debit,credit) VALUES ($1,$2,$3,0),($1,$4,0,$3)`, f.journalID, debitAccountID, decimal.NewFromInt(10), creditAccountID); err != nil {
		t.Fatal(err)
	}
	return f
}

func waitForCloseLockWaiters(t *testing.T, watcher *pgxpool.Pool, target int) {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	for {
		var waiting int
		err := watcher.QueryRow(ctx, `SELECT count(*) FROM pg_stat_activity
			WHERE datname=current_database() AND wait_event_type='Lock'
			  AND query LIKE '%accounting_periods%FOR UPDATE%'`).Scan(&waiting)
		if err != nil {
			t.Fatal(err)
		}
		if waiting >= target {
			return
		}
		select {
		case <-ctx.Done():
			t.Fatalf("only %d close sessions waited on the period lock; want %d", waiting, target)
		case <-time.After(10 * time.Millisecond):
		}
	}
}

func TestJournalPostingAndCloseSerializeOnPeriodRow(t *testing.T) {
	postingPool := newCloseTestPool(t)
	closingPool := newCloseTestPool(t)
	watcher := newCloseTestPool(t)
	f := createCloseFixture(t, postingPool)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	postingTx, err := postingPool.Begin(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer postingTx.Rollback(context.Background())
	// The journal trigger takes this same FOR SHARE lock before applying the
	// draft -> posted update. Keep it through commit to model an in-flight post.
	var status string
	if err := postingTx.QueryRow(ctx, `SELECT status FROM accounting_periods WHERE period_year=$1 AND period_month=$2 FOR SHARE`, f.year, f.month).Scan(&status); err != nil {
		t.Fatal(err)
	}
	if status != "open" {
		t.Fatalf("period status before post = %q, want open", status)
	}
	if _, err := postingTx.Exec(ctx, `UPDATE journal_entries SET status='posted', posted_at=now(), posted_by=$2 WHERE id=$1`, f.journalID, f.actorID); err != nil {
		t.Fatal(err)
	}

	type closeResult struct {
		period AccountingPeriod
		err    error
	}
	started := make(chan struct{})
	result := make(chan closeResult, 1)
	go func() {
		close(started)
		p, e := NewRepository(closingPool).CloseAccountingPeriod(ctx, f.year, f.month, f.actorID)
		result <- closeResult{period: p, err: e}
	}()
	<-started
	waitForCloseLockWaiters(t, watcher, 1)
	select {
	case r := <-result:
		t.Fatalf("close completed while posting held the shared period lock: period=%+v err=%v", r.period, r.err)
	default:
	}
	if err := postingTx.Commit(ctx); err != nil {
		t.Fatal(err)
	}
	select {
	case r := <-result:
		if r.err != nil {
			t.Fatalf("close after journal commit: %v", r.err)
		}
		if r.period.Status != "locked" {
			t.Fatalf("close status = %q, want locked", r.period.Status)
		}
	case <-ctx.Done():
		t.Fatal("close did not finish after posting committed")
	}

	var journalStatus, periodStatus string
	if err := watcher.QueryRow(ctx, `SELECT status FROM journal_entries WHERE id=$1`, f.journalID).Scan(&journalStatus); err != nil {
		t.Fatal(err)
	}
	if err := watcher.QueryRow(ctx, `SELECT status FROM accounting_periods WHERE period_year=$1 AND period_month=$2`, f.year, f.month).Scan(&periodStatus); err != nil {
		t.Fatal(err)
	}
	var closeEvents int
	if err := watcher.QueryRow(ctx, `SELECT count(*) FROM accounting_period_logs WHERE period_year=$1 AND period_month=$2 AND action='close'`, f.year, f.month).Scan(&closeEvents); err != nil {
		t.Fatal(err)
	}
	if journalStatus != "posted" || periodStatus != "locked" || closeEvents != 1 {
		t.Fatalf("final state journal=%s period=%s close_events=%d", journalStatus, periodStatus, closeEvents)
	}
}

func TestConcurrentPeriodCloseSerializesAndAuditsOnce(t *testing.T) {
	firstPool := newCloseTestPool(t)
	secondPool := newCloseTestPool(t)
	watcher := newCloseTestPool(t)
	f := createCloseFixture(t, firstPool)
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if _, err := firstPool.Exec(ctx, `UPDATE journal_entries SET status='posted',posted_at=now(),posted_by=$2 WHERE id=$1`, f.journalID, f.actorID); err != nil {
		t.Fatal(err)
	}
	gateTx, err := firstPool.Begin(ctx)
	if err != nil {
		t.Fatal(err)
	}
	defer gateTx.Rollback(context.Background())
	var status string
	if err := gateTx.QueryRow(ctx, `SELECT status FROM accounting_periods WHERE period_year=$1 AND period_month=$2 FOR UPDATE`, f.year, f.month).Scan(&status); err != nil {
		t.Fatal(err)
	}
	if status != "open" {
		t.Fatalf("period status before close attempts = %q, want open", status)
	}

	type closeResult struct {
		period AccountingPeriod
		err    error
	}
	started := make(chan struct{}, 2)
	results := make(chan closeResult, 2)
	closePeriod := func(pool *pgxpool.Pool) {
		started <- struct{}{}
		p, err := NewRepository(pool).CloseAccountingPeriod(ctx, f.year, f.month, f.actorID)
		results <- closeResult{period: p, err: err}
	}
	go closePeriod(firstPool)
	go closePeriod(secondPool)
	<-started
	<-started
	waitForCloseLockWaiters(t, watcher, 2)
	if err := gateTx.Commit(ctx); err != nil {
		t.Fatal(err)
	}

	for range 2 {
		select {
		case r := <-results:
			if r.err != nil {
				t.Fatalf("concurrent close failed: %v", r.err)
			}
			if r.period.Status != "locked" {
				t.Fatalf("concurrent close returned %q, want locked", r.period.Status)
			}
		case <-ctx.Done():
			t.Fatal("concurrent close sessions did not complete")
		}
	}

	var finalStatus string
	var closeEvents int
	if err := watcher.QueryRow(ctx, `SELECT status FROM accounting_periods WHERE period_year=$1 AND period_month=$2`, f.year, f.month).Scan(&finalStatus); err != nil {
		t.Fatal(err)
	}
	if err := watcher.QueryRow(ctx, `SELECT count(*) FROM accounting_period_logs WHERE period_year=$1 AND period_month=$2 AND action='close'`, f.year, f.month).Scan(&closeEvents); err != nil {
		t.Fatal(err)
	}
	if finalStatus != "locked" || closeEvents != 1 {
		t.Fatalf("final state status=%s close_events=%d, want locked/1", finalStatus, closeEvents)
	}
}
