package platform

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"os"
	"path/filepath"

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

// Session persistence lives in a Go-owned file, NOT WebView storage: Wails serves
// the UI from the wails:// custom scheme, and WKWebView does not persist
// localStorage / sessionStorage for custom-scheme origins, so any in-page store
// is wiped on every restart. The persisted session is injected back into the
// page at load time (see SessionInjectMiddleware) so it's available synchronously
// without a binding round-trip.
//
// Credential policy (consistent with the browser): the refresh_token is the
// long-lived secret and is never exposed to the WebView/JS context. It lives only
// in the Go process and at rest; the WebView only ever receives the short-lived
// access_token (via SessionView). Refresh is Go-internal — the WebView calls
// Refresh() with no argument and Go uses its own copy.
//
// At rest the refresh_token is encrypted, not plaintext (R2). It is the one
// credential that lets a holder mint fresh access tokens indefinitely, so
// same-user malware must not be able to read it off disk. We can't store it in
// the OS secret store directly: Casdoor issues JWT refresh_tokens that run to
// ~15 KB, far past go-keyring's per-item limits (macOS ~3 KB command, Windows
// 2560 B). Instead we keep a 32-byte AES key in the secret store (tiny, no size
// issue on any platform) and write the AES-256-GCM ciphertext to the 0600 file.
// Reading the file yields only ciphertext; decrypting it needs the key, which is
// protected exactly as a directly-stored token would be — same posture, no size
// cap. The key goes through go-keyring's system tooling rather than this app's
// code signature, so an ad-hoc / per-build re-sign doesn't invalidate it.
//
// The short-lived access_token and display identity stay as plaintext in the
// file: the access_token is already handed to the WebView and expires fast, so
// it is not the asset R2 protects.

const (
	keyringService = "cdrop"
	// keyringKeyAccount holds the 32-byte AES key (base64) that encrypts the
	// refresh_token at rest — NOT the token itself.
	keyringKeyAccount = "session_key"
)

// persistedSession is the on-disk shape. RefreshTokenEnc holds
// base64(nonce||ciphertext) under AES-256-GCM. RefreshToken is only ever set on
// a legacy file (written before R2) or as a degraded fallback when the secret
// store is unavailable; the happy path leaves it empty.
type persistedSession struct {
	AccessToken     string   `json:"access_token"`
	RefreshTokenEnc string   `json:"refresh_token_enc,omitempty"`
	RefreshToken    string   `json:"refresh_token,omitempty"`
	ExpiresIn       int      `json:"expires_in"`
	User            UserInfo `json:"user"`
}

// sessionPath is ~/Library/Application Support/cdrop/session.json on macOS.
func sessionPath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, "cdrop", "session.json"), nil
}

// loadOrCreateKey returns the AES-256 key from the OS secret store, minting and
// storing a fresh random one on first use.
func loadOrCreateKey() ([]byte, error) {
	if enc, err := keyring.Get(keyringService, keyringKeyAccount); err == nil {
		if key, derr := base64.StdEncoding.DecodeString(enc); derr == nil && len(key) == 32 {
			return key, nil
		}
		// A corrupt / wrong-sized value is unusable; fall through and regenerate.
		// (Any ciphertext encrypted under the old value becomes undecryptable —
		// the user re-logs in, which is acceptable for a corrupt store.)
	} else if !errors.Is(err, keyring.ErrNotFound) {
		return nil, err
	}
	key := make([]byte, 32)
	if _, err := rand.Read(key); err != nil {
		return nil, err
	}
	if err := keyring.Set(keyringService, keyringKeyAccount, base64.StdEncoding.EncodeToString(key)); err != nil {
		return nil, err
	}
	return key, nil
}

func newGCM(key []byte) (cipher.AEAD, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	return cipher.NewGCM(block)
}

// encryptToken seals the refresh_token under the secret-store key, returning
// base64(nonce||ciphertext).
func encryptToken(plaintext string) (string, error) {
	key, err := loadOrCreateKey()
	if err != nil {
		return "", err
	}
	gcm, err := newGCM(key)
	if err != nil {
		return "", err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		return "", err
	}
	sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
	return base64.StdEncoding.EncodeToString(sealed), nil
}

// decryptToken opens a refresh_token ciphertext using the secret-store key. It
// never mints a key: a missing key means the ciphertext is unrecoverable (the
// caller falls back to requiring a fresh login).
func decryptToken(enc string) (string, error) {
	keyB64, err := keyring.Get(keyringService, keyringKeyAccount)
	if err != nil {
		return "", err
	}
	key, err := base64.StdEncoding.DecodeString(keyB64)
	if err != nil || len(key) != 32 {
		return "", errors.New("session: malformed key in secret store")
	}
	raw, err := base64.StdEncoding.DecodeString(enc)
	if err != nil {
		return "", err
	}
	gcm, err := newGCM(key)
	if err != nil {
		return "", err
	}
	if len(raw) < gcm.NonceSize() {
		return "", errors.New("session: ciphertext too short")
	}
	nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
	plaintext, err := gcm.Open(nil, nonce, ct, nil)
	if err != nil {
		return "", err
	}
	return string(plaintext), nil
}

// SaveSession persists the login session. The refresh_token is encrypted at rest
// under a key held in the OS secret store; everything else (short-lived
// access_token + identity) is plaintext in the 0600 file. If the secret store is
// unavailable (e.g. a headless Linux box with no Secret Service), we fall back to
// writing the refresh_token in plaintext so login still works — logging the
// downgrade rather than failing auth outright.
func SaveSession(res LoginResult) error {
	p, err := sessionPath()
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
		return err
	}

	rec := persistedSession{
		AccessToken: res.AccessToken,
		ExpiresIn:   res.ExpiresIn,
		User:        res.User,
	}
	if res.RefreshToken != "" {
		if enc, err := encryptToken(res.RefreshToken); err == nil {
			rec.RefreshTokenEnc = enc
		} else {
			slog.Warn("cdrop: refresh_token kept in session file; OS secret store unavailable", "err", err)
			rec.RefreshToken = res.RefreshToken
		}
	}

	data, err := json.Marshal(rec)
	if err != nil {
		return err
	}
	return os.WriteFile(p, data, 0o600)
}

// LoadSession returns the persisted session, or nil when none is stored / the
// file is unreadable or empty. The refresh_token is decrypted from the file
// using the secret-store key. A legacy file that still embeds the plaintext
// refresh_token (written before R2, or by the fallback above) is re-encrypted on
// first load and stripped of its plaintext.
func LoadSession() (*LoginResult, error) {
	p, err := sessionPath()
	if err != nil {
		return nil, err
	}
	data, err := os.ReadFile(p)
	if errors.Is(err, os.ErrNotExist) {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	var rec persistedSession
	if err := json.Unmarshal(data, &rec); err != nil {
		return nil, err
	}
	if rec.AccessToken == "" {
		return nil, nil
	}

	res := &LoginResult{
		AccessToken: rec.AccessToken,
		ExpiresIn:   rec.ExpiresIn,
		User:        rec.User,
	}
	switch {
	case rec.RefreshToken != "":
		// Plaintext on disk (legacy or fallback): keep it in memory and re-save,
		// which encrypts it and strips the plaintext (no-ops if the store is
		// still unavailable, leaving the file as-is).
		res.RefreshToken = rec.RefreshToken
		if err := SaveSession(*res); err != nil {
			slog.Warn("cdrop: refresh_token migration to secret store failed", "err", err)
		}
	case rec.RefreshTokenEnc != "":
		if rt, err := decryptToken(rec.RefreshTokenEnc); err == nil {
			res.RefreshToken = rt
		} else {
			// Key gone / ciphertext corrupt: drop to an access-token-only session;
			// the app will require a fresh login once the access_token lapses.
			slog.Warn("cdrop: decrypt refresh_token failed; re-login will be required", "err", err)
		}
	}
	return res, nil
}

// ClearSession removes the persisted session (logout): both the file and the
// encryption key held in the OS secret store. Absent entries are a no-op.
func ClearSession() error {
	p, err := sessionPath()
	if err != nil {
		return err
	}
	if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	if err := keyring.Delete(keyringService, keyringKeyAccount); err != nil &&
		!errors.Is(err, keyring.ErrNotFound) {
		slog.Warn("cdrop: clear session key from secret store failed", "err", err)
	}
	return nil
}
