package externaladmin

import (
	"database/sql/driver"
	"errors"
	"strings"
	"testing"
	"time"

	"hyapp-admin-server/internal/cache"
	"hyapp-admin-server/internal/security"

	"github.com/DATA-DOG/go-sqlmock"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestLoginFailureLimitPersistsTimedLockAndUnifiedError(t *testing.T) {
	adminSQL, adminMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create admin sql mock: %v", err)
	}
	defer adminSQL.Close()
	adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
	if err != nil {
		t.Fatalf("create admin gorm db: %v", err)
	}
	userDB, userMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create user sql mock: %v", err)
	}
	defer userDB.Close()

	passwordHash, err := security.HashPassword("correct-password")
	if err != nil {
		t.Fatalf("hash fixture password: %v", err)
	}
	// Login resolves the tenant from the globally unique account name before it
	// touches the owner App database; no request App participates in either query.
	adminMock.ExpectQuery("SELECT `id`,`app_code` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?").
		WithArgs("operator", 1).
		WillReturnRows(sqlmock.NewRows([]string{"id", "app_code"}).AddRow(7, "fami"))
	userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps").
		WithArgs("fami").
		WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo"))
	adminMock.ExpectBegin()
	adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND username = \\?").
		WithArgs(uint64(7), "operator", 1).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
			"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
		}).AddRow(7, "fami", 9001, "operator", passwordHash, `[]`, "active", false, 0, 0, 1, 1, 1))
	adminMock.ExpectExec("UPDATE `external_admin_accounts` SET").
		WithArgs(uint(0), futureMillis{}, sqlmock.AnyArg(), uint64(7)).
		WillReturnResult(sqlmock.NewResult(0, 1))
	adminMock.ExpectExec("INSERT INTO `external_admin_login_logs`").
		WithArgs(sqlmock.AnyArg(), "fami", "operator", "127.0.0.1", "browser", "failed", "failure_limit_locked", sqlmock.AnyArg()).
		WillReturnResult(sqlmock.NewResult(1, 1))
	adminMock.ExpectCommit()

	service := NewService(adminDB, userDB, Config{MaxLoginFailures: 1, LockDuration: 10 * time.Minute})
	limiter := &recordingFixedWindowLimiter{decision: cache.FixedWindowDecision{Allowed: true}}
	service.limiter = limiter
	_, err = service.Login(t.Context(), LoginInput{
		Username: "Operator", Password: "wrong-password", IP: "127.0.0.1", UserAgent: "browser",
	})
	if !errors.Is(err, ErrInvalidCredentials) {
		t.Fatalf("login error = %v, want unified invalid credentials", err)
	}
	if err := adminMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("admin sql expectations: %v", err)
	}
	if err := userMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("user sql expectations: %v", err)
	}
	if limiter.calls != 2 || len(limiter.batches) != 2 || len(limiter.batches[0]) != 3 || len(limiter.batches[1]) != 1 {
		t.Fatalf("rate-limit batches = %+v", limiter.batches)
	}
	if !strings.Contains(limiter.batches[1][0].Key, ":app:") || limiter.batches[1][0].Limit != 120 {
		t.Fatalf("resolved App limiter = %+v", limiter.batches[1])
	}
	for _, rule := range limiter.batches[1] {
		if strings.Contains(rule.Key, ":system") || strings.Contains(rule.Key, ":ip:") || strings.Contains(rule.Key, ":identity:") {
			t.Fatalf("resolved App phase repeated a pre-resolution counter: %s", rule.Key)
		}
	}
}

func TestLoginWithoutAppCreatesSessionFromResolvedAccountTenant(t *testing.T) {
	adminSQL, adminMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create admin sql mock: %v", err)
	}
	defer adminSQL.Close()
	adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
	if err != nil {
		t.Fatalf("create admin gorm db: %v", err)
	}
	userDB, userMock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create user sql mock: %v", err)
	}
	defer userDB.Close()
	passwordHash, err := security.HashPassword("correct-password")
	if err != nil {
		t.Fatalf("hash fixture password: %v", err)
	}

	adminMock.ExpectQuery("SELECT `id`,`app_code` FROM `external_admin_accounts` WHERE username = \\? LIMIT \\?").
		WithArgs("operator", 1).
		WillReturnRows(sqlmock.NewRows([]string{"id", "app_code"}).AddRow(7, "fami"))
	userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps").
		WithArgs("fami").
		WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo"))
	adminMock.ExpectBegin()
	adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND username = \\?").
		WithArgs(uint64(7), "operator", 1).
		WillReturnRows(sqlmock.NewRows([]string{
			"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
			"password_change_required", "failed_login_count", "locked_until_ms", "last_login_at_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
		}).AddRow(7, "fami", 9001, "operator", passwordHash, `["bd:view"]`, "active", false, 0, 0, nil, 1, 1, 1))
	// The session insert is the persistence proof: AppCode comes from the account
	// projection even though LoginInput has no App field at all.
	adminMock.ExpectExec("INSERT INTO `external_admin_sessions`").
		WithArgs(uint64(7), "fami", sqlmock.AnyArg(), sqlmock.AnyArg(), "203.0.113.10", "browser", sqlmock.AnyArg(), sqlmock.AnyArg(), int64(0), "", sqlmock.AnyArg()).
		WillReturnResult(sqlmock.NewResult(21, 1))
	adminMock.ExpectExec("UPDATE `external_admin_accounts` SET").WillReturnResult(sqlmock.NewResult(0, 1))
	adminMock.ExpectExec("INSERT INTO `external_admin_login_logs`").
		WithArgs(uint64(7), "fami", "operator", "203.0.113.10", "browser", "success", "login_success", sqlmock.AnyArg()).
		WillReturnResult(sqlmock.NewResult(31, 1))
	adminMock.ExpectCommit()
	userMock.ExpectQuery("SELECT u.user_id,").
		WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(9001)).
		WillReturnRows(sqlmock.NewRows([]string{
			"user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status",
		}).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active"))
	userMock.ExpectQuery("SELECT u.region_id").
		WithArgs("fami", int64(9001)).
		WillReturnRows(sqlmock.NewRows([]string{"region_id"}).AddRow(12))
	adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE app_code = \\? AND parent_account_id IN \\(\\?\\) AND identity_type = \\? AND status = \\?").
		WithArgs("fami", uint64(7), "external_super_admin", "active", maxHierarchyAccounts+1).
		WillReturnRows(sqlmock.NewRows([]string{"id"}))

	service := NewService(adminDB, userDB, Config{})
	service.limiter = allowFixedWindowLimiter{}
	result, err := service.Login(t.Context(), LoginInput{
		Username: "Operator", Password: "correct-password", IP: "203.0.113.10", UserAgent: "browser",
	})
	if err != nil {
		t.Fatalf("login without App: %v", err)
	}
	if result.SessionToken == "" || result.CSRFToken == "" {
		t.Fatal("successful login must issue opaque session and CSRF tokens")
	}
	if result.View.AppCode != "fami" || result.View.App.AppCode != "fami" || result.View.Account.Username != "operator" {
		t.Fatalf("resolved session view = %+v", result.View)
	}
	if result.View.IdentityType != "country_manager" || result.View.RegionID != 12 || result.View.ExternalSuperAdminCount != 0 {
		t.Fatalf("identity scope view = %+v", result.View)
	}
	if err := adminMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("admin sql expectations: %v", err)
	}
	if err := userMock.ExpectationsWereMet(); err != nil {
		t.Fatalf("user sql expectations: %v", err)
	}
}

type futureMillis struct{}

func (futureMillis) Match(value driver.Value) bool {
	millis, ok := value.(int64)
	return ok && millis > time.Now().UTC().UnixMilli()
}
