// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

package goal

import (
	"context"
	"errors"
	"strings"
	"time"

	"git.secluded.site/np/internal/db"
	"git.secluded.site/np/internal/timeutil"
)

// ErrNotFound is returned when no goal exists for the requested session.
var ErrNotFound = errors.New("goal: not found")

// ErrEmptyTitle indicates that a goal title was not provided.
var ErrEmptyTitle = errors.New("goal: title is required")

// Document captures the session goal persisted in the database.
type Document struct {
	Title       string    `json:"title"`
	Description string    `json:"description"`
	UpdatedAt   time.Time `json:"updated_at"`
}

// Store provides high-level helpers for working with session goals.
type Store struct {
	db    *db.Database
	clock timeutil.Clock
}

// NewStore constructs a Store. When clock is nil, a UTC system clock is used.
func NewStore(database *db.Database, clock timeutil.Clock) *Store {
	if clock == nil {
		clock = timeutil.UTCClock{}
	}
	return &Store{
		db:    database,
		clock: clock,
	}
}

// WithTxn returns a transactional view of the store for use within db.Update.
func (s *Store) WithTxn(txn *db.Txn) TxnStore {
	return TxnStore{
		txn:   txn,
		clock: s.clock,
	}
}

// Get loads the goal for sid using a read-only transaction.
func (s *Store) Get(ctx context.Context, sid string) (Document, error) {
	var doc Document
	err := s.db.View(ctx, func(txn *db.Txn) error {
		var err error
		doc, err = load(txn, sid)
		return err
	})
	return doc, err
}

// Exists reports whether a goal has been set for sid.
func (s *Store) Exists(ctx context.Context, sid string) (bool, error) {
	var exists bool
	err := s.db.View(ctx, func(txn *db.Txn) error {
		var err error
		exists, err = txn.Exists(db.KeySessionGoal(sid))
		return err
	})
	return exists, err
}

// Set stores a goal for sid using a write transaction, returning the persisted
// document.
func (s *Store) Set(ctx context.Context, sid, title, description string) (Document, error) {
	var doc Document
	err := s.db.Update(ctx, func(txn *db.Txn) error {
		var err error
		doc, err = save(txn, s.clock, sid, title, description)
		return err
	})
	return doc, err
}

// TxnStore offers goal helpers scoped to an existing transaction.
type TxnStore struct {
	txn   *db.Txn
	clock timeutil.Clock
}

// Get loads the goal for sid within the wrapped transaction.
func (s TxnStore) Get(sid string) (Document, error) {
	return load(s.txn, sid)
}

// Exists reports whether a goal has been set for sid within the wrapped txn.
func (s TxnStore) Exists(sid string) (bool, error) {
	if s.txn == nil {
		return false, errors.New("goal: transaction is nil")
	}
	return s.txn.Exists(db.KeySessionGoal(sid))
}

// Key returns the Badger key storing the goal document. This is useful for
// observers that subscribe to goal updates.
func Key(sid string) []byte {
	return db.KeySessionGoal(sid)
}

// Set writes the goal for sid inside the wrapped transaction.
func (s TxnStore) Set(sid, title, description string) (Document, error) {
	return save(s.txn, s.clock, sid, title, description)
}

func load(txn *db.Txn, sid string) (Document, error) {
	if txn == nil {
		return Document{}, errors.New("goal: transaction is nil")
	}
	key := db.KeySessionGoal(sid)
	exists, err := txn.Exists(key)
	if err != nil {
		return Document{}, err
	}
	if !exists {
		return Document{}, ErrNotFound
	}

	var doc Document
	if err := txn.GetJSON(key, &doc); err != nil {
		return Document{}, err
	}
	return doc, nil
}

func save(txn *db.Txn, clock timeutil.Clock, sid, title, description string) (Document, error) {
	if txn == nil {
		return Document{}, errors.New("goal: transaction is nil")
	}
	if clock == nil {
		clock = timeutil.UTCClock{}
	}

	cleanTitle := strings.TrimSpace(title)
	if cleanTitle == "" {
		return Document{}, ErrEmptyTitle
	}

	doc := Document{
		Title:       cleanTitle,
		Description: description,
		UpdatedAt:   timeutil.EnsureUTC(clock.Now()),
	}

	if err := txn.SetJSON(db.KeySessionGoal(sid), doc); err != nil {
		return Document{}, err
	}
	return doc, nil
}
