//go:build !android && !ios && !freebsd && !js

// Package preferences holds user-scope UI state that is independent of the
// daemon profile: language, and any future toggles the React UI exposes to
// the user. The Store reads from and writes to a JSON file under
// os.UserConfigDir(), validates input against an injected language
// validator (typically *i18n.Bundle), and broadcasts changes to in-process
// subscribers (tray) plus an optional Wails emitter (frontend).
//
// No Wails dependency — the emitter is consumed through a minimal
// interface so the package can be tested without spinning up Wails.
package preferences

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sync"

	log "github.com/sirupsen/logrus"

	"github.com/netbirdio/netbird/client/ui/i18n"
	"github.com/netbirdio/netbird/util"
)

// preferencesFileName is the JSON file holding user-scope UI preferences.
// Stored under os.UserConfigDir()/netbird so it lives in the OS-user's
// writable config dir, not the daemon's root-owned state. Per-OS-user,
// shared across all daemon profiles.
const preferencesFileName = "ui-preferences.json"

// EventPreferencesChanged fires whenever the on-disk preferences are
// updated (from any source). The payload is the fresh UIPreferences value.
// Wails registers this name in init() so the React frontend can subscribe.
const EventPreferencesChanged = "netbird:preferences:changed"

// ViewMode is the user's preferred Main-window layout. "default" is the
// compact 380-wide layout shown on first launch; "advanced" is the wider
// 900-px layout that matches the Settings window. Persisted so the next
// launch comes up in the same mode the user last picked.
type ViewMode string

const (
	ViewModeDefault  ViewMode = "default"
	ViewModeAdvanced ViewMode = "advanced"
)

// DefaultViewMode is the value served when no preferences file exists yet
// or the on-disk file has an empty view-mode field.
const DefaultViewMode = ViewModeDefault

// ErrUnsupportedViewMode is returned by SetViewMode when the caller passes
// a value outside the known set.
var ErrUnsupportedViewMode = errors.New("unsupported view mode")

// IsValid reports whether v is one of the known ViewMode constants.
func (v ViewMode) IsValid() bool {
	switch v {
	case ViewModeDefault, ViewModeAdvanced:
		return true
	}
	return false
}

// UIPreferences is the user-scope UI state mirrored to disk and to the
// frontend. Pointer-free because the whole document is rewritten on every
// change — there are no per-field partial updates.
type UIPreferences struct {
	Language            i18n.LanguageCode `json:"language"`
	ViewMode            ViewMode          `json:"viewMode"`
	OnboardingCompleted bool              `json:"onboardingCompleted"`
}

// LanguageValidator is the dependency Store needs to reject SetLanguage
// inputs that have no shipped bundle. *i18n.Bundle satisfies it directly.
type LanguageValidator interface {
	HasLanguage(code i18n.LanguageCode) bool
}

// Emitter is the dependency Store needs to broadcast changes to the
// frontend. *application.EventProcessor (Wails) satisfies it; tests pass
// nil or a fake.
type Emitter interface {
	Emit(name string, data ...any) bool
}

// Store is the user-scope UI preferences store. Read at app start,
// updated by the React settings page (via the Wails-bound facade), and
// observed by the tray which re-renders its menu in the new language.
type Store struct {
	path string

	mu      sync.RWMutex
	current UIPreferences

	subsMu sync.Mutex
	subs   []chan UIPreferences

	validator LanguageValidator
	emitter   Emitter
}

// NewStore loads preferences from disk (creating a default file when
// none exists). The validator is consulted on SetLanguage; pass nil to
// skip validation (used by the unit tests). The emitter is optional —
// when set, SetLanguage broadcasts EventPreferencesChanged.
func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
	path, err := preferencesPath()
	if err != nil {
		return nil, fmt.Errorf("resolve preferences path: %w", err)
	}

	// Language starts empty — the absence of a value is the signal the
	// frontend uses on first launch to detect the browser locale and call
	// SetLanguage. Consumers that need an effective language (tray
	// Localizer, i18n.Bundle.Translate) already fall back to
	// i18n.DefaultLanguage when the code is empty.
	s := &Store{
		path:      path,
		validator: validator,
		emitter:   emitter,
		current:   UIPreferences{ViewMode: DefaultViewMode},
	}

	if err := s.load(); err != nil {
		log.Warnf("load ui preferences from %s: %v (using defaults)", path, err)
	}

	return s, nil
}

// Get returns a copy of the current preferences.
func (s *Store) Get() UIPreferences {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.current
}

// SetViewMode validates and persists the user's Main-window view choice,
// then broadcasts the change so any other open window can react.
func (s *Store) SetViewMode(mode ViewMode) error {
	if !mode.IsValid() {
		return fmt.Errorf("%w: %q", ErrUnsupportedViewMode, mode)
	}

	s.mu.Lock()
	if s.current.ViewMode == mode {
		s.mu.Unlock()
		return nil
	}
	next := s.current
	next.ViewMode = mode
	if err := s.persistLocked(next); err != nil {
		s.mu.Unlock()
		return fmt.Errorf("persist preferences: %w", err)
	}
	s.current = next
	s.mu.Unlock()

	s.broadcast(next)
	return nil
}

// SetOnboardingCompleted persists the welcome-window dismissal so the
// welcome flow doesn't run again on subsequent launches. Idempotent — a
// repeat of the current value is a no-op (no disk write, no broadcast).
func (s *Store) SetOnboardingCompleted(done bool) error {
	s.mu.Lock()
	if s.current.OnboardingCompleted == done {
		s.mu.Unlock()
		return nil
	}
	next := s.current
	next.OnboardingCompleted = done
	if err := s.persistLocked(next); err != nil {
		s.mu.Unlock()
		return fmt.Errorf("persist preferences: %w", err)
	}
	s.current = next
	s.mu.Unlock()

	s.broadcast(next)
	return nil
}

// SetLanguage validates and persists a new language preference, then
// broadcasts the change to internal subscribers (tray) and the emitter
// (frontend).
func (s *Store) SetLanguage(lang i18n.LanguageCode) error {
	if lang == "" {
		return fmt.Errorf("%w: empty code", i18n.ErrUnsupportedLanguage)
	}
	if s.validator != nil && !s.validator.HasLanguage(lang) {
		return fmt.Errorf("%w: %q", i18n.ErrUnsupportedLanguage, lang)
	}

	s.mu.Lock()
	if s.current.Language == lang {
		s.mu.Unlock()
		return nil
	}
	next := s.current
	next.Language = lang
	if err := s.persistLocked(next); err != nil {
		s.mu.Unlock()
		return fmt.Errorf("persist preferences: %w", err)
	}
	s.current = next
	s.mu.Unlock()

	s.broadcast(next)
	return nil
}

// Subscribe returns a channel that receives every persisted change. The
// unsubscribe function closes the channel and removes it from the list;
// callers must not close the channel themselves.
func (s *Store) Subscribe() (<-chan UIPreferences, func()) {
	ch := make(chan UIPreferences, 4)
	s.subsMu.Lock()
	s.subs = append(s.subs, ch)
	s.subsMu.Unlock()

	unsubscribe := func() {
		s.subsMu.Lock()
		defer s.subsMu.Unlock()
		for i, c := range s.subs {
			if c == ch {
				s.subs = append(s.subs[:i], s.subs[i+1:]...)
				close(ch)
				return
			}
		}
	}
	return ch, unsubscribe
}

// load reads the on-disk file into current. A missing file is not an
// error (we keep the in-memory default); malformed contents are reported
// so the caller can log+continue with the default.
func (s *Store) load() error {
	if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) {
		return nil
	}

	var loaded UIPreferences
	if _, err := util.ReadJson(s.path, &loaded); err != nil {
		return err
	}

	if !loaded.ViewMode.IsValid() {
		loaded.ViewMode = DefaultViewMode
	}

	s.mu.Lock()
	s.current = loaded
	s.mu.Unlock()
	return nil
}

// persistLocked writes the candidate preferences atomically. Caller must
// hold s.mu (write lock); the lock is not released here so the in-memory
// state is updated only after a successful write.
func (s *Store) persistLocked(v UIPreferences) error {
	if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
		return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err)
	}
	return util.WriteJson(context.Background(), s.path, v)
}

// broadcast fans the new value out to internal subscribers and to the
// frontend emitter. Subscribers with a full buffer are skipped — the tray
// only cares about the latest value, so dropping intermediate frames
// during a burst is safe.
func (s *Store) broadcast(v UIPreferences) {
	s.subsMu.Lock()
	subs := make([]chan UIPreferences, len(s.subs))
	copy(subs, s.subs)
	s.subsMu.Unlock()

	for _, ch := range subs {
		select {
		case ch <- v:
		default:
			log.Debugf("preferences subscriber channel full; dropping update")
		}
	}

	if s.emitter != nil {
		s.emitter.Emit(EventPreferencesChanged, v)
	}
}

// preferencesPath resolves os.UserConfigDir()/netbird/ui-preferences.json.
func preferencesPath() (string, error) {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, "netbird", preferencesFileName), nil
}
