package notifications

import (
	"errors"
	"strconv"
	"strings"
	"time"

	usermodel "astrology-api/models/usermodel"

	"gorm.io/gorm"
)

// The in-app notification centre: the read and housekeeping side of the same
// `user_notifications` rows that notifier.go writes.
//
// It lives in this package rather than in `services/` or `services_astrologer/`
// because there is exactly one table and one set of semantics behind it. Both
// apps and the web app authenticate as a `users` row and every notification is
// keyed on `userId`, so a customer, an astrologer and a web session all read
// their own rows through the same code. Splitting it across the two stacks
// would mean two copies of one query with nothing to distinguish them.
//
// Three states, which the apps asked for by name:
//
//	unseen  is_read = 0. What the badge counts.
//	seen    is_read = 1. The person has opened that notification.
//	new     created_at is later than users.notifications_seen_at - it arrived
//	        since the notification screen was last opened. Independent of
//	        seen/unseen: opening the screen clears `new` on everything without
//	        marking anything read, which is what a "3 new" dot on a tab
//	        actually means.
//
// Removal is the existing soft delete: isDelete = 1 plus a deleted_at stamp.
// Nothing is hard-deleted, so a row can be recovered and the push history stays
// auditable.

// Status filters accepted by List.
const (
	StatusAll    = "ALL"
	StatusRead   = "READ"
	StatusUnread = "UNREAD"
)

// Item is one row as the apps render it.
type Item struct {
	ID               uint       `json:"id"`
	Title            string     `json:"title"`
	Description      string     `json:"description"`
	NotificationType int        `json:"notification_type"`
	IsRead           bool       `json:"is_read"`
	IsSeen           bool       `json:"is_seen"`
	IsNew            bool       `json:"is_new"`
	ChatRequestID    *int       `json:"chat_request_id"`
	CallRequestID    *int       `json:"call_request_id"`
	ReadAt           *time.Time `json:"read_at"`
	CreatedAt        *time.Time `json:"created_at"`
	TimeAgo          string     `json:"time_ago"`
}

// Counts is the badge payload.
type Counts struct {
	Total  int64 `json:"total"`
	Unseen int64 `json:"unseen"`
	Seen   int64 `json:"seen"`
	New    int64 `json:"new"`
}

// Pagination mirrors the shape every other paginated list in this API returns.
type Pagination struct {
	Page         int   `json:"page"`
	Limit        int   `json:"limit"`
	TotalRecords int64 `json:"totalRecords"`
	TotalPages   int   `json:"totalPages"`
}

// ListResponse is the body of the list endpoint.
type ListResponse struct {
	List       []Item     `json:"list"`
	Counts     Counts     `json:"counts"`
	Pagination Pagination `json:"pagination"`
}

// ActionResponse is what the mark-read, delete and clear endpoints answer with,
// so the app can update its badge without a second round trip.
type ActionResponse struct {
	Affected int64  `json:"affected"`
	Counts   Counts `json:"counts"`
}

// ListQuery is the parsed query string.
type ListQuery struct {
	Page   int
	Limit  int
	Status string
	Search string
}

// Center reads and maintains one account's notifications.
type Center struct {
	db *gorm.DB
}

// NewCenter builds the centre over an explicit handle, matching the
// constructor-injected style the newer controllers use.
func NewCenter(db *gorm.DB) *Center {

	return &Center{db: db}
}

var errNoDatabase = errors.New("notifications are unavailable, please try again")

//------------------------------------------------
// Scoping
//------------------------------------------------

// scope is every non-removed notification belonging to one account. Every query
// below starts here, so a caller can never reach somebody else's rows: the id
// lists the delete and mark-read endpoints accept are filtered by this same
// userId, which is taken from the token and never from the request body.
func (c *Center) scope(userID uint) *gorm.DB {

	return c.db.
		Model(&usermodel.UserNotification{}).
		Where("userId = ?", userID).
		Where("COALESCE(isDelete, 0) = ?", 0)
}

//------------------------------------------------
// Reading
//------------------------------------------------

// List returns one page, newest first.
func (c *Center) List(userID uint, query ListQuery) (*ListResponse, error) {

	if c.db == nil {
		return nil, errNoDatabase
	}

	if userID == 0 {
		return nil, errors.New("unauthorized")
	}

	page, limit := normalizePage(query.Page, query.Limit)

	seenAt := c.seenAt(userID)

	//------------------------------------------------
	// Filters
	//------------------------------------------------

	rows := c.scope(userID)

	switch strings.ToUpper(strings.TrimSpace(query.Status)) {

	case StatusRead:
		rows = rows.Where("COALESCE(is_read, 0) = ?", 1)

	case StatusUnread:
		rows = rows.Where("COALESCE(is_read, 0) = ?", 0)
	}

	if search := strings.TrimSpace(query.Search); search != "" {

		pattern := "%" + search + "%"

		rows = rows.Where("(title LIKE ? OR description LIKE ?)", pattern, pattern)
	}

	//------------------------------------------------
	// Page
	//------------------------------------------------

	var total int64

	if err := rows.Count(&total).Error; err != nil {
		return nil, err
	}

	var records []usermodel.UserNotification

	err := rows.
		// id breaks the tie: several notifications can share a timestamp to
		// the second, and without it their order between pages is undefined,
		// so a row could appear twice or not at all while paging.
		Order("created_at DESC").
		Order("id DESC").
		Offset((page - 1) * limit).
		Limit(limit).
		Find(&records).Error

	if err != nil {
		return nil, err
	}

	items := make([]Item, 0, len(records))

	for index := range records {
		items = append(items, buildItem(&records[index], seenAt))
	}

	counts, err := c.counts(userID, seenAt)

	if err != nil {
		return nil, err
	}

	return &ListResponse{
		List:   items,
		Counts: *counts,
		Pagination: Pagination{
			Page:         page,
			Limit:        limit,
			TotalRecords: total,
			TotalPages:   totalPages(total, limit),
		},
	}, nil
}

// CountsFor is the badge endpoint: the counts on their own, with no rows.
func (c *Center) CountsFor(userID uint) (*Counts, error) {

	if c.db == nil {
		return nil, errNoDatabase
	}

	if userID == 0 {
		return nil, errors.New("unauthorized")
	}

	return c.counts(userID, c.seenAt(userID))
}

func (c *Center) counts(userID uint, seenAt *time.Time) (*Counts, error) {

	counts := &Counts{}

	if err := c.scope(userID).Count(&counts.Total).Error; err != nil {
		return nil, err
	}

	err := c.scope(userID).
		Where("COALESCE(is_read, 0) = ?", 0).
		Count(&counts.Unseen).Error

	if err != nil {
		return nil, err
	}

	counts.Seen = counts.Total - counts.Unseen

	// Never opened the screen: everything is new.
	if seenAt == nil {

		counts.New = counts.Total

		return counts, nil
	}

	err = c.scope(userID).
		Where("created_at > ?", *seenAt).
		Count(&counts.New).Error

	if err != nil {
		return nil, err
	}

	return counts, nil
}

//------------------------------------------------
// Marking
//------------------------------------------------

// MarkSeen records that the notification screen was opened, which is what
// clears the `new` flag and the tab dot. It deliberately does not mark anything
// read: seeing a list is not reading its entries.
func (c *Center) MarkSeen(userID uint) (*ActionResponse, error) {

	if c.db == nil {
		return nil, errNoDatabase
	}

	if userID == 0 {
		return nil, errors.New("unauthorized")
	}

	now := time.Now()

	err := c.db.
		Model(&usermodel.User{}).
		Where("id = ?", userID).
		Update("notifications_seen_at", now).Error

	if err != nil {
		return nil, err
	}

	counts, err := c.counts(userID, &now)

	if err != nil {
		return nil, err
	}

	return &ActionResponse{Affected: 0, Counts: *counts}, nil
}

// MarkRead marks the given notifications read, or all of them when `all` is set.
//
// Already-read rows are excluded from the update so `affected` reports what
// actually changed rather than how many ids were posted, and so read_at keeps
// the first read rather than being pushed forward by every repeat call.
func (c *Center) MarkRead(userID uint, ids []uint, all bool) (*ActionResponse, error) {

	if c.db == nil {
		return nil, errNoDatabase
	}

	if userID == 0 {
		return nil, errors.New("unauthorized")
	}

	ids = cleanIDs(ids)

	if !all && len(ids) == 0 {
		return nil, errors.New("send ids, or all = true")
	}

	now := time.Now()

	update := c.scope(userID).Where("COALESCE(is_read, 0) = ?", 0)

	if !all {
		update = update.Where("id IN ?", ids)
	}

	result := update.Updates(map[string]any{
		"is_read":    true,
		"read_at":    now,
		"updated_at": now,
	})

	if result.Error != nil {
		return nil, result.Error
	}

	counts, err := c.counts(userID, c.seenAt(userID))

	if err != nil {
		return nil, err
	}

	return &ActionResponse{Affected: result.RowsAffected, Counts: *counts}, nil
}

//------------------------------------------------
// Removing
//------------------------------------------------

// Delete removes one or several notifications. One id and twenty go through the
// same path, so the app's swipe-to-delete and its multi-select both call this.
func (c *Center) Delete(userID uint, ids []uint) (*ActionResponse, error) {

	ids = cleanIDs(ids)

	if len(ids) == 0 {
		return nil, errors.New("select at least one notification to remove")
	}

	return c.remove(userID, ids)
}

// ClearAll removes every notification on the account.
func (c *Center) ClearAll(userID uint) (*ActionResponse, error) {

	return c.remove(userID, nil)
}

// remove soft-deletes, because the existing schema soft-deletes: a row is kept
// with isDelete = 1 and stamped, so the history stays auditable and a mistaken
// clear-all is recoverable in the database.
func (c *Center) remove(userID uint, ids []uint) (*ActionResponse, error) {

	if c.db == nil {
		return nil, errNoDatabase
	}

	if userID == 0 {
		return nil, errors.New("unauthorized")
	}

	now := time.Now()

	update := c.scope(userID)

	if len(ids) > 0 {
		update = update.Where("id IN ?", ids)
	}

	result := update.Updates(map[string]any{
		"isDelete":   true,
		"isActive":   false,
		"deleted_at": now,
		"updated_at": now,
	})

	if result.Error != nil {
		return nil, result.Error
	}

	counts, err := c.counts(userID, c.seenAt(userID))

	if err != nil {
		return nil, err
	}

	return &ActionResponse{Affected: result.RowsAffected, Counts: *counts}, nil
}

//------------------------------------------------
// Helpers
//------------------------------------------------

// seenAt reads when this account last opened the notification screen. nil means
// never, and everything then counts as new.
func (c *Center) seenAt(userID uint) *time.Time {

	if c.db == nil || userID == 0 {
		return nil
	}

	var seenAt *time.Time

	err := c.db.
		Model(&usermodel.User{}).
		Where("id = ?", userID).
		Pluck("notifications_seen_at", &seenAt).Error

	if err != nil {
		return nil
	}

	return seenAt
}

func buildItem(record *usermodel.UserNotification, seenAt *time.Time) Item {

	item := Item{
		ID:               record.ID,
		Title:            record.Title,
		Description:      record.Description,
		NotificationType: record.NotificationType,
		IsRead:           record.IsRead,
		IsSeen:           record.IsRead,
		ChatRequestID:    record.ChatRequestID,
		CallRequestID:    record.CallRequestID,
		ReadAt:           record.ReadAt,
		CreatedAt:        record.CreatedAt,
		TimeAgo:          timeAgo(record.CreatedAt),
	}

	// Arrived since the screen was last opened. A row with no timestamp is not
	// treated as new: an unknown age should not light the dot up forever.
	if record.CreatedAt != nil {
		item.IsNew = seenAt == nil || record.CreatedAt.After(*seenAt)
	}

	return item
}

// timeAgo renders the "2h ago" line the lists show, so every client does not
// reimplement it and disagree about the wording.
func timeAgo(at *time.Time) string {

	if at == nil {
		return ""
	}

	elapsed := time.Since(*at)

	switch {

	case elapsed < time.Minute:
		return "Just now"

	case elapsed < time.Hour:
		return plural(int(elapsed.Minutes()), "minute") + " ago"

	case elapsed < 24*time.Hour:
		return plural(int(elapsed.Hours()), "hour") + " ago"

	case elapsed < 30*24*time.Hour:
		return plural(int(elapsed.Hours()/24), "day") + " ago"

	case elapsed < 365*24*time.Hour:
		return plural(int(elapsed.Hours()/24/30), "month") + " ago"
	}

	return plural(int(elapsed.Hours()/24/365), "year") + " ago"
}

func plural(value int, unit string) string {

	if value <= 1 {
		return "1 " + unit
	}

	return strconv.Itoa(value) + " " + unit + "s"
}

// cleanIDs drops zeroes and duplicates, so a client that pads its array cannot
// widen the WHERE clause or inflate the affected count.
func cleanIDs(ids []uint) []uint {

	seen := map[uint]bool{}

	cleaned := make([]uint, 0, len(ids))

	for _, id := range ids {

		if id == 0 || seen[id] {
			continue
		}

		seen[id] = true

		cleaned = append(cleaned, id)
	}

	return cleaned
}

func normalizePage(page int, limit int) (int, int) {

	if page < 1 {
		page = 1
	}

	if limit < 1 {
		limit = 20
	}

	// Bounded so a client cannot ask for the whole table in one request.
	if limit > 100 {
		limit = 100
	}

	return page, limit
}

func totalPages(total int64, limit int) int {

	if total == 0 || limit < 1 {
		return 0
	}

	pages := int(total) / limit

	if int(total)%limit != 0 {
		pages++
	}

	return pages
}
