package maintenance

import (
	"bytes"
	"database/sql"
	"errors"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"crypto-miner-server/internal/db"
	"crypto-miner-server/internal/models"
)

func openTestDB(t *testing.T) *db.Database {
	t.Helper()
	d, err := db.New(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = d.Close() })
	return d
}

func insertBuild(t *testing.T, d *db.Database, b *models.BuildRecord) {
	t.Helper()
	if err := d.InsertBuild(b); err != nil {
		t.Fatal(err)
	}
}

func seedHashrateSample(t *testing.T, d *db.Database, agentID string, ts time.Time, hashrate float64) {
	t.Helper()
	_, err := d.Exec("INSERT INTO hashrate_samples (agent_id, hashrate, timestamp) VALUES (?, ?, ?)",
		agentID, hashrate, ts)
	if err != nil {
		t.Fatal(err)
	}
}

func TestStartRetentionJobs_NoOpWhenDisabled(t *testing.T) {
	d := openTestDB(t)
	t.Cleanup(StopRetentionJobs)
	StartRetentionJobs(d, t.TempDir(), 0, 0)
	// Disabled config must not start a goroutine that mutates data.
	time.Sleep(20 * time.Millisecond)
}

func TestStartRetentionJobs_RunsImmediately(t *testing.T) {
	d := openTestDB(t)
	seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)

	t.Cleanup(StopRetentionJobs)
	StartRetentionJobs(d, t.TempDir(), 24, 0)
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
		if err != nil {
			t.Fatal(err)
		}
		if n == 0 {
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	t.Fatal("expected immediate retention pass to purge old hashrate samples")
}

func TestStartRetentionJobs_TickerInterval(t *testing.T) {
	prev := retentionTickInterval
	retentionTickInterval = 40 * time.Millisecond
	t.Cleanup(func() { retentionTickInterval = prev })

	d := openTestDB(t)
	var passes int32
	noopRetention := func(database *db.Database, dataDir string, statsHours, buildDays int) {
		atomic.AddInt32(&passes, 1)
	}
	runRetentionFn = noopRetention
	t.Cleanup(func() {
		retentionTickInterval = prev
		runRetentionFn = runRetention
		StopRetentionJobs()
	})

	StartRetentionJobs(d, t.TempDir(), 1, 0)

	deadline := time.Now().Add(250 * time.Millisecond)
	for time.Now().Before(deadline) {
		if atomic.LoadInt32(&passes) >= 2 {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("expected at least 2 retention passes (immediate + tick), got %d", passes)
}

func TestStopRetentionJobs_StopsBackgroundLoop(t *testing.T) {
	prev := retentionTickInterval
	retentionTickInterval = 40 * time.Millisecond
	t.Cleanup(func() {
		retentionTickInterval = prev
		runRetentionFn = runRetention
		StopRetentionJobs()
	})

	d := openTestDB(t)
	var passes int32
	runRetentionFn = func(database *db.Database, dataDir string, statsHours, buildDays int) {
		atomic.AddInt32(&passes, 1)
	}

	StartRetentionJobs(d, t.TempDir(), 1, 0)

	deadline := time.Now().Add(250 * time.Millisecond)
	for time.Now().Before(deadline) {
		if atomic.LoadInt32(&passes) >= 2 {
			break
		}
		time.Sleep(10 * time.Millisecond)
	}
	if atomic.LoadInt32(&passes) < 2 {
		t.Fatalf("expected at least 2 passes before stop, got %d", passes)
	}

	before := atomic.LoadInt32(&passes)
	StopRetentionJobs()
	time.Sleep(120 * time.Millisecond)
	if got := atomic.LoadInt32(&passes); got != before {
		t.Fatalf("expected no retention passes after stop, before=%d after=%d", before, got)
	}
}

func TestRunRetention_PurgesHashrateSamples(t *testing.T) {
	d := openTestDB(t)
	seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
	seedHashrateSample(t, d, "a1", time.Now(), 200)

	runRetention(d, t.TempDir(), 24, 0)

	n, err := d.PurgeHashrateSamplesBefore(time.Now().Add(-24 * time.Hour))
	if err != nil {
		t.Fatal(err)
	}
	if n != 0 {
		t.Fatalf("expected old sample already purged, PurgeHashrateSamplesBefore returned %d", n)
	}

	var remaining int
	if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
		t.Fatal(err)
	}
	if remaining != 1 {
		t.Fatalf("expected 1 recent sample left, got %d", remaining)
	}
}

func TestRunRetention_SkipsStatsWhenZero(t *testing.T) {
	d := openTestDB(t)
	seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)

	runRetention(d, t.TempDir(), 0, 0)

	var remaining int
	if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&remaining); err != nil {
		t.Fatal(err)
	}
	if remaining != 1 {
		t.Fatalf("expected sample retained when statsHours=0, got %d", remaining)
	}
}

func TestRunRetention_PurgesBuildWithFilePath(t *testing.T) {
	d := openTestDB(t)
	dataDir := t.TempDir()
	artifactDir := filepath.Join(dataDir, "artifacts", "old-build")
	if err := os.MkdirAll(artifactDir, 0o755); err != nil {
		t.Fatal(err)
	}
	artifactFile := filepath.Join(artifactDir, "agent.exe")
	if err := os.WriteFile(artifactFile, []byte("binary"), 0o644); err != nil {
		t.Fatal(err)
	}

	insertBuild(t, d, &models.BuildRecord{
		ID:         "old-build",
		WorkerName: "worker-1",
		ServerURL:  "http://localhost",
		Wallet:     "wallet",
		FilePath:   artifactFile,
		CreatedAt:  time.Now().Add(-48 * time.Hour),
	})
	insertBuild(t, d, &models.BuildRecord{
		ID:         "new-build",
		WorkerName: "worker-2",
		ServerURL:  "http://localhost",
		Wallet:     "wallet",
		FilePath:   filepath.Join(dataDir, "artifacts", "new-build", "agent.exe"),
		CreatedAt:  time.Now(),
	})

	runRetention(d, dataDir, 0, 1)

	if _, err := os.Stat(artifactDir); !os.IsNotExist(err) {
		t.Fatalf("expected artifact dir removed, stat err=%v", err)
	}
	_, err := d.GetBuild("old-build")
	if !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("expected old build deleted from db, got %v", err)
	}
	if _, err := d.GetBuild("new-build"); err != nil {
		t.Fatalf("expected new build retained: %v", err)
	}
}

func TestRunRetention_PurgesBuildWithoutFilePath(t *testing.T) {
	d := openTestDB(t)
	dataDir := t.TempDir()
	buildDir := filepath.Join(dataDir, "builds", "legacy-build")
	if err := os.MkdirAll(buildDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(buildDir, "bundle.zip"), []byte("zip"), 0o644); err != nil {
		t.Fatal(err)
	}

	insertBuild(t, d, &models.BuildRecord{
		ID:         "legacy-build",
		WorkerName: "worker-legacy",
		ServerURL:  "http://localhost",
		Wallet:     "wallet",
		CreatedAt:  time.Now().Add(-72 * time.Hour),
	})

	runRetention(d, dataDir, 0, 1)

	if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
		t.Fatalf("expected fallback build dir removed, stat err=%v", err)
	}
	_, err := d.GetBuild("legacy-build")
	if !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("expected legacy build deleted, got %v", err)
	}
}

func TestRunRetention_SkipsBuildsWhenZero(t *testing.T) {
	d := openTestDB(t)
	dataDir := t.TempDir()
	buildDir := filepath.Join(dataDir, "builds", "keep-me")
	if err := os.MkdirAll(buildDir, 0o755); err != nil {
		t.Fatal(err)
	}
	insertBuild(t, d, &models.BuildRecord{
		ID:         "keep-me",
		WorkerName: "worker",
		ServerURL:  "http://localhost",
		Wallet:     "wallet",
		CreatedAt:  time.Now().Add(-72 * time.Hour),
	})

	runRetention(d, dataDir, 0, 0)

	if _, err := os.Stat(buildDir); err != nil {
		t.Fatalf("expected build dir kept when buildDays=0: %v", err)
	}
	if _, err := d.GetBuild("keep-me"); err != nil {
		t.Fatalf("expected build record kept: %v", err)
	}
}

func TestRunRetention_HashratePurgeErrorLogged(t *testing.T) {
	d := openTestDB(t)
	_ = d.Close()

	var buf bytes.Buffer
	prev := log.Writer()
	log.SetOutput(&buf)
	t.Cleanup(func() { log.SetOutput(prev) })

	runRetention(d, t.TempDir(), 24, 0)

	if !strings.Contains(buf.String(), "[Retention] hashrate purge failed:") {
		t.Fatalf("expected hashrate purge error log, got: %q", buf.String())
	}
}

func TestRunRetention_BuildListErrorLogged(t *testing.T) {
	d := openTestDB(t)
	_ = d.Close()

	var buf bytes.Buffer
	prev := log.Writer()
	log.SetOutput(&buf)
	t.Cleanup(func() { log.SetOutput(prev) })

	runRetention(d, t.TempDir(), 0, 7)

	if !strings.Contains(buf.String(), "[Retention] build list failed:") {
		t.Fatalf("expected build list error log, got: %q", buf.String())
	}
}

func TestRunRetention_StatsAndBuildsTogether(t *testing.T) {
	d := openTestDB(t)
	dataDir := t.TempDir()
	seedHashrateSample(t, d, "a1", time.Now().Add(-48*time.Hour), 100)
	buildDir := filepath.Join(dataDir, "builds", "combo-old")
	if err := os.MkdirAll(buildDir, 0o755); err != nil {
		t.Fatal(err)
	}
	insertBuild(t, d, &models.BuildRecord{
		ID:         "combo-old",
		WorkerName: "worker",
		ServerURL:  "http://localhost",
		Wallet:     "wallet",
		CreatedAt:  time.Now().Add(-48 * time.Hour),
	})

	runRetention(d, dataDir, 24, 1)

	var samples int
	if err := d.QueryRow("SELECT COUNT(*) FROM hashrate_samples").Scan(&samples); err != nil {
		t.Fatal(err)
	}
	if samples != 0 {
		t.Fatalf("expected stats purged, got %d samples", samples)
	}
	if _, err := os.Stat(buildDir); !os.IsNotExist(err) {
		t.Fatalf("expected build dir removed, stat err=%v", err)
	}
	_, err := d.GetBuild("combo-old")
	if !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("expected build removed, got %v", err)
	}
}

func TestRetentionTickIntervalDefault(t *testing.T) {
	if retentionTickInterval != 6*time.Hour {
		t.Fatalf("expected default tick interval 6h, got %v", retentionTickInterval)
	}
}
