local addonName, TOAST = ...

-- Crew State is the single persistence and validation boundary for live
-- observations. Transport (comms.lua), presence, score resolution, and UI
-- consume this module; none of them writes raw observations directly.

local SCHEMA_VERSION = 1
local PROTOCOL_VERSION = 1

local KEY_FRESH_SECONDS = 60 * 60
local SCORE_FRESH_SECONDS = 24 * 60 * 60
local MAX_KEY_AGE_SECONDS = 8 * 24 * 60 * 60
local MAX_SCORE_AGE_SECONDS = 31 * 24 * 60 * 60
local MAX_SCORE = 100000
local MAX_KEY_LEVEL = 50

local SOURCE_PRIORITY = {
    BOT = 1,
    RELAYED = 2,
    LIBKS_DIRECT = 3,
    TOAST_DIRECT = 4,
    SELF = 5,
}

local VALID_SOURCES = {}
for source in pairs(SOURCE_PRIORITY) do
    VALID_SOURCES[source] = true
end

local memberByIdentity
local membersByName
local membersByDiscordId
local membersByNickname

-- string.lower corrupts non-ASCII UTF-8 (see TOAST.Utf8Lower in core.lua), and
-- this roster genuinely contains Cyrillic character and realm names, so every
-- fold at this identity boundary must go through the UTF-8-aware version.
local function Lower(text)
    if TOAST.Utf8Lower then return TOAST.Utf8Lower(text) end
    return text:lower()
end

-- Lua's %s character class is locale-dependent and matches byte 0xA0, which is
-- the SECOND byte of Cyrillic "Р" (U+0420 = D0 A0) among others. Using %s to
-- strip separators therefore tore a byte out of the middle of a UTF-8
-- character and left an invalid, unmatchable key. Every pattern that touches a
-- realm or character name uses explicit ASCII whitespace instead.
local ASCII_SPACE = " \t\r\n\f\v"
local TRIM_PATTERN = "^[" .. ASCII_SPACE .. "]*(.-)[" .. ASCII_SPACE .. "]*$"
local REALM_STRIP_PATTERN = "[" .. ASCII_SPACE .. "%-'%(%)%.]"

local function NormalizeName(name)
    if type(name) ~= "string" then return nil end
    name = name:match(TRIM_PATTERN)
    if name == "" then return nil end
    return Lower(name)
end

local function NormalizeRealm(realm)
    if type(realm) ~= "string" then return nil end
    -- WoW exposes a realm as "Tarren Mill", while exports and addon
    -- messages may spell the same realm as "Tarren-Mill", "TarrenMill" or
    -- "tarren-mill". Realm punctuation is presentation only at this identity
    -- boundary.
    realm = realm:gsub(REALM_STRIP_PATTERN, ""):match(TRIM_PATTERN)
    if realm == "" then return nil end
    return Lower(realm)
end

local function IdentityKey(name, realm)
    local normalizedName = NormalizeName(name)
    local normalizedRealm = NormalizeRealm(realm)
    if not normalizedName or not normalizedRealm then return nil end
    return normalizedName .. "-" .. normalizedRealm
end

-- Identity keys for one exported member row.
--
-- The bot now ships a canonical `realm_slug`, which is the authoritative
-- identity — but lookups also arrive from the live game as (UnitName,
-- GetRealmName), i.e. a display realm. Those two do not always fold to the
-- same string ("Aggra (Português)" vs "aggra-português"), so a row is indexed
-- under both and either spelling resolves.
--
-- TOAST.RealmIdentity fails closed when the bot marked the realm unresolved,
-- so such a row is never merged into a real realm's identity.
local function MemberIdentityKeys(member)
    local name = NormalizeName(member.name)
    if not name then return nil end

    local keys, seen = {}, {}
    local function add(realm)
        local key = realm and IdentityKey(member.name, realm) or nil
        if key and not seen[key] then
            seen[key] = true
            keys[#keys + 1] = key
        end
    end

    add(TOAST.RealmIdentity and TOAST.RealmIdentity(member) or nil)
    if member.realm_resolved ~= false then
        add(member.realm)
    end

    if #keys == 0 then return nil end
    return keys
end

-- The single canonical identity key for an exported member row.
--
-- Every consumer that needs to key a roster row -- the crew index, the online
-- roster in comms.lua -- must go through this rather than concatenating a name
-- with a realm itself. A raw realm_slug ("tarren-mill") and a live WoW realm
-- ("TarrenMill", "Tarren Mill") are different strings for the same realm, so a
-- hand-rolled join produces keys that never match across those sources.
local function MemberIdentity(member)
    if type(member) ~= "table" then return nil end
    local keys = MemberIdentityKeys(member)
    return keys and keys[1] or nil
end

-- A Discord nickname is a display label, not an identity: it may carry a roster
-- prefix ("🌊ᴛɪᴅᴇ Jane Doe") and it is not guaranteed unique. Normalizing only
-- makes it comparable; ResolveCrewIdentity below decides when it may be trusted.
local function NormalizeNickname(nickname)
    if type(nickname) ~= "string" then return nil end

    -- ParseDiscordPrefix lives in leaderboard.lua, which loads after this file,
    -- so it is called lazily through TOAST and never captured as an upvalue.
    -- Its second return strips the first token unconditionally, so that value
    -- is only usable when the first return confirms a known prefix -- otherwise
    -- a prefixless "Jane Doe" would silently become "Doe".
    local ok, prefix, rest = pcall(TOAST.ParseDiscordPrefix, nickname)
    if ok and prefix and type(rest) == "string" then
        nickname = rest
    end

    nickname = nickname:gsub("[" .. ASCII_SPACE .. "]+", " "):match(TRIM_PATTERN)
    if nickname == "" then return nil end
    return Lower(nickname)
end

local function AddToMultimap(map, key, member)
    if not key then return end
    local matches = map[key]
    if not matches then
        matches = {}
        map[key] = matches
    end
    matches[#matches + 1] = member
end

local function EnsureMemberIndexes()
    if memberByIdentity then return end

    memberByIdentity = {}
    membersByName = {}
    membersByDiscordId = {}

    for _, member in ipairs(TOAST.members or {}) do
        if type(member) == "table" then
            local identities = MemberIdentityKeys(member)
            local name = NormalizeName(member.name)
            if identities and name then
                for _, identity in ipairs(identities) do
                    memberByIdentity[identity] = member
                end
                AddToMultimap(membersByName, name, member)

                -- Only Discord-linked characters can carry a crew identity, so
                -- unlinked roster entries stay out of the crew index.
                if member.discord_id then
                    AddToMultimap(membersByDiscordId, member.discord_id, member)
                end
            end
        end
    end
end

-- Built separately from the indexes above, and only once ParseDiscordPrefix is
-- actually loaded: an index built without it would store raw nicknames while
-- later lookups strip the prefix, so the two sides would never match.
local function EnsureNicknameIndex()
    if membersByNickname then return true end
    if type(TOAST.ParseDiscordPrefix) ~= "function" then return false end

    EnsureMemberIndexes()
    membersByNickname = {}
    for _, members in pairs(membersByDiscordId) do
        for _, member in ipairs(members) do
            AddToMultimap(membersByNickname, NormalizeNickname(member.discord_nickname), member)
        end
    end
    return true
end

local function ResolveMember(name, realm)
    EnsureMemberIndexes()

    local identity = IdentityKey(name, realm)
    if identity then return memberByIdentity[identity] end
    if NormalizeRealm(realm) then return nil end

    local matches = membersByName[NormalizeName(name)]
    if matches and #matches == 1 then return matches[1] end
    return nil
end

local function UniqueMemberByName(name)
    local matches = membersByName[NormalizeName(name) or ""]
    if matches and #matches == 1 then return matches[1] end
    return nil
end

-- A nickname only identifies a crew when every character carrying it belongs to
-- the same Discord id. Display names are not unique, and joining two crews on a
-- collision would show one person's alt as another person's presence.
local function CrewIdByNickname(nickname)
    if not EnsureNicknameIndex() then return nil end

    local matches = membersByNickname[NormalizeNickname(nickname) or ""]
    if not matches then return nil end

    local crewId
    for _, member in ipairs(matches) do
        if crewId and member.discord_id ~= crewId then return nil end
        crewId = member.discord_id
    end
    return crewId
end

-- Answers the two separate questions a leaderboard/dashboard row asks about a
-- roster entry, because they can have different answers:
--   member   -- which CHARACTER this row is. Never taken from the nickname
--               fallback: whisper and invite targets are built from it, so a
--               display-name match must not swap in someone else's character.
--   discordId -- which CREW it belongs to. An exact character match that is not
--               Discord-linked still leaves this open, so resolution continues
--               through the unique-name and nickname fallbacks; without it an
--               online alt can never light up the row of its main.
local function ResolveCrewIdentity(name, realm, discordNickname)
    EnsureMemberIndexes()

    local member = ResolveMember(name, realm) or UniqueMemberByName(name)
    local discordId = member and member.discord_id
    if not discordId then
        local named = UniqueMemberByName(name)
        discordId = named and named.discord_id or CrewIdByNickname(discordNickname)
    end
    return member, discordId
end

-- The row-shaped form of ResolveCrewIdentity: leaderboard rows, roster entries
-- and key rows all carry name/realm/discord_nickname, so unpacking them belongs
-- here rather than copied into each UI module.
local function ResolveEntry(entry)
    if type(entry) ~= "table" then return nil, nil end
    return ResolveCrewIdentity(entry.name, entry.realm, entry.discord_nickname)
end

-- The character actually logged in, resolved fresh on every call.
--
-- Deliberately separate from the remembered Discord identity in TOAST_DB.me:
-- that one answers "does this installation know who is playing", which stays
-- true across a character swap. This answers "may this character act as crew",
-- and is the ONLY acceptable source for that. isRosterMember false means
-- roster external: no crew membership, no TIDE/CPT role, no broker or guild
-- transport, no own key/score contribution.
local function GetActiveCharacter()
    local name, realm = UnitName("player"), GetRealmName()
    local member = ResolveMember(name, realm)
    return {
        name = name,
        realm = realm,
        classToken = select(2, UnitClass("player")),
        identityKey = IdentityKey(name, realm),
        member = member,
        isRosterMember = member ~= nil,
    }
end

local function GetCrewMembers(discordId)
    EnsureMemberIndexes()

    local copy = {}
    for _, member in ipairs(discordId and membersByDiscordId[discordId] or {}) do
        copy[#copy + 1] = member
    end
    return copy
end

local function GetCrewMemberCount(discordId)
    EnsureMemberIndexes()

    local matches = discordId and membersByDiscordId[discordId]
    return matches and #matches or 0
end

local function EnsureDB()
    TOAST_DB = TOAST_DB or {}
    local root = TOAST_DB.crewState
    if type(root) ~= "table" then
        root = {}
        TOAST_DB.crewState = root
    end
    root.schemaVersion = SCHEMA_VERSION
    if type(root.characters) ~= "table" then root.characters = {} end
    if type(root.sync) ~= "table" then root.sync = {} end
    if type(root.capabilities) ~= "table" then root.capabilities = {} end

    -- This is a realm-provided Unix timestamp, so it handles regional reset
    -- times and daylight saving correctly.  A new week invalidates keys, not
    -- scores or capability observations.
    local resetStart = C_DateAndTime and C_DateAndTime.GetWeeklyResetStartTime
    if resetStart then
        local ok, weekId = pcall(resetStart)
        if ok and type(weekId) == "number" and weekId > 0 then
            weekId = tostring(math.floor(weekId))
            if root.sync.keyCacheWeekId and root.sync.keyCacheWeekId ~= weekId then
                for _, character in pairs(root.characters) do
                    if type(character) == "table" then character.key = nil end
                end
            end
            root.sync.keyCacheWeekId = weekId
        end
    end
    return root
end

local function IsFiniteNumber(value)
    return type(value) == "number" and value == value
end

local function ValidSource(source)
    return VALID_SOURCES[source] and source or nil
end

local function ValidObservedAt(value, fallback)
    value = tonumber(value)
    if not IsFiniteNumber(value) or value <= 0 then return fallback end
    return math.floor(value)
end

local function SourceWins(newSource, oldSource)
    return (SOURCE_PRIORITY[newSource] or 0) > (SOURCE_PRIORITY[oldSource] or 0)
end

local function Clone(record)
    if type(record) ~= "table" then return nil end
    local copy = {}
    for key, value in pairs(record) do copy[key] = value end
    return copy
end

local function CharacterRecord(member)
    local root = EnsureDB()
    local identity = IdentityKey(member.name, member.realm)
    if not identity then return nil end

    local record = root.characters[identity]
    if type(record) ~= "table" then
        record = {
            name = member.name,
            realm = member.realm,
        }
        root.characters[identity] = record
    else
        record.name = member.name
        record.realm = member.realm
    end
    return record, identity
end

local function ValidKey(mapID, level)
    mapID = tonumber(mapID)
    level = tonumber(level)
    if not IsFiniteNumber(mapID) or not IsFiniteNumber(level) then return nil end
    mapID, level = math.floor(mapID), math.floor(level)
    if mapID == 0 and level == 0 then return 0, 0 end
    if mapID <= 0 or level <= 0 or level > MAX_KEY_LEVEL then return nil end
    return mapID, level
end

local function ValidScore(seasonID, score)
    seasonID = tonumber(seasonID)
    score = tonumber(score)
    if not IsFiniteNumber(seasonID) or not IsFiniteNumber(score) then return nil end
    seasonID, score = math.floor(seasonID), math.floor(score)
    if seasonID <= 0 or score < 0 or score > MAX_SCORE then return nil end
    return seasonID, score
end

local function KeyShouldReplace(current, candidate)
    if type(current) ~= "table" then return true end
    if candidate.observedAt > (tonumber(current.observedAt) or 0) then return true end
    if candidate.observedAt < (tonumber(current.observedAt) or 0) then return false end
    return SourceWins(candidate.source, current.source)
end

local function ScoreShouldReplace(current, candidate, currentSeasonID)
    if type(current) ~= "table" then return true end

    local oldSeason = tonumber(current.seasonID)
    if oldSeason and oldSeason ~= candidate.seasonID then
        -- A known new current season replaces an old-season observation even
        -- when its numerical score starts lower. Without that live context,
        -- retain the old record rather than guessing which season is current.
        return currentSeasonID ~= nil and candidate.seasonID == currentSeasonID
    end
    if candidate.score < (tonumber(current.score) or 0) then return false end
    if candidate.score > (tonumber(current.score) or 0) then return true end
    if candidate.observedAt > (tonumber(current.observedAt) or 0) then return true end
    if candidate.observedAt < (tonumber(current.observedAt) or 0) then return false end
    return SourceWins(candidate.source, current.source)
end

local function NotifyChanged(kind, member)
    if kind == "key" and type(TOAST.RefreshKeyColumn) == "function" then
        TOAST.RefreshKeyColumn()
    end
    if type(TOAST.OnCrewStateChanged) == "function" then
        TOAST.OnCrewStateChanged(kind, member)
    end
end

function TOAST.MergeCrewKey(name, realm, mapID, level, source, observedAt)
    local member = ResolveMember(name, realm)
    if not member then return nil, "unknown_member" end

    mapID, level = ValidKey(mapID, level)
    if not mapID then return nil, "invalid_key" end
    source = ValidSource(source)
    if not source then return nil, "invalid_source" end

    local now = GetServerTime()
    observedAt = ValidObservedAt(observedAt, now)
    if observedAt > now + 60 then observedAt = now end
    if now - observedAt > MAX_KEY_AGE_SECONDS then return nil, "stale_key" end

    local record = CharacterRecord(member)
    if not record then return nil, "invalid_identity" end

    local candidate = {
        mapID = mapID,
        level = level,
        observedAt = observedAt,
        source = source,
    }
    if not KeyShouldReplace(record.key, candidate) then
        return Clone(record.key), "unchanged"
    end

    record.key = candidate
    NotifyChanged("key", member)
    return Clone(candidate), "updated"
end

function TOAST.MergeCrewScore(name, realm, seasonID, score, source, observedAt, currentSeasonID)
    local member = ResolveMember(name, realm)
    if not member then return nil, "unknown_member" end

    seasonID, score = ValidScore(seasonID, score)
    if not seasonID then return nil, "invalid_score" end
    if currentSeasonID and seasonID ~= currentSeasonID then
        return nil, "season_mismatch"
    end
    source = ValidSource(source)
    if not source then return nil, "invalid_source" end

    local now = GetServerTime()
    observedAt = ValidObservedAt(observedAt, now)
    if observedAt > now + 60 then observedAt = now end
    if now - observedAt > MAX_SCORE_AGE_SECONDS then return nil, "stale_score" end

    local record = CharacterRecord(member)
    if not record then return nil, "invalid_identity" end

    local candidate = {
        seasonID = seasonID,
        score = score,
        observedAt = observedAt,
        source = source,
    }
    if not ScoreShouldReplace(record.score, candidate, currentSeasonID) then
        return Clone(record.score), "unchanged"
    end

    record.score = candidate
    NotifyChanged("score", member)
    return Clone(candidate), "updated"
end

function TOAST.GetCrewKey(name, realm)
    local member = ResolveMember(name, realm)
    if not member then return nil end
    local root = EnsureDB()
    local record = root.characters[IdentityKey(member.name, member.realm)]
    local key = record and Clone(record.key)
    if not key then return nil end
    key.fresh = GetServerTime() - key.observedAt < KEY_FRESH_SECONDS
    return key
end

-- Presentation accessor for the existing UI.  It derives its value directly
-- from Crew State and never creates a parallel key cache.
function TOAST.GetCrewKeyDisplay(name, realm)
    local key = TOAST.GetCrewKey(name, realm)
    if not key or not key.mapID or key.mapID <= 0 then return nil end
    return {
        level = key.level,
        short = TOAST.GetDungeonShort and TOAST.GetDungeonShort(key.mapID) or "",
        lastSeen = key.observedAt,
        source = key.source,
        mapID = key.mapID,
        fresh = key.fresh,
    }
end

function TOAST.GetKnownCrewKeyCount()
    local count = 0
    for _, character in pairs(EnsureDB().characters) do
        local key = type(character) == "table" and character.key or nil
        if key and key.mapID and key.mapID > 0 and key.level and key.level > 0 then
            count = count + 1
        end
    end
    return count
end

function TOAST.GetPeerScoreObservation(name, realm, currentSeasonID)
    local member = ResolveMember(name, realm)
    if not member then return nil end
    local root = EnsureDB()
    local record = root.characters[IdentityKey(member.name, member.realm)]
    local score = record and Clone(record.score)
    if not score then return nil end
    if currentSeasonID and score.seasonID ~= currentSeasonID then return nil end
    score.fresh = GetServerTime() - score.observedAt < SCORE_FRESH_SECONDS
    return score
end

function TOAST.GetCrewStateSync()
    return Clone(EnsureDB().sync) or {}
end

function TOAST.GetCrewStateWeekId()
    return EnsureDB().sync.keyCacheWeekId
end

function TOAST.SetCrewStateSyncField(field, value)
    if type(field) ~= "string" or field == "" then return end
    EnsureDB().sync[field] = value
end

function TOAST.MarkCrewCapability(name, realm, protocolVersion, addonVersion)
    local member = ResolveMember(name, realm)
    if not member then return false end
    protocolVersion = tonumber(protocolVersion)
    if protocolVersion ~= PROTOCOL_VERSION then return false end
    if type(addonVersion) ~= "string" or addonVersion == "" or #addonVersion > 32 then
        return false
    end

    local root = EnsureDB()
    local identity = IdentityKey(member.name, member.realm)
    root.capabilities[identity] = {
        name = member.name,
        realm = member.realm,
        protocolVersion = protocolVersion,
        addonVersion = addonVersion,
        lastResponseAt = GetServerTime(),
    }
    return true
end

function TOAST.GetCrewCapability(name, realm)
    local member = ResolveMember(name, realm)
    if not member then return nil end
    local record = EnsureDB().capabilities[IdentityKey(member.name, member.realm)]
    if type(record) ~= "table" then return nil end
    local observed = tonumber(record.lastResponseAt)
    if not observed or GetServerTime() - observed > 7 * 24 * 60 * 60 then return nil end
    return Clone(record)
end

local function PercentEncode(value)
    return (value:gsub("[^%w]", function(char)
        return string.format("%%%02X", string.byte(char))
    end))
end

local function PercentDecode(value)
    if type(value) ~= "string" then return nil end
    local ok, decoded = pcall(function()
        return (value:gsub("%%(%x%x)", function(hex)
            return string.char(tonumber(hex, 16))
        end))
    end)
    if not ok then return nil end
    return decoded
end

function TOAST.EncodeCrewIdentity(name, realm)
    local member = ResolveMember(name, realm)
    if not member then return nil end
    return PercentEncode(member.name) .. "~" .. PercentEncode(member.realm)
end

function TOAST.DecodeCrewIdentity(identity)
    if type(identity) ~= "string" or #identity > 100 then return nil end
    local name, realm = identity:match("^([^~]+)~([^~]+)$")
    if not name then return nil end
    return PercentDecode(name), PercentDecode(realm)
end

function TOAST.BuildCrewCacheRecords()
    local records, now = {}, GetServerTime()
    for _, character in pairs(EnsureDB().characters) do
        if type(character) == "table" and character.name and character.realm then
            local identity = TOAST.EncodeCrewIdentity(character.name, character.realm)
            local key = character.key
            if identity and key and key.mapID and key.level and key.observedAt then
                records[#records + 1] = table.concat({ "K", identity, key.mapID, key.level,
                    math.max(0, now - key.observedAt) }, ",")
            end
            local score = character.score
            if identity and score and score.seasonID and score.score and score.observedAt then
                records[#records + 1] = table.concat({ "R", identity, score.seasonID, score.score,
                    math.max(0, now - score.observedAt) }, ",")
            end
        end
    end
    table.sort(records)
    return records
end

function TOAST.MergeCrewCacheRecord(raw, sender, currentSeasonID)
    if type(raw) ~= "string" or #raw > 180 then return nil, "invalid_record" end
    local kind, identity, a, b, age = raw:match("^([KR]),([^,]+),([^,]+),([^,]+),(%d+)$")
    if not kind then return nil, "invalid_record" end
    local name, realm = TOAST.DecodeCrewIdentity(identity)
    local senderName, senderRealm = sender and sender:match("^([^%-]+)%-(.+)$")
    if not senderName and sender then senderName = sender end
    local source = (senderName and NormalizeName(senderName) == NormalizeName(name)
        and NormalizeRealm(senderRealm) == NormalizeRealm(realm)) and "TOAST_DIRECT" or "RELAYED"
    age = math.min(tonumber(age) or 0, kind == "K" and MAX_KEY_AGE_SECONDS or MAX_SCORE_AGE_SECONDS)
    local observedAt = GetServerTime() - age
    if kind == "K" then
        return TOAST.MergeCrewKey(name, realm, a, b, source, observedAt)
    end
    return TOAST.MergeCrewScore(name, realm, a, b, source, observedAt, currentSeasonID)
end

-- Envelope parsing is intentionally transport-agnostic. Message-specific
-- validation belongs to comms.lua; this helper only accepts safe CS1 framing.
function TOAST.ParseCrewProtocolMessage(message)
    if type(message) ~= "string" or #message > 255 then return nil, "invalid_length" end
    local fields = {}
    for field in (message .. "|"):gmatch("(.-)|") do
        fields[#fields + 1] = field
        if #fields > 12 then return nil, "too_many_fields" end
    end
    if fields[1] ~= "CS1" or type(fields[2]) ~= "string" or fields[2] == "" then
        return nil, "not_crew_state"
    end
    return {
        version = PROTOCOL_VERSION,
        type = fields[2],
        fields = fields,
    }
end

TOAST.CrewState = {
    SCHEMA_VERSION = SCHEMA_VERSION,
    PROTOCOL_VERSION = PROTOCOL_VERSION,
    KEY_FRESH_SECONDS = KEY_FRESH_SECONDS,
    SCORE_FRESH_SECONDS = SCORE_FRESH_SECONDS,
    SOURCE_PRIORITY = SOURCE_PRIORITY,
    ResolveMember = ResolveMember,
    ResolveCrewIdentity = ResolveCrewIdentity,
    ResolveEntry = ResolveEntry,
    GetActiveCharacter = GetActiveCharacter,
    GetCrewMembers = GetCrewMembers,
    GetCrewMemberCount = GetCrewMemberCount,
    IdentityKey = IdentityKey,
    MemberIdentity = MemberIdentity,
    MemberIdentityKeys = MemberIdentityKeys,
    NormalizeRealm = NormalizeRealm,
}

-- Promoted to the namespace root: "which character am I on, and is it crew?"
-- is asked from UI, comms and scores alike, and every one of those must get
-- the same answer from the same resolver.
TOAST.GetActiveCharacter = GetActiveCharacter
