package platform

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	keyring "github.com/zalando/go-keyring"
)

// withTempSession isolates session storage: an in-memory keyring mock (so tests
// never touch the real Keychain / Credential Manager) plus a temp HOME so
// sessionPath resolves under t.TempDir().
func withTempSession(t *testing.T) {
	t.Helper()
	keyring.MockInit()
	dir := t.TempDir()
	// os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS.
	t.Setenv("XDG_CONFIG_HOME", dir)
	t.Setenv("HOME", dir)
}

// readSessionFile returns the parsed on-disk record plus its raw bytes.
func readSessionFile(t *testing.T) (persistedSession, []byte) {
	t.Helper()
	p, err := sessionPath()
	if err != nil {
		t.Fatalf("sessionPath: %v", err)
	}
	data, err := os.ReadFile(p)
	if err != nil {
		if os.IsNotExist(err) {
			return persistedSession{}, nil
		}
		t.Fatalf("read session file: %v", err)
	}
	var rec persistedSession
	if err := json.Unmarshal(data, &rec); err != nil {
		t.Fatalf("unmarshal session file: %v", err)
	}
	return rec, data
}

// The refresh_token must be encrypted at rest (R2): the plaintext never appears
// in the file, the keyring holds only the small AES key, and the round-trip
// returns the token in memory.
func TestSaveSession_RefreshTokenEncryptedAtRest(t *testing.T) {
	withTempSession(t)

	res := LoginResult{
		AccessToken:  "access-abc",
		RefreshToken: "refresh-xyz-secret",
		ExpiresIn:    3600,
		User:         UserInfo{ID: "u1", Name: "Tester"},
	}
	if err := SaveSession(res); err != nil {
		t.Fatalf("save: %v", err)
	}

	rec, raw := readSessionFile(t)
	if rec.RefreshToken != "" {
		t.Errorf("plaintext refresh_token must not be in the file, got %q", rec.RefreshToken)
	}
	if rec.RefreshTokenEnc == "" {
		t.Error("refresh_token_enc must be present")
	}
	if strings.Contains(string(raw), "refresh-xyz-secret") {
		t.Error("the plaintext token must not appear anywhere in the file bytes")
	}
	// The keyring holds the AES key, not the token.
	if k, err := keyring.Get(keyringService, keyringKeyAccount); err != nil || k == "" {
		t.Errorf("keyring must hold the session key: %q err %v", k, err)
	}

	loaded, err := LoadSession()
	if err != nil || loaded == nil {
		t.Fatalf("load: %v (nil=%v)", err, loaded == nil)
	}
	if loaded.RefreshToken != "refresh-xyz-secret" || loaded.AccessToken != "access-abc" {
		t.Errorf("loaded tokens: rt=%q at=%q", loaded.RefreshToken, loaded.AccessToken)
	}
}

// A large JWT refresh_token (Casdoor issues ~15 KB) must round-trip — this is
// exactly the case go-keyring can't store directly (its per-item limit is a few
// KB), and the reason the token is encrypted into the file instead.
func TestSaveSession_LargeTokenRoundTrips(t *testing.T) {
	withTempSession(t)

	big := strings.Repeat("a", 16000)
	res := LoginResult{AccessToken: "acc", RefreshToken: big, ExpiresIn: 3600, User: UserInfo{ID: "u"}}
	if err := SaveSession(res); err != nil {
		t.Fatalf("save large token: %v", err)
	}
	rec, raw := readSessionFile(t)
	if rec.RefreshToken != "" {
		t.Error("large token must not be stored in plaintext")
	}
	if strings.Contains(string(raw), big) {
		t.Error("large plaintext token must not appear in the file bytes")
	}
	loaded, err := LoadSession()
	if err != nil || loaded == nil || loaded.RefreshToken != big {
		t.Fatalf("large token did not round-trip: err %v", err)
	}
}

// A legacy file with a plaintext refresh_token (written before R2) must be
// re-encrypted on first load and stripped of its plaintext.
func TestLoadSession_MigratesLegacyPlaintext(t *testing.T) {
	withTempSession(t)

	p, _ := sessionPath()
	if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	// Seed a legacy file: plaintext refresh_token, no ciphertext field.
	data, _ := json.Marshal(persistedSession{
		AccessToken:  "access-abc",
		RefreshToken: "legacy-plain",
		ExpiresIn:    3600,
		User:         UserInfo{ID: "u1"},
	})
	if err := os.WriteFile(p, data, 0o600); err != nil {
		t.Fatalf("seed legacy file: %v", err)
	}

	loaded, err := LoadSession()
	if err != nil || loaded == nil {
		t.Fatalf("load: %v", err)
	}
	if loaded.RefreshToken != "legacy-plain" {
		t.Errorf("migrated session must carry the token in memory, got %q", loaded.RefreshToken)
	}
	rec, raw := readSessionFile(t)
	if rec.RefreshToken != "" {
		t.Error("plaintext must be stripped from disk after migration")
	}
	if rec.RefreshTokenEnc == "" || strings.Contains(string(raw), "legacy-plain") {
		t.Error("migrated token must be encrypted in the file")
	}
}

func TestSessionRoundTripAndClear(t *testing.T) {
	withTempSession(t)

	if got, err := LoadSession(); err != nil || got != nil {
		t.Fatalf("absent session should be nil; got %+v err %v", got, err)
	}

	want := LoginResult{
		AccessToken:  "acc",
		RefreshToken: "ref",
		ExpiresIn:    3600,
		User:         UserInfo{ID: "u-1", Name: "alice", Avatar: "a.png"},
	}
	if err := SaveSession(want); err != nil {
		t.Fatalf("SaveSession: %v", err)
	}
	got, err := LoadSession()
	if err != nil {
		t.Fatalf("LoadSession: %v", err)
	}
	if got == nil || *got != want {
		t.Errorf("round-trip: got %+v, want %+v", got, want)
	}

	if err := ClearSession(); err != nil {
		t.Fatalf("ClearSession: %v", err)
	}
	if got, _ := LoadSession(); got != nil {
		t.Error("session should be gone after ClearSession")
	}
	// The key is dropped too — no orphaned secret-store entry.
	if _, err := keyring.Get(keyringService, keyringKeyAccount); err != keyring.ErrNotFound {
		t.Errorf("session key should be gone after clear, got err %v", err)
	}
	if err := ClearSession(); err != nil {
		t.Errorf("ClearSession on absent should be no-op, got %v", err)
	}
}

func TestLoadSessionIgnoresEmptyToken(t *testing.T) {
	withTempSession(t)
	if err := SaveSession(LoginResult{RefreshToken: "ref"}); err != nil {
		t.Fatalf("SaveSession: %v", err)
	}
	got, err := LoadSession()
	if err != nil {
		t.Fatalf("LoadSession: %v", err)
	}
	if got != nil {
		t.Errorf("empty-access-token session should load as nil, got %+v", got)
	}
}
