package app

import (
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"time"
)

type Store struct {
	mu                         sync.Mutex
	path                       string
	candidateCursorPath        string
	candidateCursor            int64
	candidateCursorInitialized bool
	jobs                       map[string]Job
	byMessage                  map[string]string
}

func OpenStore(path string) (*Store, error) {
	s := &Store{
		path: path, candidateCursorPath: path + ".candidate-cursor",
		jobs: map[string]Job{}, byMessage: map[string]string{},
	}
	payload, err := os.ReadFile(path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("read job store: %w", err)
	}
	if len(payload) > 0 {
		var jobs []Job
		if err := json.Unmarshal(payload, &jobs); err != nil {
			return nil, fmt.Errorf("decode job store: %w", err)
		}
		for _, job := range jobs {
			if job.Status == JobRunning {
				if job.Mode == JobModeTicket {
					job.Status = JobFailed
					job.Error = "single-ticket lifecycle was interrupted by service restart; external merge, deployment, replay, and Jira state require human readback before retry"
					job.NotificationStatus = NotificationPending
				} else {
					job.Status = JobQueued
					job.Error = "requeued after service restart"
				}
			}
			s.jobs[job.ID] = job
			s.byMessage[job.MessageID] = job.ID
		}
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
		return nil, fmt.Errorf("create job store directory: %w", err)
	}
	cursorPayload, err := os.ReadFile(s.candidateCursorPath)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("read candidate cursor: %w", err)
	}
	if len(cursorPayload) > 0 {
		var cursor struct {
			LastCandidateID int64 `json:"lastCandidateId"`
			Initialized     bool  `json:"initialized"`
		}
		if err := json.Unmarshal(cursorPayload, &cursor); err != nil || cursor.LastCandidateID < 0 {
			return nil, fmt.Errorf("decode candidate cursor: invalid content")
		}
		s.candidateCursor = cursor.LastCandidateID
		s.candidateCursorInitialized = cursor.Initialized
	}
	return s, nil
}

func (s *Store) Enqueue(input Job) (Job, bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if id := s.byMessage[input.MessageID]; id != "" {
		return s.jobs[id], false, nil
	}
	now := time.Now().UTC()
	input.ID = randomID()
	input.Status = JobQueued
	input.CreatedAt = now
	input.UpdatedAt = now
	s.jobs[input.ID] = input
	s.byMessage[input.MessageID] = input.ID
	if err := s.persistLocked(); err != nil {
		delete(s.jobs, input.ID)
		delete(s.byMessage, input.MessageID)
		return Job{}, false, err
	}
	return input, true, nil
}

func (s *Store) Get(id string) (Job, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	job, ok := s.jobs[id]
	return job, ok
}

func (s *Store) LatestSuccessfulAnalyzeThread(chatID, rootMessageID, excludeJobID string) (Job, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	var latest Job
	found := false
	for _, job := range s.jobs {
		sameLarkThread := job.LarkRootMessageID == rootMessageID ||
			(job.LarkRootMessageID == "" && job.MessageID == rootMessageID)
		if job.ID == excludeJobID || job.ChatID != chatID || !sameLarkThread ||
			job.Mode != JobModeAnalyze || job.Status != JobSucceeded || job.ThreadID == "" {
			continue
		}
		if !found || latest.CreatedAt.Before(job.CreatedAt) {
			latest = job
			found = true
		}
	}
	return latest, found
}

func (s *Store) Update(id string, update func(*Job)) (Job, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	job, ok := s.jobs[id]
	if !ok {
		return Job{}, fmt.Errorf("job %s not found", id)
	}
	before := job
	update(&job)
	job.UpdatedAt = time.Now().UTC()
	s.jobs[id] = job
	if err := s.persistLocked(); err != nil {
		s.jobs[id] = before
		return Job{}, err
	}
	return job, nil
}

func (s *Store) QueuedIDs() []string {
	s.mu.Lock()
	defer s.mu.Unlock()
	jobs := make([]Job, 0, len(s.jobs))
	for _, job := range s.jobs {
		if job.Status == JobQueued {
			jobs = append(jobs, job)
		}
	}
	sort.Slice(jobs, func(i, j int) bool { return jobs[i].CreatedAt.Before(jobs[j].CreatedAt) })
	ids := make([]string, 0, len(jobs))
	for _, job := range jobs {
		ids = append(ids, job.ID)
	}
	return ids
}

func (s *Store) PendingNotificationIDs() []string {
	s.mu.Lock()
	defer s.mu.Unlock()
	jobs := make([]Job, 0, len(s.jobs))
	for _, job := range s.jobs {
		if job.Mode == JobModeTicket &&
			(job.Status == JobSucceeded || job.Status == JobFailed) &&
			job.NotificationStatus == NotificationPending {
			jobs = append(jobs, job)
		}
	}
	sort.Slice(jobs, func(i, j int) bool { return jobs[i].UpdatedAt.Before(jobs[j].UpdatedAt) })
	ids := make([]string, 0, len(jobs))
	for _, job := range jobs {
		ids = append(ids, job.ID)
	}
	return ids
}

func (s *Store) CandidateCursor() (int64, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.candidateCursor, s.candidateCursorInitialized
}

func (s *Store) SetCandidateCursor(value int64) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if value < s.candidateCursor {
		return fmt.Errorf("candidate cursor cannot move backwards")
	}
	if value == s.candidateCursor && s.candidateCursorInitialized {
		return nil
	}
	payload, err := json.Marshal(struct {
		LastCandidateID int64 `json:"lastCandidateId"`
		Initialized     bool  `json:"initialized"`
	}{LastCandidateID: value, Initialized: true})
	if err != nil {
		return err
	}
	if err := atomicWriteFile(s.candidateCursorPath, append(payload, '\n')); err != nil {
		return fmt.Errorf("persist candidate cursor: %w", err)
	}
	s.candidateCursor = value
	s.candidateCursorInitialized = true
	return nil
}

func (s *Store) persistLocked() error {
	jobs := make([]Job, 0, len(s.jobs))
	for _, job := range s.jobs {
		jobs = append(jobs, job)
	}
	sort.Slice(jobs, func(i, j int) bool { return jobs[i].CreatedAt.Before(jobs[j].CreatedAt) })
	tmp, err := os.CreateTemp(filepath.Dir(s.path), ".jobs-*.tmp")
	if err != nil {
		return fmt.Errorf("create temporary job store: %w", err)
	}
	tmpName := tmp.Name()
	defer os.Remove(tmpName)
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return err
	}
	encoder := json.NewEncoder(tmp)
	encoder.SetIndent("", "  ")
	if err := encoder.Encode(jobs); err != nil {
		tmp.Close()
		return fmt.Errorf("encode job store: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Rename(tmpName, s.path); err != nil {
		return fmt.Errorf("replace job store: %w", err)
	}
	return nil
}

func atomicWriteFile(path string, payload []byte) error {
	tmp, err := os.CreateTemp(filepath.Dir(path), ".state-*.tmp")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	defer os.Remove(tmpName)
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return err
	}
	if _, err := tmp.Write(payload); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpName, path)
}

func randomID() string {
	var value [8]byte
	if _, err := rand.Read(value[:]); err != nil {
		panic(err)
	}
	return hex.EncodeToString(value[:])
}
