package storage

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

	"gitea.mixdep.ru/mix/gosentry/src/domain"
)

type Store struct {
	Paths  Paths
	Config domain.Config
}

func OpenStore() (*Store, []domain.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.json compact:
	// only durable job definitions remain, because runtime fields are tagged
	// json:"-" 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 writeJSON(s.Paths.ConfigPath, s.Config)
}

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

func loadOrCreateConfig(paths Paths) (domain.Config, error) {
	// Defaults favor a portable installation: settings and jobs begin next to the
	// executable, while logs are grouped under a dedicated subdirectory.
	config := domain.DefaultConfig()

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

	data, err := os.ReadFile(paths.ConfigPath)
	if err != nil {
		return domain.Config{}, err
	}
	if err := json.Unmarshal(data, &config); err != nil {
		return domain.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
	}
	if config.ExecutionMode == "" {
		config.ExecutionMode = domain.ExecutionModeParallel
	}
	if config.OverlapPolicy == "" {
		config.OverlapPolicy = domain.OverlapPolicySkip
	}
	// DefaultTimeoutSeconds is deliberately not normalized: 0 is a meaningful
	// value ("no timeout"), not a missing one, so backfilling it here would make
	// the setting impossible to persist. Negative values are rejected by
	// app.validateConfig before they can be saved.
	if config.Theme == "" {
		config.Theme = domain.ThemeDefault
	}
	return config, nil
}

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

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

func normalizeJobs(jobs []domain.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)
		// Runtime state (last run, next run, status, output, activity) is no longer
		// part of Job. It is reconstructed each time the app starts via
		// domain.NewRuntime, so normalizeJobs only touches durable configuration.
	}
}

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 writeJSON(path string, value any) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		return err
	}
	// A trailing newline keeps the file friendly to editors and diff tools that
	// expect text files to end with one.
	data = append(data, '\n')
	// WriteFile replaces the full file instead of patching it in place. For small
	// JSON 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() []domain.Job {
	return []domain.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, "'", "'\\''") + "'"
}
