// Package diagnostics provides bounded, safe text for deterministic D&D
// decisions and findings.
package diagnostics

import (
	"fmt"
	"strconv"
	"strings"

	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	frameworkdiagnostics "gitea.maximumdirect.net/eric/notarius/internal/framework/diagnostics"
)

const (
	MaxIssues         = 20
	MaxDisplayedRunes = 128
	MaxMessageBytes   = 4096
)

// Finding is a local, ungrouped D&D diagnostic fact. Producers convert these
// to bounded framework diagnostics at their result boundary.
type Finding struct {
	Scope      string
	ReasonCode string
	Message    string
}

// Corrections collects domain-owned model instructions and contextual record
// descriptions without coupling them to operator-facing diagnostics. Rules are
// emitted before affected records so the bounded result remains useful even
// when a large candidate exceeds the display budget.
type Corrections struct {
	groups  []correctionGroup
	indexes map[string]int
}

type correctionGroup struct {
	rule        string
	records     []string
	recordsSeen map[string]struct{}
}

// Add records one semantic rule and, when non-empty, one contextual record to
// which it applies. key is local grouping state and is never rendered.
func (c *Corrections) Add(key, rule, record string) {
	if c.indexes == nil {
		c.indexes = make(map[string]int)
	}
	index, ok := c.indexes[key]
	if !ok {
		index = len(c.groups)
		c.indexes[key] = index
		c.groups = append(c.groups, correctionGroup{rule: rule, recordsSeen: make(map[string]struct{})})
	}
	if record == "" {
		return
	}
	group := &c.groups[index]
	if _, seen := group.recordsSeen[record]; seen {
		return
	}
	group.recordsSeen[record] = struct{}{}
	group.records = append(group.records, record)
}

// Guidance returns one bounded correction request. It deliberately renders
// neither grouping keys nor operator diagnostics.
func (c Corrections) Guidance(prefix string) string {
	issues := make([]string, 0, len(c.groups)*2)
	for _, group := range c.groups {
		issues = append(issues, group.rule)
	}
	for _, group := range c.groups {
		issues = append(issues, group.records...)
	}
	return Aggregate(prefix, issues)
}

// SourceRange describes cited transcript positions without exposing source
// identities or application entity IDs.
func SourceRange(refs []source.SourceRef) string {
	if len(refs) == 0 {
		return "without a cited source range"
	}
	description := SourceRefRange(refs[0])
	if len(refs) > 1 {
		description += fmt.Sprintf(" (first of %d cited ranges)", len(refs))
	}
	return description
}

// SourceRefRange describes one transcript range without exposing its source
// identity.
func SourceRefRange(ref source.SourceRef) string {
	if ref.StartUnitID == ref.EndUnitID {
		return "at source unit " + strconv.Itoa(ref.StartUnitID)
	}
	return fmt.Sprintf("at source units %d-%d", ref.StartUnitID, ref.EndUnitID)
}

// DataQualityResult converts accepted source-quality findings into bounded,
// locally grouped advisories. These findings do not indicate process
// degradation.
func DataQualityResult(findings []Finding) (contracts.ValidationResult, error) {
	diagnostics, err := Collect(findings, contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality)
	if err != nil {
		return contracts.ValidationResult{}, err
	}
	return contracts.ValidationResult{Approved: true, Diagnostics: diagnostics}, nil
}

// Collect converts individual deterministic findings into bounded,
// occurrence-grouped producer diagnostics with one consistent classification.
func Collect(findings []Finding, disposition contracts.DiagnosticDisposition, category contracts.DiagnosticCategory) ([]contracts.ProducerDiagnostic, error) {
	collector := frameworkdiagnostics.NewCollector()
	for _, finding := range findings {
		if err := collector.Add(contracts.ProducerDiagnostic{
			Disposition:     disposition,
			Category:        category,
			ReasonCode:      finding.ReasonCode,
			OccurrenceCount: 1,
			Samples:         []contracts.DiagnosticSample{{Scope: finding.Scope, Message: finding.Message}},
		}); err != nil {
			return nil, fmt.Errorf("collect diagnostic: %w", err)
		}
	}
	return collector.Diagnostics(), nil
}

// NormalizationDiagnostics classifies deterministic normalization findings as
// observations, except for explicitly identified unresolved-quality findings.
func NormalizationDiagnostics(findings []Finding, advisoryReasonCodes ...string) ([]contracts.ProducerDiagnostic, error) {
	advisoryReasons := make(map[string]struct{}, len(advisoryReasonCodes))
	for _, reasonCode := range advisoryReasonCodes {
		advisoryReasons[reasonCode] = struct{}{}
	}
	observations := make([]Finding, 0, len(findings))
	advisories := make([]Finding, 0)
	for _, finding := range findings {
		if _, advisory := advisoryReasons[finding.ReasonCode]; advisory {
			advisories = append(advisories, finding)
			continue
		}
		observations = append(observations, finding)
	}
	diagnostics, err := Collect(observations, contracts.DiagnosticDispositionObservation, contracts.DiagnosticCategoryNormalization)
	if err != nil {
		return nil, err
	}
	qualityDiagnostics, err := Collect(advisories, contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality)
	if err != nil {
		return nil, err
	}
	return append(diagnostics, qualityDiagnostics...), nil
}

func Truncate(value string) string {
	runes := []rune(value)
	if len(runes) <= MaxDisplayedRunes {
		return value
	}
	return string(runes[:MaxDisplayedRunes-1]) + "…"
}

func Quote(value string) string { return strconv.Quote(Truncate(value)) }

func Aggregate(prefix string, issues []string) string {
	displayed := make([]string, 0, min(len(issues), MaxIssues))
	for len(displayed) < len(issues) && len(displayed) < MaxIssues {
		issue := Truncate(issues[len(displayed)])
		candidate := aggregateMessage(prefix, append(displayed, issue), len(issues)-len(displayed)-1)
		if len([]byte(candidate)) > MaxMessageBytes {
			break
		}
		displayed = append(displayed, issue)
	}
	return aggregateMessage(prefix, displayed, len(issues)-len(displayed))
}

func aggregateMessage(prefix string, issues []string, omitted int) string {
	message := prefix + ": " + strings.Join(issues, ", ")
	if omitted > 0 {
		message += fmt.Sprintf("; %d additional issue(s) omitted", omitted)
	}
	return message
}

func min(left, right int) int {
	if left < right {
		return left
	}
	return right
}
