package storage

import (
	"database/sql"
	"encoding/json"
	"sort"
)

// The durable record of what has actually happened in the realm.
//
// Every other adventure table is a snapshot gogobee replaces wholesale. This one
// accumulates, because the questions it answers are historical: what has this
// adventurer killed, where have they been, how many times have they died. The
// story feed technically holds the same information — but as English, inside a
// headline, which you cannot count.
//
// Pete still computes nothing about the *game*. It only counts facts gogobee
// already told it. No row here is ever authored by Pete or edited after insert.

// AdvEvent is one game fact, kept as fact rather than as the sentence it was
// rendered into. Mirrors web.AdvFact minus the transport-only fields (no_push,
// class_race) that describe delivery rather than the event. Stakes is the one
// former transport field kept here: for a treasure_found it carries the item's
// name, which is the fact, not the delivery.
type AdvEvent struct {
	GUID       string   `json:"guid"`
	EventType  string   `json:"event_type"`
	Tier       string   `json:"tier"`
	Subject    string   `json:"subject"`
	Opponent   string   `json:"opponent"`
	Boss       string   `json:"boss"`
	Zone       string   `json:"zone"`
	Region     string   `json:"region"`
	Level      int      `json:"level"`
	Tally      int      `json:"tally"`
	Outcome    string   `json:"outcome"`
	Milestone  string   `json:"milestone"`
	Stakes     string   `json:"stakes"`
	Actors     []string `json:"actors"`
	OccurredAt int64    `json:"occurred_at"`
}

// InsertAdventureEvent records a fact. Idempotent on guid via INSERT OR IGNORE:
// gogobee retries a fact whose ack it lost, and a retried siege must not add a
// second kill to anybody's tally. The story insert upstream is guarded by
// IsGUIDSeen for the same reason; this is the same guarantee enforced by the
// table rather than by a check-then-act that a concurrent retry could race.
func InsertAdventureEvent(e *AdvEvent) error {
	actors, err := json.Marshal(e.Actors)
	if err != nil {
		return err
	}
	_, err = Get().Exec(`
		INSERT OR IGNORE INTO adventure_events
			(guid, event_type, tier, subject, opponent, boss, zone, region,
			 level, tally, outcome, milestone, stakes, actors, occurred_at)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		e.GUID, e.EventType, e.Tier, e.Subject, e.Opponent, e.Boss, e.Zone,
		e.Region, e.Level, e.Tally, e.Outcome, e.Milestone, e.Stakes, string(actors), e.OccurredAt)
	return err
}

// BossTally is one monster and how many times this adventurer has put it down.
type BossTally struct {
	Boss  string `json:"boss"`
	Kills int    `json:"kills"`
	First bool   `json:"first"` // they were the first in the realm to ever clear it
}

// ZoneTally is one zone and how many times this adventurer has cleared it.
type ZoneTally struct {
	Zone   string `json:"zone"`
	Region string `json:"region"`
	Clears int    `json:"clears"`
	First  bool   `json:"first"`
}

// TrophyCase is an adventurer's whole history, counted.
//
// Treasure is counted only from the treasure_found fact, never from the vault
// snapshot: a bought sword is not a trophy, and the snapshot is current-state
// with no "found it in X on day 3". So a treasure tally that predates the first
// treasure_found is a clean zero, not a back-derivation.
type TrophyCase struct {
	Name           string          `json:"name"`
	Bosses         []BossTally     `json:"bosses,omitempty"`
	Zones          []ZoneTally     `json:"zones,omitempty"`
	Treasures      []TreasureTally `json:"treasures,omitempty"`
	BossKills      int             `json:"boss_kills"`  // total, including firsts
	BossFirsts     int             `json:"boss_firsts"` // realm-firsts among them
	ZoneClears     int             `json:"zone_clears"`
	ZoneFirsts     int             `json:"zone_firsts"`
	TreasuresFound int             `json:"treasures_found"` // story-grade finds, total
	TreasureFirsts int             `json:"treasure_firsts"` // realm-first hoards among them
	Deaths         int             `json:"deaths"`
	Retreats       int             `json:"retreats"`
	RivalWins      int             `json:"rival_wins"`
	Milestones     []string        `json:"milestones,omitempty"`
	Survived       int             `json:"survived"` // mischief contracts walked away from
	Downed         int             `json:"downed"`   // mischief contracts that landed
	Events         int             `json:"events"`   // total facts on file
	FirstSeen      int64           `json:"first_seen"`
	LastSeen       int64           `json:"last_seen"`
}

// TreasureTally is one story-grade find: the item's name, the zone it came out
// of, and whether it was a realm-first hoard. Unlike bosses and zones there is no
// repeat count — a named treasure is found once, so each is its own row.
type TreasureTally struct {
	Item  string `json:"item"`
	Zone  string `json:"zone"`
	First bool   `json:"first"` // first in the realm to pull this hoard
}

// EventsBySubject returns every fact about a character, newest first. This is the
// timeline *and* the trophy source: one read, aggregated in Go, because the pool
// is MaxOpenConns(1) and six COUNT queries against it would serialize behind each
// other for an answer that fits comfortably in memory (a busy adventurer
// accumulates tens of facts, not millions).
//
// limit <= 0 means no limit, and callers who count should use it. A limit here
// silently truncates a *tally*, not just a list: read 40 facts for a display cap
// and a veteran's kill count quietly stops at 40 and stays wrong forever. Cap the
// trail in the caller, after the counting is done.
//
// Facts where the character is the *opponent* are included: a duel they lost is
// part of their history, and only the subject field would otherwise carry it.
func EventsBySubject(name string, limit int) ([]AdvEvent, error) {
	if name == "" {
		return nil, nil
	}
	q := `
		SELECT guid, event_type, tier, subject, opponent, boss, zone, region,
		       level, tally, outcome, milestone, stakes, actors, occurred_at
		  FROM adventure_events
		 WHERE subject = ? OR opponent = ?
		 ORDER BY occurred_at DESC`
	args := []any{name, name}
	if limit > 0 {
		q += ` LIMIT ?`
		args = append(args, limit)
	}
	rows, err := Get().Query(q, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []AdvEvent
	for rows.Next() {
		var e AdvEvent
		var tier, subject, opponent, boss, zone, region, outcome, milestone, stakes, actors sql.NullString
		if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent,
			&boss, &zone, &region, &e.Level, &e.Tally, &outcome, &milestone,
			&stakes, &actors, &e.OccurredAt); err != nil {
			return nil, err
		}
		e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String
		e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String
		e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String
		if actors.String != "" {
			_ = json.Unmarshal([]byte(actors.String), &e.Actors)
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

// BuildTrophyCase counts a character's history out of their facts. Pure: it takes
// the rows EventsBySubject already read rather than querying again, so the who
// page pays for one database read and gets both the timeline and the trophies.
//
// Only facts where the character is the *subject* count toward trophies. They
// appear in an ally's dispatch as an opponent too, and crediting those would let
// a duel someone lost show up as a kill in their own case.
func BuildTrophyCase(name string, events []AdvEvent) TrophyCase {
	tc := TrophyCase{Name: name}
	bosses := map[string]*BossTally{}
	zones := map[string]*ZoneTally{}

	for _, e := range events {
		if tc.LastSeen == 0 || e.OccurredAt > tc.LastSeen {
			tc.LastSeen = e.OccurredAt
		}
		if tc.FirstSeen == 0 || e.OccurredAt < tc.FirstSeen {
			tc.FirstSeen = e.OccurredAt
		}
		tc.Events++

		if e.Subject != name {
			continue // they're the other party in someone else's dispatch
		}
		switch e.EventType {
		case "boss_kill", "boss_first":
			tc.BossKills++
			if e.Boss != "" {
				b := bosses[e.Boss]
				if b == nil {
					b = &BossTally{Boss: e.Boss}
					bosses[e.Boss] = b
				}
				b.Kills++
				if e.EventType == "boss_first" {
					b.First = true
				}
			}
			if e.EventType == "boss_first" {
				tc.BossFirsts++
			}
		case "zone_clear", "zone_first":
			tc.ZoneClears++
			if e.Zone != "" {
				z := zones[e.Zone]
				if z == nil {
					z = &ZoneTally{Zone: e.Zone, Region: e.Region}
					zones[e.Zone] = z
				}
				z.Clears++
				if e.EventType == "zone_first" {
					z.First = true
				}
			}
			if e.EventType == "zone_first" {
				tc.ZoneFirsts++
			}
		case "treasure_found":
			tc.TreasuresFound++
			// A realm-first hoard rides the priority tier, the same way zone_first
			// does; a plain story-grade find is a bulletin.
			first := e.Tier == "priority"
			if first {
				tc.TreasureFirsts++
			}
			if e.Stakes != "" {
				tc.Treasures = append(tc.Treasures, TreasureTally{Item: e.Stakes, Zone: e.Zone, First: first})
			}
		case "death":
			tc.Deaths++
		case "retreat":
			tc.Retreats++
		case "rival_result":
			tc.RivalWins++ // the fact's subject is the winner; see renderAdventure
		case "milestone":
			if e.Milestone != "" {
				tc.Milestones = append(tc.Milestones, e.Milestone)
			}
		case "mischief_survived":
			tc.Survived++
		case "mischief_downed":
			tc.Downed++
		}
	}

	for _, b := range bosses {
		tc.Bosses = append(tc.Bosses, *b)
	}
	for _, z := range zones {
		tc.Zones = append(tc.Zones, *z)
	}
	// Deterministic order: most-fought first, then alphabetical. The tiebreak is
	// not cosmetic — map iteration is randomized in Go, so without it the panel
	// reshuffles on every request and a cached page and a live one disagree.
	sort.Slice(tc.Bosses, func(i, j int) bool {
		if tc.Bosses[i].Kills != tc.Bosses[j].Kills {
			return tc.Bosses[i].Kills > tc.Bosses[j].Kills
		}
		return tc.Bosses[i].Boss < tc.Bosses[j].Boss
	})
	sort.Slice(tc.Zones, func(i, j int) bool {
		if tc.Zones[i].Clears != tc.Zones[j].Clears {
			return tc.Zones[i].Clears > tc.Zones[j].Clears
		}
		return tc.Zones[i].Zone < tc.Zones[j].Zone
	})
	return tc
}
