package channelserver

import (
	"fmt"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

	"erupe-ce/server/channelserver/compression/nullcomp"
	"erupe-ce/server/migrations"
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
	"go.uber.org/zap"
)

var (
	testDBOnce        sync.Once
	testDB            *sqlx.DB
	testDBSetupFailed bool
)

// TestDBConfig holds the configuration for the test database
type TestDBConfig struct {
	Host     string
	Port     string
	User     string
	Password string
	DBName   string
}

// DefaultTestDBConfig returns the default test database configuration
// that matches docker-compose.test.yml
func DefaultTestDBConfig() *TestDBConfig {
	return &TestDBConfig{
		Host:     getEnv("TEST_DB_HOST", "localhost"),
		Port:     getEnv("TEST_DB_PORT", "5433"),
		User:     getEnv("TEST_DB_USER", "test"),
		Password: getEnv("TEST_DB_PASSWORD", "test"),
		DBName:   getEnv("TEST_DB_NAME", "erupe_test"),
	}
}

func getEnv(key, defaultValue string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return defaultValue
}

// SetupTestDB creates a connection to the test database and applies the schema.
// The schema is applied only once per test binary via sync.Once. Subsequent calls
// only TRUNCATE data for test isolation, avoiding expensive pg_restore + patch cycles.
func SetupTestDB(t *testing.T) *sqlx.DB {
	t.Helper()

	testDBOnce.Do(func() {
		config := DefaultTestDBConfig()
		connStr := fmt.Sprintf(
			"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
			config.Host, config.Port, config.User, config.Password, config.DBName,
		)

		db, err := sqlx.Open("postgres", connStr)
		if err != nil {
			testDBSetupFailed = true
			return
		}

		if err := db.Ping(); err != nil {
			_ = db.Close()
			testDBSetupFailed = true
			return
		}

		// Clean the database and apply schema once
		CleanTestDB(t, db)
		ApplyTestSchema(t, db)

		testDB = db
	})

	if testDBSetupFailed || testDB == nil {
		t.Skipf("Test database not available. Run: docker compose -f docker/docker-compose.test.yml up -d")
		return nil
	}

	// Truncate all data for test isolation (schema stays intact)
	truncateAllTables(t, testDB)

	return testDB
}

// CleanTestDB drops all objects in the public schema to ensure a clean state
func CleanTestDB(t *testing.T, db *sqlx.DB) {
	t.Helper()

	// Drop and recreate the public schema to remove all objects (tables, types, sequences, etc.)
	_, err := db.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`)
	if err != nil {
		t.Logf("Warning: Failed to clean database: %v", err)
	}
}

// ApplyTestSchema applies the database schema using the embedded migration system.
func ApplyTestSchema(t *testing.T, db *sqlx.DB) {
	t.Helper()

	logger, _ := zap.NewDevelopment()
	_, err := migrations.Migrate(db, logger.Named("test-migrations"))
	if err != nil {
		t.Fatalf("Failed to apply schema migrations: %v", err)
	}
}

// truncateAllTables truncates all tables in the public schema for test isolation.
// It retries on deadlock, which can occur when a previous test's goroutines still
// hold connections with in-flight DB operations.
func truncateAllTables(t *testing.T, db *sqlx.DB) {
	t.Helper()

	rows, err := db.Query("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
	if err != nil {
		t.Fatalf("Failed to list tables for truncation: %v", err)
	}
	defer func() { _ = rows.Close() }()

	var tables []string
	for rows.Next() {
		var name string
		if err := rows.Scan(&name); err != nil {
			t.Fatalf("Failed to scan table name: %v", err)
		}
		tables = append(tables, name)
	}

	if len(tables) == 0 {
		return
	}

	stmt := "TRUNCATE " + strings.Join(tables, ", ") + " CASCADE"
	const maxRetries = 3
	for attempt := 1; attempt <= maxRetries; attempt++ {
		_, err := db.Exec(stmt)
		if err == nil {
			return
		}
		if attempt < maxRetries {
			time.Sleep(50 * time.Millisecond)
			continue
		}
		t.Fatalf("Failed to truncate tables after %d attempts: %v", maxRetries, err)
	}
}

// TeardownTestDB is a no-op. The shared DB connection is reused across tests
// and closed automatically at process exit.
func TeardownTestDB(t *testing.T, db *sqlx.DB) {
	t.Helper()
}

// CreateTestUser creates a test user and returns the user ID
func CreateTestUser(t *testing.T, db *sqlx.DB, username string) uint32 {
	t.Helper()

	var userID uint32
	err := db.QueryRow(`
		INSERT INTO users (username, password, rights)
		VALUES ($1, 'test_password_hash', 0)
		RETURNING id
	`, username).Scan(&userID)

	if err != nil {
		t.Fatalf("Failed to create test user: %v", err)
	}

	return userID
}

// CreateTestCharacter creates a test character and returns the character ID
func CreateTestCharacter(t *testing.T, db *sqlx.DB, userID uint32, name string) uint32 {
	t.Helper()

	// Create minimal valid savedata (needs to be large enough for the game to parse)
	// The name is at offset 88, and various game mode pointers extend up to ~147KB for ZZ mode
	// We need at least 150KB to accommodate all possible pointer offsets
	saveData := make([]byte, 150000)                // Large enough for all game modes
	copy(saveData[88:], append([]byte(name), 0x00)) // Name at offset 88 with null terminator

	// Import the nullcomp package for compression
	compressed, err := nullcomp.Compress(saveData)
	if err != nil {
		t.Fatalf("Failed to compress savedata: %v", err)
	}

	var charID uint32
	err = db.QueryRow(`
		INSERT INTO characters (user_id, is_female, is_new_character, name, unk_desc_string, gr, hr, weapon_type, last_login, savedata, decomyset, savemercenary)
		VALUES ($1, false, false, $2, '', 0, 0, 0, 0, $3, '', '')
		RETURNING id
	`, userID, name, compressed).Scan(&charID)

	if err != nil {
		t.Fatalf("Failed to create test character: %v", err)
	}

	return charID
}

// CreateTestGuild creates a test guild with the given leader and returns the guild ID
func CreateTestGuild(t *testing.T, db *sqlx.DB, leaderCharID uint32, name string) uint32 {
	t.Helper()

	tx, err := db.Begin()
	if err != nil {
		t.Fatalf("Failed to begin transaction: %v", err)
	}

	var guildID uint32
	err = tx.QueryRow(
		"INSERT INTO guilds (name, leader_id) VALUES ($1, $2) RETURNING id",
		name, leaderCharID,
	).Scan(&guildID)
	if err != nil {
		_ = tx.Rollback()
		t.Fatalf("Failed to create test guild: %v", err)
	}

	_, err = tx.Exec(
		"INSERT INTO guild_characters (guild_id, character_id) VALUES ($1, $2)",
		guildID, leaderCharID,
	)
	if err != nil {
		_ = tx.Rollback()
		t.Fatalf("Failed to add leader to guild: %v", err)
	}

	if err := tx.Commit(); err != nil {
		t.Fatalf("Failed to commit guild creation: %v", err)
	}

	return guildID
}

// CreateTestSignSession creates a sign session and returns the session ID.
func CreateTestSignSession(t *testing.T, db *sqlx.DB, userID uint32, token string) uint32 {
	t.Helper()

	var id uint32
	err := db.QueryRow(
		`INSERT INTO sign_sessions (user_id, token) VALUES ($1, $2) RETURNING id`,
		userID, token,
	).Scan(&id)
	if err != nil {
		t.Fatalf("Failed to create test sign session: %v", err)
	}
	return id
}

// CreateTestServer creates a server entry for testing.
func CreateTestServer(t *testing.T, db *sqlx.DB, serverID uint16) {
	t.Helper()

	_, err := db.Exec(
		`INSERT INTO servers (server_id, current_players) VALUES ($1, 0)`,
		serverID,
	)
	if err != nil {
		t.Fatalf("Failed to create test server: %v", err)
	}
}

// CreateTestUserBinary creates a user_binary row for the given character ID.
func CreateTestUserBinary(t *testing.T, db *sqlx.DB, charID uint32) {
	t.Helper()

	_, err := db.Exec(`INSERT INTO user_binary (id) VALUES ($1)`, charID)
	if err != nil {
		t.Fatalf("Failed to create test user_binary: %v", err)
	}
}

// CreateTestGachaShop creates a gacha shop entry and returns its ID.
func CreateTestGachaShop(t *testing.T, db *sqlx.DB, name string, gachaType int) uint32 {
	t.Helper()

	var id uint32
	err := db.QueryRow(
		`INSERT INTO gacha_shop (name, gacha_type, min_gr, min_hr, url_banner, url_feature, url_thumbnail, wide, recommended, hidden)
		VALUES ($1, $2, 0, 0, '', '', '', false, false, false) RETURNING id`,
		name, gachaType,
	).Scan(&id)
	if err != nil {
		t.Fatalf("Failed to create test gacha shop: %v", err)
	}
	return id
}

// CreateTestGachaEntry creates a gacha entry and returns its ID.
func CreateTestGachaEntry(t *testing.T, db *sqlx.DB, gachaID uint32, entryType int, weight int) uint32 {
	t.Helper()

	var id uint32
	err := db.QueryRow(
		`INSERT INTO gacha_entries (gacha_id, entry_type, weight, rarity, item_type, item_number, item_quantity, rolls, frontier_points, daily_limit)
		VALUES ($1, $2, $3, 1, 0, 0, 0, 1, 0, 0) RETURNING id`,
		gachaID, entryType, weight,
	).Scan(&id)
	if err != nil {
		t.Fatalf("Failed to create test gacha entry: %v", err)
	}
	return id
}

// CreateTestGachaItem creates a gacha item for an entry.
func CreateTestGachaItem(t *testing.T, db *sqlx.DB, entryID uint32, itemType uint8, itemID uint16, quantity uint16) {
	t.Helper()

	_, err := db.Exec(
		`INSERT INTO gacha_items (entry_id, item_type, item_id, quantity) VALUES ($1, $2, $3, $4)`,
		entryID, itemType, itemID, quantity,
	)
	if err != nil {
		t.Fatalf("Failed to create test gacha item: %v", err)
	}
}

// SetTestDB assigns a database to a Server and initializes all repositories.
// Use this in integration tests instead of setting s.server.db directly.
func SetTestDB(s *Server, db *sqlx.DB) {
	s.db = db
	s.charRepo = NewCharacterRepository(db)
	s.guildRepo = NewGuildRepository(db)
	s.userRepo = NewUserRepository(db)
	s.gachaRepo = NewGachaRepository(db, nil)
	s.houseRepo = NewHouseRepository(db)
	s.festaRepo = NewFestaRepository(db)
	s.towerRepo = NewTowerRepository(db)
	s.rengokuRepo = NewRengokuRepository(db)
	s.mailRepo = NewMailRepository(db)
	s.stampRepo = NewStampRepository(db)
	s.distRepo = NewDistributionRepository(db)
	s.sessionRepo = NewSessionRepository(db)
	s.eventRepo = NewEventRepository(db)
	s.achievementRepo = NewAchievementRepository(db)
	s.shopRepo = NewShopRepository(db)
	s.cafeRepo = NewCafeRepository(db)
	s.goocooRepo = NewGoocooRepository(db)
	s.divaRepo = NewDivaRepository(db)
	s.miscRepo = NewMiscRepository(db)
	s.scenarioRepo = NewScenarioRepository(db)
	s.mercenaryRepo = NewMercenaryRepository(db)
}
