AddonDiagnostics = AddonDiagnostics or {}
AddonDiagnostics.IncidentStore = {}
local IncidentStore = AddonDiagnostics.IncidentStore

local STORE_SCHEMA_VERSION = 2
local MAX_INCIDENTS = 20
local MAX_MESSAGE_CHARACTERS = 1200
local MAX_STACK_CHARACTERS = 5000
local MAX_SIGNATURE_STACK_CHARACTERS = 900
local MAX_LABEL_CHARACTERS = 160
local MAX_BUILD_CHARACTERS = 240
local MAX_STACK_CONTEXT_CHARACTERS = 640
local MAX_RELATED_INCIDENTS = 5
local MAX_REDACTIONS = 24
local MAX_ENHANCED_PROVIDER_ID_CHARACTERS = 80
local MAX_ENHANCED_BREADCRUMB_CONTEXT_CHARACTERS = 12000
local MAX_ENHANCED_SNAPSHOT_CONTEXT_CHARACTERS = 6000
local MAX_ENHANCED_INTEGRITY_CHARACTERS = 640

local function IsAccessibleValue(value)
    if type(issecretvalue) == "function" and issecretvalue(value) then
        return false
    end
    if type(canaccessvalue) == "function" and not canaccessvalue(value) then
        return false
    end
    return true
end

local function BoundString(value, maximumCharacters, fallback)
    if not IsAccessibleValue(value) or type(value) ~= "string" or value == "" then
        return fallback
    end
    if string.len(value) > maximumCharacters then
        return string.sub(value, 1, maximumCharacters)
    end
    return value
end
local function IsDelimiter(character)
    return character == " " or character == "\t" or character == "\n"
        or character == "|" or character == "," or character == ";"
        or character == "<" or character == ">" or character == "("
        or character == ")" or character == "[" or character == "]"
        or character == '"' or character == "'"
end

local function FindTokenStart(text, position)
    for index = position, 1, -1 do
        if IsDelimiter(string.sub(text, index, index)) then
            return index + 1
        end
    end
    return 1
end

local function FindTokenEnd(text, position)
    local length = string.len(text)
    for index = position, length do
        if IsDelimiter(string.sub(text, index, index)) then
            return index
        end
    end
    return length + 1
end

local function RedactEmailTokens(text)
    local output = text
    local searchStart = 1
    for index = 1, MAX_REDACTIONS do
        local atIndex = string.find(output, "@", searchStart, true)
        if not atIndex then return output end
        local tokenStart = FindTokenStart(output, atIndex)
        local tokenEnd = FindTokenEnd(output, atIndex + 1)
        if tokenStart < atIndex and tokenEnd > atIndex + 1 then
            output = string.format("%s[email redacted]%s", string.sub(output, 1, tokenStart - 1), string.sub(output, tokenEnd))
            searchStart = tokenStart + 16
        else
            searchStart = atIndex + 1
        end
    end
    return output
end

local SENSITIVE_MARKERS = "password=|passwd=|credential=|token=|secret=|authorization=|auth=|email=|account=|userid=|username=|player=|character=|guild=|bnet=|battletag=|apikey="

local function RedactAssignment(text, marker)
    local output = text
    local searchStart = 1
    for index = 1, MAX_REDACTIONS do
        local lowered = string.lower(output)
        local markerStart = string.find(lowered, marker, searchStart, true)
        if not markerStart then return output end
        local valueStart = markerStart + string.len(marker)
        local valueEnd = FindTokenEnd(output, valueStart)
        output = string.format("%s[redacted]%s", string.sub(output, 1, valueStart - 1), string.sub(output, valueEnd))
        searchStart = valueStart + 10
    end
    return output
end

local function RedactSensitiveAssignments(text)
    local output = text
    local markerStart = 1
    for index = 1, 18 do
        local markerEnd = string.find(SENSITIVE_MARKERS, "|", markerStart, true)
        local marker = nil
        if markerEnd then marker = string.sub(SENSITIVE_MARKERS, markerStart, markerEnd - 1) else marker = string.sub(SENSITIVE_MARKERS, markerStart) end
        if marker ~= "" then output = RedactAssignment(output, marker) end
        if not markerEnd then return output end
        markerStart = markerEnd + 1
    end
    return output
end

local function RedactMarkerTokens(text, marker, replacement)
    local output = text
    local searchStart = 1
    for index = 1, MAX_REDACTIONS do
        local lowered = string.lower(output)
        local markerPosition = string.find(lowered, marker, searchStart, true)
        if not markerPosition then return output end
        local tokenStart = FindTokenStart(output, markerPosition)
        local tokenEnd = FindTokenEnd(output, markerPosition + string.len(marker))
        output = string.format("%s%s%s", string.sub(output, 1, tokenStart - 1), replacement, string.sub(output, tokenEnd))
        searchStart = tokenStart + string.len(replacement)
    end
    return output
end

local function RedactIdentityLinkMarker(text, marker)
    local output = text
    local searchStart = 1
    for index = 1, MAX_REDACTIONS do
        local lowered = string.lower(output)
        local linkStart = string.find(lowered, marker, searchStart, true)
        if not linkStart then return output end
        local firstLinkEnd = string.find(lowered, "|h", linkStart, true)
        if not firstLinkEnd then return RedactMarkerTokens(output, marker, "[identity redacted]") end
        local secondLinkEnd = string.find(lowered, "|h", firstLinkEnd + 2, true)
        local replaceEnd = firstLinkEnd + 1
        if secondLinkEnd then replaceEnd = secondLinkEnd + 1 end
        output = string.format("%s[identity redacted]%s", string.sub(output, 1, linkStart - 1), string.sub(output, replaceEnd + 1))
        searchStart = linkStart + 19
    end
    return output
end

local function RedactIdentityLinks(text)
    local output = text
    output = RedactIdentityLinkMarker(output, "|hplayer:")
    output = RedactIdentityLinkMarker(output, "|hbnplayer:")
    output = RedactIdentityLinkMarker(output, "|hguild:")
    return output
end

local function SanitizeText(value, maximumCharacters, fallback)
    local text = BoundString(value, maximumCharacters, fallback)
    text = RedactSensitiveAssignments(text)
    text = RedactEmailTokens(text)
    text = RedactIdentityLinks(text)
    text = RedactMarkerTokens(text, "player-", "[player id redacted]")
    text = RedactMarkerTokens(text, "bearer ", "[credential redacted]")
    if string.len(text) > maximumCharacters then
        text = string.sub(text, 1, maximumCharacters)
    end
    return text
end

local function GetTimestamp()
    if type(GetServerTime) == "function" then
        return math.max(0, tonumber(GetServerTime()) or 0)
    end
    if type(time) == "function" then
        return math.max(0, tonumber(time()) or 0)
    end
    return 0
end

local function GetClientBuild()
    if type(GetBuildInfo) ~= "function" then return "Client build unavailable" end
    local version, build, buildDate, interfaceVersion = GetBuildInfo()
    return SanitizeText(string.format("Client %s | Build %s | Date %s | Interface %s", tostring(version or "unknown"), tostring(build or "unknown"), tostring(buildDate or "unknown"), tostring(interfaceVersion or "unknown")), MAX_BUILD_CHARACTERS, "Client build unavailable")
end

local function GetAddonVersion(name)
    if not name or name == "" then return "unknown" end
    local value = nil
    if C_AddOns and C_AddOns.GetAddOnMetadata then
        value = C_AddOns.GetAddOnMetadata(name, "Version")
    elseif type(GetAddOnMetadata) == "function" then
        value = GetAddOnMetadata(name, "Version")
    end
    return SanitizeText(value, 64, "version not declared")
end

local function ExtractAddonName(text, marker, searchStart)
    local markerStart = string.find(text, marker, searchStart, true)
    if not markerStart then return nil, nil end
    local nameStart = markerStart + string.len(marker)
    local textLength = string.len(text)
    for index = nameStart, textLength do
        local character = string.sub(text, index, index)
        if character == "/" or character == "\\" or character == ":" or character == "\n" then
            return string.sub(text, nameStart, index - 1), index
        end
    end
    return string.sub(text, nameStart), textLength + 1
end

local function FindRelevantAddon(message, stack)
    local combined = string.format("%s\n%s", message, stack)
    local markers = "Interface/AddOns/|Interface\\AddOns\\"
    local markerStart = 1
    for markerIndex = 1, 2 do
        local markerEnd = string.find(markers, "|", markerStart, true)
        local marker = nil
        if markerEnd then marker = string.sub(markers, markerStart, markerEnd - 1) else marker = string.sub(markers, markerStart) end
        local searchStart = 1
        for occurrence = 1, 16 do
            local name, nextStart = ExtractAddonName(combined, marker, searchStart)
            if name then
                if name ~= "AddonDiagnostics" and string.sub(name, 1, 9) ~= "Blizzard_" and name ~= "" then
                    return SanitizeText(name, 80, "Undetermined")
                end
                searchStart = nextStart or (searchStart + 1)
            else
                searchStart = string.len(combined) + 1
            end
        end
        if markerEnd then markerStart = markerEnd + 1 end
    end
    return nil
end

local function FindExternalFrameLine(stack)
    local text = BoundString(stack, MAX_STACK_CHARACTERS, "")
    local lineStart = 1
    for lineIndex = 1, 64 do
        local lineEnd = string.find(text, "\n", lineStart, true)
        local line = nil
        if lineEnd then line = string.sub(text, lineStart, lineEnd - 1) else line = string.sub(text, lineStart) end
        if FindRelevantAddon("", line) then
            return SanitizeText(line, 320, "unavailable")
        end
        if not lineEnd then return nil end
        lineStart = lineEnd + 1
    end
    return nil
end

local function FindRelevantModule(message, stack)
    local addonName = FindRelevantAddon(message, stack)
    if not addonName then return nil end
    local line = FindExternalFrameLine(stack)
    if not line then return nil end
    local forwardMarker = string.format("Interface/AddOns/%s/", addonName)
    local backwardMarker = string.format("Interface\\AddOns\\%s\\", addonName)
    local marker = forwardMarker
    local markerStart = string.find(line, forwardMarker, 1, true)
    if not markerStart then
        marker = backwardMarker
        markerStart = string.find(line, backwardMarker, 1, true)
    end
    if not markerStart then return nil end
    local pathStart = markerStart + string.len(marker)
    local pathEnd = string.len(line) + 1
    for index = pathStart, string.len(line) do
        local character = string.sub(line, index, index)
        if pathEnd > string.len(line) and (character == "]" or character == ":" or character == " " or character == "\t") then
            pathEnd = index
        end
    end
    local moduleName = string.sub(line, pathStart, pathEnd - 1)
    if moduleName == "" then return nil end
    return SanitizeText(string.format("%s/%s", addonName, moduleName), 200, "unavailable")
end

local function ClassifyIncident(message, stack)
    local result = {}
    local combinedLower = string.lower(string.format("%s\n%s", message, stack))
    local addonName = FindRelevantAddon(message, stack)
    local secretTaint = string.find(combinedLower, "secret number value", 1, true) ~= nil
        or string.find(combinedLower, "secret value", 1, true) ~= nil
        or string.find(combinedLower, "execution tainted by", 1, true) ~= nil
    local executionTimeout = string.find(combinedLower, "script ran too long", 1, true) ~= nil
    local restricted = string.find(combinedLower, "restrictedaddonenvironment", 1, true) ~= nil
        or string.find(combinedLower, "invalid frame handle", 1, true) ~= nil
        or string.find(combinedLower, "protected", 1, true) ~= nil
    result.sourceHeading = "Likely Source"
    if secretTaint then
        result.category = "Secret-value / taint boundary"
        result.whatHappened = "World of Warcraft stopped an operation because addon execution attempted to use a protected secret value after taint was present. The stack shows where the restricted value surfaced, not necessarily where the taint originated."
        result.detectedIn = "Protected/secret value boundary"
    elseif executionTimeout then
        result.category = "Lua execution timeout"
        result.whatHappened = "World of Warcraft stopped a Lua execution path because it ran for too long. The terminal stack line identifies where the watchdog stopped execution, but the expensive work may have accumulated earlier in the same call path."
        result.detectedIn = "Lua execution watchdog"
    elseif restricted then
        result.category = "Restricted/protected UI operation"
        result.whatHappened = "World of Warcraft rejected or failed a restricted UI/frame operation. The place where the game detected the problem is not necessarily the addon that originally caused the bad UI state."
        result.detectedIn = "Blizzard restricted/UI-frame handling"
    else
        result.category = "Lua runtime error"
        result.whatHappened = "A Lua error occurred while addon or Blizzard UI code was running. Addon Diagnostics captured the error and stack without requiring a recording to be started first."
        result.detectedIn = "Lua runtime"
    end
    if addonName then
        result.likelySource = addonName
        result.sourceVersion = GetAddonVersion(addonName)
        result.confidence = "Medium"
        if secretTaint then
            result.nextStep = string.format("Send this report to the %s developer/support location with the action you were taking when the secret-value/taint failure appeared. The stack makes that addon relevant, but does not prove where taint originated.", addonName)
        elseif executionTimeout then
            result.nextStep = string.format("Send this report to the %s developer/support location with the action that preceded the timeout. Treat the watchdog line as the stop point and the surrounding call path as the useful performance evidence.", addonName)
        else
            result.nextStep = string.format("If the problem matches something you were doing in %s, send this report to that addon's developer/support location. The stack makes it relevant, but does not prove it is the root cause.", addonName)
        end
        result.reportDestination = string.format("%s developer/support page or community", addonName)
    else
        result.likelySource = "Undetermined"
        result.sourceVersion = "not determined"
        result.confidence = "Low"
        result.nextStep = "Include this report with the addon or feature you were using immediately before the problem. If it repeats, use Start Recorder for a deeper reproduction while keeping this incident report."
        result.reportDestination = "The addon or feature used immediately before the incident; this stack does not prove a single third-party source."
    end
    return result
end

local function ApplyEnhancedIncidentContext(incident, providerHint)
    incident.enhancedProviderID = "none"
    incident.enhancedBreadcrumbContext = ""
    incident.enhancedSnapshotContext = ""
    incident.enhancedIntegrity = "No matching registered Enhanced provider was identified for this incident."

    local broker = AddonDiagnostics.EnhancedDiagnosticsBroker
    if not broker or type(broker.ResolveRegisteredProviderID) ~= "function" then
        incident.enhancedIntegrity = "Enhanced provider registry unavailable when the incident was captured."
        return
    end
    local providerID = broker:ResolveRegisteredProviderID(providerHint)
    if providerID == nil then return end
    incident.enhancedProviderID = SanitizeText(providerID, MAX_ENHANCED_PROVIDER_ID_CHARACTERS, "none")

    local breadcrumbCount, evictedCount, droppedCount = 0, 0, 0
    if type(broker.GetPassiveBreadcrumbEvidence) == "function" then
        local evidence, capturedCount, evicted, dropped = broker:GetPassiveBreadcrumbEvidence(providerID)
        breadcrumbCount = tonumber(capturedCount) or 0
        evictedCount = tonumber(evicted) or 0
        droppedCount = tonumber(dropped) or 0
        incident.enhancedBreadcrumbContext = SanitizeText(evidence, MAX_ENHANCED_BREADCRUMB_CONTEXT_CHARACTERS, "")
    end

    local snapshotAccepted = false
    local snapshotReason = "snapshot requester unavailable"
    local snapshots = AddonDiagnostics.EnhancedDiagnosticsSnapshots
    if snapshots and type(snapshots.CaptureIncidentSnapshot) == "function" then
        local accepted, evidence, reason = snapshots:CaptureIncidentSnapshot(providerID)
        snapshotAccepted = accepted == true
        snapshotReason = reason
        if snapshotAccepted then
            incident.enhancedSnapshotContext = SanitizeText(evidence, MAX_ENHANCED_SNAPSHOT_CONTEXT_CHARACTERS, "")
        end
    end
    incident.enhancedIntegrity = SanitizeText(string.format(
        "Provider: %s | Recent passive breadcrumbs attached: %d | Passive breadcrumbs evicted: %d | Passive breadcrumbs dropped: %d | Incident snapshot: %s | Snapshot note: %s",
        providerID, tonumber(breadcrumbCount) or 0, tonumber(evictedCount) or 0, tonumber(droppedCount) or 0,
        snapshotAccepted and "accepted" or "not attached", tostring(snapshotReason or "none")
    ), MAX_ENHANCED_INTEGRITY_CHARACTERS, "Enhanced incident integrity unavailable")
end

local function NotifyChanged()
    local hud = AddonDiagnostics.PerformanceHUD
    if hud and type(hud.RefreshIncidentBubble) == "function" then
        hud:RefreshIncidentBubble()
    end
end

local function NormalizeIncident(record)
    if type(record) ~= "table" then return nil end
    local normalized = {}
    normalized.id = SanitizeText(record.id, 24, "ADI-unknown")
    normalized.message = SanitizeText(record.message, MAX_MESSAGE_CHARACTERS, "(unavailable Lua error)")
    normalized.stack = SanitizeText(record.stack, MAX_STACK_CHARACTERS, "unavailable")
    normalized.category = SanitizeText(record.category, MAX_LABEL_CHARACTERS, "Lua runtime error")
    normalized.sourceHeading = SanitizeText(record.sourceHeading, 64, "Likely Source")
    normalized.whatHappened = SanitizeText(record.whatHappened, 640, "A Lua error was captured.")
    normalized.detectedIn = SanitizeText(record.detectedIn, MAX_LABEL_CHARACTERS, "Lua runtime")
    normalized.likelySource = SanitizeText(record.likelySource, MAX_LABEL_CHARACTERS, "Undetermined")
    normalized.sourceVersion = SanitizeText(record.sourceVersion, 64, "not determined")
    normalized.confidence = SanitizeText(record.confidence, 16, "Low")
    normalized.nextStep = SanitizeText(record.nextStep, 640, "Review the technical details below.")
    normalized.reportDestination = SanitizeText(record.reportDestination, 320, "Developer/support location for the relevant addon or feature")
    normalized.clientBuild = SanitizeText(record.clientBuild, MAX_BUILD_CHARACTERS, "Client build unavailable")
    normalized.firstSeen = math.max(0, tonumber(record.firstSeen) or 0)
    normalized.lastSeen = math.max(normalized.firstSeen, tonumber(record.lastSeen) or normalized.firstSeen)
    normalized.occurrences = math.max(1, math.min(999999, tonumber(record.occurrences) or 1))
    normalized.reviewed = record.reviewed == true
    normalized.copied = record.copied == true
    normalized.recordingActive = record.recordingActive == true
    normalized.presentationSuppressed = SanitizeText(record.presentationSuppressed, 24, "not observed")
    normalized.enhancedProviderID = SanitizeText(record.enhancedProviderID, MAX_ENHANCED_PROVIDER_ID_CHARACTERS, "none")
    normalized.enhancedBreadcrumbContext = SanitizeText(record.enhancedBreadcrumbContext, MAX_ENHANCED_BREADCRUMB_CONTEXT_CHARACTERS, "")
    normalized.enhancedSnapshotContext = SanitizeText(record.enhancedSnapshotContext, MAX_ENHANCED_SNAPSHOT_CONTEXT_CHARACTERS, "")
    normalized.enhancedIntegrity = SanitizeText(record.enhancedIntegrity, MAX_ENHANCED_INTEGRITY_CHARACTERS, "No retained Enhanced incident context.")
    return normalized
end

local function EnsureDatabase()
    local existing = AddonDiagnosticsIncidentDB
    if type(existing) ~= "table" then existing = {} end
    local database = {}
    database.schemaVersion = STORE_SCHEMA_VERSION
    database.nextID = math.max(1, math.min(999999, tonumber(existing.nextID) or 1))
    database.overflowDropped = math.max(0, tonumber(existing.overflowDropped) or 0)
    database.incidents = {}
    local source = existing.incidents
    if type(source) == "table" then
        for index = 1, MAX_INCIDENTS do
            local normalized = NormalizeIncident(source[index])
            if normalized then table.insert(database.incidents, normalized) end
        end
    end
    AddonDiagnosticsIncidentDB = database
    return database
end

local database = {}
database.schemaVersion = STORE_SCHEMA_VERSION
database.nextID = 1
database.overflowDropped = 0
database.incidents = {}

local function OnIncidentStoreLifecycleEvent(self, event, addonName)
    if event ~= "ADDON_LOADED" or addonName ~= "AddonDiagnostics" then return end
    database = EnsureDatabase()
    NotifyChanged()
    self:UnregisterEvent("ADDON_LOADED")
end

local incidentStoreLifecycleFrame = CreateFrame("Frame")
incidentStoreLifecycleFrame:RegisterEvent("ADDON_LOADED")
incidentStoreLifecycleFrame:SetScript("OnEvent", OnIncidentStoreLifecycleEvent)

local function GetDatabaseIncidentCount()
    local count = 0
    for index = 1, MAX_INCIDENTS do
        if database.incidents[index] then count = count + 1 else return count end
    end
    return count
end

local function IsProtectedActionIncident(incident)
    if type(incident) ~= "table" then return false end
    if incident.sourceHeading == "Blizzard Attribution" then return true end
    local message = tostring(incident.message or "")
    return string.find(message, "ADDON_ACTION_BLOCKED", 1, true) == 1
        or string.find(message, "ADDON_ACTION_FORBIDDEN", 1, true) == 1
end

local function BuildStackContext(incident)
    local stack = tostring(incident.stack or "")
    local observerSeen = string.find(stack, "Interface/AddOns/AddonDiagnostics/UniversalObserver.lua", 1, true) ~= nil
        or string.find(stack, "Interface\\AddOns\\AddonDiagnostics\\UniversalObserver.lua", 1, true) ~= nil
    local externalFrame = FindExternalFrameLine(stack)
    local output = ""
    if observerSeen then
        output = "Addon Diagnostics observer frames are capture/forwarding machinery added after the error surfaced; they are not evidence that Addon Diagnostics caused the failure."
    end
    if externalFrame then
        if output ~= "" then output = string.format("%s\n", output) end
        output = string.format("%sFirst external addon frame in the captured stack: %s", output, externalFrame)
    end
    if output == "" then
        output = "No separate external addon frame or Addon Diagnostics observer frame was identified in the bounded captured stack."
    end
    return SanitizeText(output, MAX_STACK_CONTEXT_CHARACTERS, "Stack interpretation unavailable")
end

local function BuildRelatedContext(incident)
    local addonName = FindRelevantAddon(tostring(incident.message or ""), tostring(incident.stack or ""))
    local moduleName = FindRelevantModule(tostring(incident.message or ""), tostring(incident.stack or ""))
    if not addonName or not moduleName then
        return "No bounded same-addon/module relationship could be established from the retained stack evidence."
    end
    local relatedCount = 0
    local shownCount = 0
    local relatedIDs = ""
    for index = 1, MAX_INCIDENTS do
        local other = database.incidents[index]
        if other and other.id ~= incident.id then
            local otherAddon = FindRelevantAddon(tostring(other.message or ""), tostring(other.stack or ""))
            local otherModule = FindRelevantModule(tostring(other.message or ""), tostring(other.stack or ""))
            if otherAddon == addonName and otherModule == moduleName then
                relatedCount = relatedCount + 1
                if shownCount < MAX_RELATED_INCIDENTS then
                    if relatedIDs ~= "" then relatedIDs = string.format("%s, ", relatedIDs) end
                    relatedIDs = string.format("%s%s", relatedIDs, tostring(other.id or "ADI-unknown"))
                    shownCount = shownCount + 1
                end
            end
        end
    end
    if relatedCount == 0 then
        return string.format("No separate retained incident currently shares addon %s and module %s.", addonName, moduleName)
    end
    local suffix = ""
    if relatedCount > shownCount then suffix = string.format(" (+%d more)", relatedCount - shownCount) end
    return SanitizeText(string.format("%d separate retained incident(s) share addon %s and module %s: %s%s. These records remain separate because their exact duplicate signatures did not match.", relatedCount, addonName, moduleName, relatedIDs, suffix), MAX_STACK_CONTEXT_CHARACTERS, "Related-incident context unavailable")
end


local function NormalizeFamilyMessage(message)
    local text = string.lower(SanitizeText(message, MAX_MESSAGE_CHARACTERS, "unavailable"))
    return text
end

local function BuildProtectedFamilyParts(incident)
    local message = tostring(incident.message or "")
    local contextMarker = " | reported context: "
    local functionMarker = " | protected function: "
    local contextStart = string.find(message, contextMarker, 1, true)
    local functionStart = string.find(message, functionMarker, 1, true)
    if not contextStart or not functionStart or functionStart <= contextStart then
        return "Blizzard reported context", NormalizeFamilyMessage(message)
    end
    local contextValueStart = contextStart + string.len(contextMarker)
    local contextValue = string.sub(message, contextValueStart, functionStart - 1)
    local functionValue = string.sub(message, functionStart + string.len(functionMarker))
    return SanitizeText(contextValue, MAX_LABEL_CHARACTERS, "Blizzard reported context"),
        SanitizeText(functionValue, MAX_LABEL_CHARACTERS, "protected action")
end

local function BuildFamilyIdentity(incident)
    if type(incident) ~= "table" then return "invalid", "Unclassified incident" end
    local category = tostring(incident.category or "Lua runtime error")
    if not IsProtectedActionIncident(incident) then
        local classification = ClassifyIncident(tostring(incident.message or ""), tostring(incident.stack or ""))
        category = tostring(classification.category or category)
    end

    if IsProtectedActionIncident(incident) then
        local contextValue, functionValue = BuildProtectedFamilyParts(incident)
        local key = string.format("protected|%s|%s|%s", string.lower(contextValue), string.lower(category), string.lower(functionValue))
        local label = string.format("%s - %s - %s", contextValue, category, functionValue)
        return SanitizeText(key, 720, "protected-family"), SanitizeText(label, 300, "Protected-action report")
    end

    local message = tostring(incident.message or "")
    local stack = tostring(incident.stack or "")
    local addonName = FindRelevantAddon(message, stack)
    local moduleName = FindRelevantModule(message, stack)
    local normalizedMessage = NormalizeFamilyMessage(message)
    if not addonName or not moduleName then
        local singletonKey = string.format("singleton|%s", tostring(incident.id or "ADI-unknown"))
        local singletonLabel = string.format("%s - %s", tostring(incident.likelySource or "Undetermined"), category)
        return SanitizeText(singletonKey, 120, "singleton"), SanitizeText(singletonLabel, 300, "Unclassified report")
    end
    local sourceVersion = tostring(incident.sourceVersion or "not determined")
    local key = string.format("lua|%s|%s|%s|%s|%s", string.lower(addonName), string.lower(sourceVersion), string.lower(category), string.lower(moduleName), normalizedMessage)
    local label = string.format("%s - %s - %s", addonName, moduleName, category)
    return SanitizeText(key, 1800, "lua-family"), SanitizeText(label, 300, "Addon problem report")
end

local function BuildProblemFamilies()
    local families = {}
    local familyByKey = {}
    local count = GetDatabaseIncidentCount()
    for index = count, 1, -1 do
        local incident = database.incidents[index]
        if incident then
            local key, label = BuildFamilyIdentity(incident)
            local family = familyByKey[key]
            if not family then
                family = {}
                family.key = key
                family.label = label
                family.members = {}
                family.incidentCount = 0
                family.occurrences = 0
                family.copied = true
                family.unread = false
                family.firstSeen = tonumber(incident.firstSeen) or 0
                family.lastSeen = tonumber(incident.lastSeen) or 0
                family.leadIncident = incident
                familyByKey[key] = family
                table.insert(families, family)
            end
            table.insert(family.members, incident)
            family.incidentCount = family.incidentCount + 1
            family.occurrences = math.min(9999999, family.occurrences + math.max(1, tonumber(incident.occurrences) or 1))
            if incident.copied ~= true then family.copied = false end
            if incident.reviewed ~= true then family.unread = true end
            local firstSeen = tonumber(incident.firstSeen) or 0
            local lastSeen = tonumber(incident.lastSeen) or 0
            if family.firstSeen == 0 or (firstSeen > 0 and firstSeen < family.firstSeen) then family.firstSeen = firstSeen end
            if lastSeen > family.lastSeen then
                family.lastSeen = lastSeen
                family.leadIncident = incident
            end
        end
    end
    return families
end

function IncidentStore:GetIncidentReportView(incident)
    if type(incident) ~= "table" then return nil end
    local view = {}
    if IsProtectedActionIncident(incident) then
        view.category = incident.category
        view.sourceHeading = incident.sourceHeading
        view.whatHappened = incident.whatHappened
        view.detectedIn = incident.detectedIn
        view.likelySource = incident.likelySource
        view.confidence = incident.confidence
        view.nextStep = incident.nextStep
        view.reportDestination = incident.reportDestination
    else
        local classification = ClassifyIncident(tostring(incident.message or ""), tostring(incident.stack or ""))
        view.category = classification.category
        view.sourceHeading = classification.sourceHeading
        view.whatHappened = classification.whatHappened
        view.detectedIn = classification.detectedIn
        view.likelySource = classification.likelySource
        view.confidence = classification.confidence
        view.nextStep = classification.nextStep
        view.reportDestination = classification.reportDestination
    end
    view.sourceVersion = incident.sourceVersion
    view.stackContext = BuildStackContext(incident)
    view.relatedContext = BuildRelatedContext(incident)
    return view
end

local function FindDuplicate(message, stack)
    local stackSignature = string.sub(stack, 1, MAX_SIGNATURE_STACK_CHARACTERS)
    for index = 1, MAX_INCIDENTS do
        local incident = database.incidents[index]
        if not incident then return nil end
        if incident.message == message and string.sub(incident.stack, 1, MAX_SIGNATURE_STACK_CHARACTERS) == stackSignature then
            return incident
        end
    end
    return nil
end

local function FindOldestReviewedIndex()
    local selected = nil
    local selectedTime = nil
    for index = 1, MAX_INCIDENTS do
        local incident = database.incidents[index]
        if incident and incident.reviewed == true then
            local seen = tonumber(incident.lastSeen) or 0
            if selectedTime == nil or seen < selectedTime then
                selected = index
                selectedTime = seen
            end
        end
    end
    return selected
end

local function CreateIncidentID()
    local value = database.nextID
    database.nextID = value + 1
    if database.nextID > 999999 then database.nextID = 1 end
    return string.format("ADI-%06d", value)
end

local function RefreshDuplicate(message, stack, recordingActive, presentationResult, providerHint)
    local duplicate = FindDuplicate(message, stack)
    if not duplicate then return nil end
    duplicate.lastSeen = GetTimestamp()
    duplicate.occurrences = math.min(999999, (tonumber(duplicate.occurrences) or 1) + 1)
    duplicate.reviewed = false
    duplicate.copied = false
    duplicate.recordingActive = recordingActive == true
    duplicate.presentationSuppressed = presentationResult
    ApplyEnhancedIncidentContext(duplicate, providerHint)
    NotifyChanged()
    return duplicate.id
end

local function EnsureIncidentCapacity()
    if GetDatabaseIncidentCount() < MAX_INCIDENTS then return true end
    local removeIndex = FindOldestReviewedIndex()
    if removeIndex then
        table.remove(database.incidents, removeIndex)
        return true
    end
    database.overflowDropped = database.overflowDropped + 1
    NotifyChanged()
    return false
end

local function StoreIncident(incident)
    table.insert(database.incidents, incident)
    NotifyChanged()
    return incident.id
end

function IncidentStore:CaptureIncident(message, stack, recordingActive)
    local safeMessage = SanitizeText(message, MAX_MESSAGE_CHARACTERS, "(non-string or inaccessible Lua error)")
    local safeStack = SanitizeText(stack, MAX_STACK_CHARACTERS, "unavailable")
    local providerHint = FindRelevantAddon(safeMessage, safeStack)
    local duplicateID = RefreshDuplicate(safeMessage, safeStack, recordingActive, "not observed", providerHint)
    if duplicateID then return duplicateID end
    if not EnsureIncidentCapacity() then return nil end

    local classification = ClassifyIncident(safeMessage, safeStack)
    local now = GetTimestamp()
    local incident = {}
    incident.id = CreateIncidentID()
    incident.message = safeMessage
    incident.stack = safeStack
    incident.category = classification.category
    incident.sourceHeading = classification.sourceHeading
    incident.whatHappened = classification.whatHappened
    incident.detectedIn = classification.detectedIn
    incident.likelySource = classification.likelySource
    incident.sourceVersion = classification.sourceVersion
    incident.confidence = classification.confidence
    incident.nextStep = classification.nextStep
    incident.reportDestination = classification.reportDestination
    incident.clientBuild = GetClientBuild()
    incident.firstSeen = now
    incident.lastSeen = now
    incident.occurrences = 1
    incident.reviewed = false
    incident.copied = false
    incident.recordingActive = recordingActive == true
    incident.presentationSuppressed = "not observed"
    ApplyEnhancedIncidentContext(incident, providerHint)
    return StoreIncident(incident)
end

function IncidentStore:CaptureProtectedAction(eventName, reportedContext, protectedFunction, recordingActive)
    local safeEvent = SanitizeText(eventName, 48, "ADDON_ACTION_UNKNOWN")
    local safeContext = SanitizeText(reportedContext, MAX_LABEL_CHARACTERS, "unavailable")
    local safeFunction = SanitizeText(protectedFunction, MAX_LABEL_CHARACTERS, "unavailable")
    local safeMessage = SanitizeText(string.format("%s | reported context: %s | protected function: %s", safeEvent, safeContext, safeFunction), MAX_MESSAGE_CHARACTERS, "Protected action event")
    local noCausalStack = "Not captured: this protected-action event does not provide a causal Lua stack."
    local duplicateID = RefreshDuplicate(safeMessage, noCausalStack, recordingActive, "preserved by design", safeContext)
    if duplicateID then return duplicateID end
    if not EnsureIncidentCapacity() then return nil end

    local now = GetTimestamp()
    local incident = {}
    incident.id = CreateIncidentID()
    incident.message = safeMessage
    incident.stack = noCausalStack
    incident.category = "Restricted/protected UI operation"
    incident.sourceHeading = "Blizzard Attribution"
    incident.whatHappened = "World of Warcraft blocked or forbade a protected interface action and emitted a protected-action event. Blizzard associated the event with the reported context below; that association is useful evidence but does not prove which addon originally caused the tainted state."
    incident.detectedIn = "Blizzard protected-action event"
    incident.likelySource = SanitizeText(string.format("Reported context: %s", safeContext), MAX_LABEL_CHARACTERS, "Reported context unavailable")
    incident.sourceVersion = "not determined"
    incident.confidence = "Context only"
    incident.nextStep = "Keep this report with the action you were taking when the message appeared. If the reported context names an addon, treat it as Blizzard-supplied evidence, not proof of root cause; repeated reproduction can strengthen attribution."
    incident.reportDestination = "The developer/support location for the addon or feature implicated by repeated evidence; do not assign blame from this event alone."
    incident.clientBuild = GetClientBuild()
    incident.firstSeen = now
    incident.lastSeen = now
    incident.occurrences = 1
    incident.reviewed = false
    incident.copied = false
    incident.recordingActive = recordingActive == true
    incident.presentationSuppressed = "preserved by design"
    ApplyEnhancedIncidentContext(incident, safeContext)
    return StoreIncident(incident)
end
local function FindByID(incidentID)
    for index = 1, MAX_INCIDENTS do
        local incident = database.incidents[index]
        if not incident then return nil end
        if incident.id == incidentID then return incident end
    end
    return nil
end

function IncidentStore:MarkPresentationSuppressed(incidentID, suppressed)
    local incident = FindByID(incidentID)
    if not incident then return false end
    if suppressed == true then
        incident.presentationSuppressed = "hidden after forwarding"
    elseif suppressed == false then
        incident.presentationSuppressed = "not hidden"
    else
        incident.presentationSuppressed = "not observed"
    end
    return true
end

function IncidentStore:MarkReviewed(incidentID)
    local incident = FindByID(incidentID)
    if not incident then return false end
    incident.reviewed = true
    NotifyChanged()
    return true
end

function IncidentStore:MarkCopied(incidentID)
    local incident = FindByID(incidentID)
    if not incident then return false end
    incident.copied = true
    return true
end


function IncidentStore:MarkIncidentsCopied(incidentIDs)
    if type(incidentIDs) ~= "table" then return 0 end
    local copiedCount = 0
    for idIndex = 1, MAX_INCIDENTS do
        local incidentID = incidentIDs[idIndex]
        if not incidentID then return copiedCount end
        if self:MarkCopied(incidentID) then copiedCount = copiedCount + 1 end
    end
    return copiedCount
end

function IncidentStore:GetProblemFamilies()
    return BuildProblemFamilies()
end

function IncidentStore:GetProblemFamilyCount()
    local families = BuildProblemFamilies()
    local count = 0
    for index = 1, MAX_INCIDENTS do
        if not families[index] then return count end
        count = count + 1
    end
    return count
end

function IncidentStore:GetProblemFamilyByNewestIndex(newestIndex)
    local families = BuildProblemFamilies()
    local count = self:GetProblemFamilyCount()
    if count == 0 then return nil end
    local safeIndex = math.max(1, math.min(count, tonumber(newestIndex) or 1))
    return families[safeIndex]
end

function IncidentStore:GetLatestUnreadProblemFamily()
    local families = BuildProblemFamilies()
    for index = 1, MAX_INCIDENTS do
        local family = families[index]
        if not family then return nil, nil end
        if family.unread == true then return family, index end
    end
    return nil, nil
end

function IncidentStore:MarkProblemFamilyReviewed(family)
    if type(family) ~= "table" or type(family.members) ~= "table" then return 0 end
    local reviewedCount = 0
    for index = 1, MAX_INCIDENTS do
        local incident = family.members[index]
        if not incident then
            NotifyChanged()
            return reviewedCount
        end
        if self:MarkReviewed(incident.id) then reviewedCount = reviewedCount + 1 end
    end
    NotifyChanged()
    return reviewedCount
end

function IncidentStore:GetUncopiedProblemFamilies()
    local families = BuildProblemFamilies()
    local uncopied = {}
    for index = 1, MAX_INCIDENTS do
        local family = families[index]
        if not family then return uncopied end
        if family.copied ~= true then table.insert(uncopied, family) end
    end
    return uncopied
end

function IncidentStore:ClearCopied()
    local removed = 0
    local retained = {}
    for index = 1, MAX_INCIDENTS do
        local incident = database.incidents[index]
        if incident and incident.copied == true then
            removed = removed + 1
        elseif incident then
            table.insert(retained, incident)
        end
    end
    database.incidents = retained
    NotifyChanged()
    return removed
end

function IncidentStore:GetIncidentCount()
    return GetDatabaseIncidentCount()
end

function IncidentStore:GetUnreadCount()
    local count = 0
    for index = 1, MAX_INCIDENTS do
        local incident = database.incidents[index]
        if incident and incident.reviewed ~= true then count = count + 1 end
    end
    return count
end

function IncidentStore:GetIncidentByNewestIndex(newestIndex)
    local count = self:GetIncidentCount()
    local safeIndex = math.max(1, math.min(count, tonumber(newestIndex) or 1))
    if count == 0 then return nil end
    return database.incidents[count - safeIndex + 1]
end

function IncidentStore:GetLatestUnreadIncident()
    local count = self:GetIncidentCount()
    for index = count, 1, -1 do
        local incident = database.incidents[index]
        if incident and incident.reviewed ~= true then return incident end
    end
    return nil
end

function IncidentStore:GetLatestIncident()
    local count = self:GetIncidentCount()
    if count == 0 then return nil end
    return database.incidents[count]
end

function IncidentStore:GetNewestIndexForID(incidentID)
    local count = self:GetIncidentCount()
    for index = count, 1, -1 do
        local incident = database.incidents[index]
        if incident and incident.id == incidentID then
            return count - index + 1
        end
    end
    return nil
end

function IncidentStore:GetOverflowCount()
    return math.max(0, tonumber(database.overflowDropped) or 0)
end
