package core

import (
	"errors"
	"os"
	"path/filepath"
	"runtime"
	"strings"

	"go.yaml.in/yaml/v4"
)

type Store struct {
	Paths  Paths
	Config Config
}

func OpenStore() (*Store, []Job, error) {
	paths, err := ResolvePaths()
	if err != nil {
		return nil, nil, err
	}

	store := &Store{Paths: paths}
	config, err := loadOrCreateConfig(paths)
	if err != nil {
		return nil, nil, err
	}
	store.Config = config
	store.applyConfigPaths()
	// Save the config after loading so missing defaults are written back. This
	// rewrites old or hand-edited files into the current clean schema without
	// forcing the user to delete them manually.
	if err := store.SaveConfig(); err != nil {
		return nil, nil, err
	}

	jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
	if err != nil {
		return nil, nil, err
	}
	normalizeJobs(jobs)
	// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
	// only durable job definitions remain, because runtime fields are tagged
	// yaml:"-" in the model.
	if err := store.SaveJobs(jobs); err != nil {
		return nil, nil, err
	}
	return store, jobs, nil
}

func (s *Store) SaveConfig() error {
	s.applyConfigPaths()
	if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
		return err
	}
	return writeYAML(s.Paths.ConfigPath, s.Config)
}

func (s *Store) SaveJobs(jobs []Job) error {
	if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
		return err
	}
	return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
}

func loadOrCreateConfig(paths Paths) (Config, error) {
	// Defaults favor a portable installation: settings and jobs begin next to the
	// executable, while logs are grouped under a dedicated subdirectory.
	config := Config{
		JobsDir:           ".",
		LogsDir:           "logs",
		MaxLogFiles:       100,
		MaxLogAgeDays:     30,
		StartOnLogin:      false,
		KeepRunningInTray: true,
		NotifyOnFailure:   true,
	}

	configPath := paths.ConfigPath
	if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
		legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
		if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
			// The rename from PySentry to GoSentry changed the preferred config
			// filename. Read the old file once if it is still present so portable
			// installs continue to start without a manual migration step. The
			// caller later saves the loaded config back through SaveConfig, which
			// naturally rewrites it under gosentry.yaml.
			configPath = legacyPath
		} else {
			return config, writeYAML(paths.ConfigPath, config)
		}
	}

	if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
		return config, writeYAML(paths.ConfigPath, config)
	}

	data, err := os.ReadFile(configPath)
	if err != nil {
		return Config{}, err
	}
	if err := yaml.Unmarshal(data, &config); err != nil {
		return Config{}, err
	}
	if strings.TrimSpace(config.JobsDir) == "" {
		// Empty paths are treated as missing values rather than intentional root
		// directories. This avoids accidentally writing jobs to unexpected places.
		config.JobsDir = "."
	}
	if strings.TrimSpace(config.LogsDir) == "" {
		config.LogsDir = "logs"
	}
	if config.MaxLogFiles <= 0 {
		config.MaxLogFiles = 100
	}
	if config.MaxLogAgeDays <= 0 {
		config.MaxLogAgeDays = 30
	}
	return config, nil
}

func loadOrCreateJobs(path string) ([]Job, error) {
	if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
		// The first run creates harmless sample jobs so a new user can immediately
		// see scheduled and manual execution without inventing a command.
		jobs := defaultJobs()
		normalizeJobs(jobs)
		return jobs, writeYAML(path, JobsFile{Jobs: jobs})
	}

	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var file JobsFile
	if err := yaml.Unmarshal(data, &file); err != nil {
		return nil, err
	}
	return file.Jobs, nil
}

func normalizeJobs(jobs []Job) {
	next := 1
	for index := range jobs {
		job := &jobs[index]
		if job.ID <= 0 {
			// IDs are assigned only when absent. Existing IDs stay stable because
			// History and future log associations use them to identify jobs.
			job.ID = next
		}
		if job.ID >= next {
			next = job.ID + 1
		}
		if strings.TrimSpace(job.Name) == "" {
			job.Name = "Untitled job"
		}
		if strings.TrimSpace(job.Schedule) == "" {
			job.Schedule = "@every 1m"
		}
		if strings.TrimSpace(job.Command) == "" {
			// An empty command would fail in a confusing way. A safe echo command
			// gives the user something observable and harmless instead.
			job.Command = echoCommand("GoSentry job ran")
		}
		job.Arguments = strings.TrimSpace(job.Arguments)
		job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
		if job.SuccessExitCodes == "" {
			job.SuccessExitCodes = "0"
		}
		if job.LastRun == "" {
			job.LastRun = "Never"
		}
		if job.Output == "" {
			job.Output = "No command output captured yet."
		}
		if job.Enabled {
			job.LastState = "Ready"
			job.NextRun = "After start"
		} else {
			job.LastState = "Paused"
			job.NextRun = "Paused"
		}
		// Runtime fields are reconstructed each time the app starts. Persisted run
		// records live in log files, not in jobs.yaml, to keep the jobs file easy
		// to review and edit by hand.
		job.Logs = nil
	}
}

func resolveJobsDir(appDir string, jobsDir string) string {
	return resolveConfiguredDir(appDir, jobsDir)
}

func resolveConfiguredDir(appDir string, dir string) string {
	if filepath.IsAbs(dir) {
		return dir
	}
	// Relative paths are resolved against the executable directory, not the
	// process working directory. This matches ResolvePaths and keeps shortcuts,
	// Explorer launches, and terminal launches consistent.
	return filepath.Clean(filepath.Join(appDir, dir))
}

func (s *Store) applyConfigPaths() {
	s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
	s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
	s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
}

func writeYAML(path string, value any) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	data, err := yaml.Marshal(value)
	if err != nil {
		return err
	}
	// WriteFile replaces the full file instead of patching it in place. For small
	// YAML files this is simpler and prevents stale keys from older versions from
	// lingering after the schema changes.
	return os.WriteFile(path, data, 0o644)
}

func defaultJobs() []Job {
	return []Job{
		{
			ID:       1,
			Name:     "Hello scheduler",
			Folder:   "Examples",
			Schedule: "@every 1m",
			Command:  echoCommand("GoSentry test job: scheduler is alive"),
			Enabled:  true,
		},
		{
			ID:       2,
			Name:     "Write timestamp",
			Folder:   "Examples",
			Schedule: "*/1 * * * *",
			Command:  echoCommand("GoSentry test job: timestamp command ran"),
			Enabled:  true,
		},
		{
			ID:       3,
			Name:     "Paused sample",
			Schedule: "@every 1m",
			Command:  echoCommand("This paused sample should not run until enabled"),
			Enabled:  false,
		},
	}
}

func echoCommand(message string) string {
	if runtime.GOOS == "windows" {
		return "echo " + message
	}
	// POSIX shells need quotes for messages with spaces. Single quotes inside the
	// message are escaped using the standard close-quote/backslash/reopen pattern.
	return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
}
