// Package support persists the Telegram bot's support-relay state. For each user
// who direct-messages the bot it records the dedicated forum topic the bot opened
// in the operators' support chat, whether the user is blocked, and the ids of the
// messages relayed into that topic — so an operator's "clear" can delete them while
// keeping the topic's info card. The state is a small JSON file on a writable
// volume: the bot host has no database.
//
// The store is concurrency-safe. The go-telegram/bot library dispatches each update
// in its own goroutine, so every exported method locks. Mutators update only their
// own fields (SetTopic never touches Blocked, SetBlocked never touches the relayed
// ids) so a topic recreate cannot clobber a concurrent block toggle.
package support

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"time"
)

// User is one user's support-relay state.
type User struct {
	// UserID is the user's Telegram user id (the private chat id the bot relays to).
	UserID int64 `json:"user_id"`
	// TopicID is the forum topic's message_thread_id in the support chat; 0 means no
	// topic has been created yet.
	TopicID int `json:"topic_id"`
	// HeaderMsgID is the info-card message that opens the topic and carries the
	// block/clear buttons; it is never deleted by a clear.
	HeaderMsgID int `json:"header_msg_id"`
	// Blocked reports whether the bot drops this user's incoming messages.
	Blocked bool `json:"blocked"`
	// FirstName, LastName and Username snapshot the user's Telegram profile at the
	// time the topic was created or last refreshed (for the topic name and info card).
	FirstName string `json:"first_name,omitempty"`
	LastName  string `json:"last_name,omitempty"`
	Username  string `json:"username,omitempty"`
	// RelayedMsgIDs are the ids of the messages posted into the topic after the header
	// (the user's relayed messages and the operators' replies); a clear deletes them.
	RelayedMsgIDs []int `json:"relayed_msg_ids,omitempty"`
	// CreatedAt is when the topic was first opened for this user.
	CreatedAt time.Time `json:"created_at"`
}

// Store is the concurrency-safe, JSON-backed support-relay state. The zero value is
// not usable; build one with Open or New.
type Store struct {
	mu      sync.Mutex
	path    string
	users   map[int64]*User
	byTopic map[int]int64 // TopicID -> UserID, for resolving operator replies
}

// file is the on-disk shape: a flat list of users. A JSON object keyed by user id
// would force the int64 keys through strings; a list keeps the ids typed.
type file struct {
	Users []*User `json:"users"`
}

// New returns an empty store that persists to path, creating the parent directory
// when missing. Use it as the fallback when Open reports a corrupt file.
func New(path string) *Store {
	_ = os.MkdirAll(filepath.Dir(path), 0o755)
	return &Store{path: path, users: map[int64]*User{}, byTopic: map[int]int64{}}
}

// Open loads the store from path. A missing file yields an empty store and no
// error; an unreadable or malformed file returns an error so the caller can decide
// whether to start empty.
func Open(path string) (*Store, error) {
	s := New(path)
	b, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return s, nil
		}
		return nil, fmt.Errorf("support: read state %s: %w", path, err)
	}
	var f file
	if err := json.Unmarshal(b, &f); err != nil {
		return nil, fmt.Errorf("support: parse state %s: %w", path, err)
	}
	for _, u := range f.Users {
		if u == nil || u.UserID == 0 {
			continue
		}
		s.users[u.UserID] = u
		if u.TopicID != 0 {
			s.byTopic[u.TopicID] = u.UserID
		}
	}
	return s, nil
}

// Get returns a detached copy of the user's state and whether it exists.
func (s *Store) Get(userID int64) (User, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[userID]
	if !ok {
		return User{}, false
	}
	cp := *u
	cp.RelayedMsgIDs = append([]int(nil), u.RelayedMsgIDs...)
	return cp, true
}

// ByTopic returns the user id owning the given forum topic, and whether one is known.
func (s *Store) ByTopic(topicID int) (int64, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	uid, ok := s.byTopic[topicID]
	return uid, ok
}

// Blocked reports whether the user's incoming messages are dropped.
func (s *Store) Blocked(userID int64) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if u, ok := s.users[userID]; ok {
		return u.Blocked
	}
	return false
}

// SetTopic records the forum topic and info-card message opened for the user and
// refreshes the profile snapshot, creating the record on first contact. It rebuilds
// the topic index when the topic changes (a recreate) and preserves the block state
// and the relayed-message ids. It persists the result.
func (s *Store) SetTopic(userID int64, topicID, headerMsgID int, firstName, lastName, username string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[userID]
	if !ok {
		u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
		s.users[userID] = u
	} else if u.TopicID != 0 && u.TopicID != topicID {
		delete(s.byTopic, u.TopicID)
	}
	u.TopicID = topicID
	u.HeaderMsgID = headerMsgID
	u.FirstName, u.LastName, u.Username = firstName, lastName, username
	if topicID != 0 {
		s.byTopic[topicID] = userID
	}
	return s.save()
}

// SetBlocked sets the user's blocked flag, creating a minimal record when none
// exists, and persists the result.
func (s *Store) SetBlocked(userID int64, blocked bool) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[userID]
	if !ok {
		u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
		s.users[userID] = u
	}
	u.Blocked = blocked
	return s.save()
}

// AppendMsg records a message posted into the user's topic (for a later clear) and
// persists the result. It is a no-op when the user has no record yet.
func (s *Store) AppendMsg(userID int64, msgID int) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[userID]
	if !ok {
		return nil
	}
	u.RelayedMsgIDs = append(u.RelayedMsgIDs, msgID)
	return s.save()
}

// ClearMsgs empties and returns the user's relayed-message ids (the info card is not
// among them) and persists the result, so the caller can delete them from the chat.
func (s *Store) ClearMsgs(userID int64) ([]int, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	u, ok := s.users[userID]
	if !ok || len(u.RelayedMsgIDs) == 0 {
		return nil, nil
	}
	ids := u.RelayedMsgIDs
	u.RelayedMsgIDs = nil
	if err := s.save(); err != nil {
		// Roll back so the ids are not lost if the write fails.
		u.RelayedMsgIDs = ids
		return nil, err
	}
	return ids, nil
}

// save writes the state atomically (temp file in the same directory, then rename).
// The caller must hold s.mu.
func (s *Store) save() error {
	f := file{Users: make([]*User, 0, len(s.users))}
	for _, u := range s.users {
		f.Users = append(f.Users, u)
	}
	sort.Slice(f.Users, func(i, j int) bool { return f.Users[i].UserID < f.Users[j].UserID })
	b, err := json.MarshalIndent(f, "", "  ")
	if err != nil {
		return fmt.Errorf("support: marshal state: %w", err)
	}
	tmp, err := os.CreateTemp(filepath.Dir(s.path), ".support-*.json")
	if err != nil {
		return fmt.Errorf("support: create temp state: %w", err)
	}
	tmpName := tmp.Name()
	defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds
	if _, err := tmp.Write(b); err != nil {
		_ = tmp.Close()
		return fmt.Errorf("support: write temp state: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("support: close temp state: %w", err)
	}
	if err := os.Rename(tmpName, s.path); err != nil {
		return fmt.Errorf("support: replace state %s: %w", s.path, err)
	}
	return nil
}
