package semanticreconcile

import (
	"fmt"
	"sort"
	"strings"
	"unicode/utf8"

	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)

// ProposalResponse is the complete private structured response contract.
type ProposalResponse struct {
	DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
}

// DuplicateGroup proposes supplied request-local candidate numbers that may
// denote one entity and identifies one supplied member as canonical.
type DuplicateGroup struct {
	CandidateNumbers         []int `json:"candidate_numbers"`
	CanonicalCandidateNumber int   `json:"canonical_candidate_number"`
}

// IssueCategory identifies one stable proposal safety failure.
type IssueCategory string

const (
	IssueMemberNonPositive    IssueCategory = "member_non_positive"
	IssueMemberUnknown        IssueCategory = "member_unknown"
	IssueRepeatedMember       IssueCategory = "repeated_member"
	IssueFewerThanTwoMembers  IssueCategory = "fewer_than_two_members"
	IssueCanonicalNonPositive IssueCategory = "canonical_non_positive"
	IssueCanonicalUnknown     IssueCategory = "canonical_unknown"
	IssueCanonicalNotMember   IssueCategory = "canonical_not_member"
	IssueOverlappingMember    IssueCategory = "overlapping_member"
)

var allIssueCategories = []IssueCategory{
	IssueMemberNonPositive,
	IssueMemberUnknown,
	IssueRepeatedMember,
	IssueFewerThanTwoMembers,
	IssueCanonicalNonPositive,
	IssueCanonicalUnknown,
	IssueCanonicalNotMember,
	IssueOverlappingMember,
}

var issueCorrectionProse = map[IssueCategory]string{
	IssueMemberNonPositive:    "Use only candidate numbers in %s.",
	IssueMemberUnknown:        "Remove every candidate number outside %s.",
	IssueRepeatedMember:       "List each candidate number at most once within the duplicate group.",
	IssueFewerThanTwoMembers:  "Include at least two distinct candidate numbers, or omit the duplicate group.",
	IssueCanonicalNonPositive: "Choose `canonical_candidate_number` from %s.",
	IssueCanonicalUnknown:     "Choose `canonical_candidate_number` from %s.",
	IssueCanonicalNotMember:   "Make `canonical_candidate_number` one of the `candidate_numbers` in the same duplicate group.",
	IssueOverlappingMember:    "Place each candidate number in at most one duplicate group.",
}

// Issue identifies an unsafe proposal category at its original response group
// index without prescribing caller diagnostic text.
type Issue struct {
	GroupIndex int
	Category   IssueCategory
}

// IssueDetails renders stable, domain-neutral proposal diagnostics for
// operators and debug records. Its internal categories are not model guidance.
func IssueDetails(issues []Issue) []string {
	details := make([]string, len(issues))
	for index, issue := range issues {
		details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
	}
	return details
}

// CorrectionDetails translates proposal issues into stable model-facing prose.
// The response-local group ordinals help the model find the defective group in
// the exact response appended to the correction request.
func CorrectionDetails(issues []Issue, numberRange CandidateNumberRange) ([]string, error) {
	if err := validateCorrectionRange(numberRange); err != nil {
		return nil, err
	}
	groupsByCategory := make(map[IssueCategory][]int)
	seen := make(map[IssueCategory]map[int]struct{})
	for _, issue := range issues {
		if issue.GroupIndex < 0 {
			return nil, fmt.Errorf("semantic reconciliation issue group index must not be negative")
		}
		if _, exists := issueCorrectionProse[issue.Category]; !exists {
			return nil, fmt.Errorf("semantic reconciliation issue category %q has no correction guidance", issue.Category)
		}
		if seen[issue.Category] == nil {
			seen[issue.Category] = make(map[int]struct{})
		}
		if _, exists := seen[issue.Category][issue.GroupIndex]; exists {
			continue
		}
		seen[issue.Category][issue.GroupIndex] = struct{}{}
		groupsByCategory[issue.Category] = append(groupsByCategory[issue.Category], issue.GroupIndex)
	}

	details := make([]string, 0, len(groupsByCategory))
	includeNamespaceReminder := false
	for _, category := range allIssueCategories {
		groups := groupsByCategory[category]
		if len(groups) == 0 {
			continue
		}
		sort.Ints(groups)
		prose := issueCorrectionProse[category]
		if issueNeedsCandidateRange(category) {
			prose = fmt.Sprintf(prose, candidateRangeDescription(numberRange))
			includeNamespaceReminder = true
		}
		details = append(details, fmt.Sprintf("%s: %s", correctionGroupLabel(groups), prose))
	}
	if includeNamespaceReminder {
		details = append(details, "Candidate numbers are the request-local `candidate_number` values in that range. Transcript unit `id` values and evidence `start_unit_id` and `end_unit_id` values are source positions, not candidate numbers.")
	}
	return details, nil
}

// CorrectionGuidance builds one bounded request for a complete corrected
// proposal. Additional details let a typed owner append a domain rule without
// weakening or duplicating the shared protocol guidance.
func CorrectionGuidance(issues []Issue, numberRange CandidateNumberRange, additionalDetails ...string) (string, error) {
	details, err := CorrectionDetails(issues, numberRange)
	if err != nil {
		return "", err
	}
	for _, detail := range additionalDetails {
		if !utf8.ValidString(detail) {
			return "", fmt.Errorf("semantic reconciliation additional correction detail has invalid UTF-8")
		}
		detail = strings.TrimSpace(detail)
		if detail == "" {
			return "", fmt.Errorf("semantic reconciliation additional correction detail must not be blank")
		}
		details = append(details, detail)
	}
	if len(details) == 0 {
		return "", fmt.Errorf("semantic reconciliation correction guidance requires at least one detail")
	}

	parts := make([]string, 0, len(details)+2)
	parts = append(parts, "The previous semantic-duplicate proposal was invalid.")
	parts = append(parts, details...)
	parts = append(parts, "Return one complete corrected JSON response that follows the original instructions; do not return a patch or commentary.")
	guidance := strings.Join(parts, " ")
	if len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes {
		return "", fmt.Errorf("semantic reconciliation correction guidance exceeds maximum length")
	}
	return guidance, nil
}

func validateCorrectionRange(numberRange CandidateNumberRange) error {
	if numberRange.First != 1 || numberRange.Last < 2 {
		return fmt.Errorf("semantic reconciliation correction candidate-number range must start at 1 and include at least two candidates")
	}
	return nil
}

func candidateRangeDescription(numberRange CandidateNumberRange) string {
	return fmt.Sprintf("the inclusive candidate-number range %d through %d", numberRange.First, numberRange.Last)
}

func issueNeedsCandidateRange(category IssueCategory) bool {
	switch category {
	case IssueMemberNonPositive, IssueMemberUnknown, IssueCanonicalNonPositive, IssueCanonicalUnknown:
		return true
	default:
		return false
	}
}

func correctionGroupLabel(groupIndexes []int) string {
	const maximumDisplayedGroups = 12
	displayed := groupIndexes
	if len(displayed) > maximumDisplayedGroups {
		displayed = displayed[:maximumDisplayedGroups]
	}
	ordinals := make([]string, len(displayed))
	for index, groupIndex := range displayed {
		ordinals[index] = fmt.Sprintf("%d", groupIndex+1)
	}
	if len(groupIndexes) == 1 {
		return "Duplicate group " + ordinals[0]
	}
	label := "Duplicate groups " + strings.Join(ordinals, ", ")
	if omitted := len(groupIndexes) - len(displayed); omitted > 0 {
		label += fmt.Sprintf(", and %d additional affected group(s)", omitted)
	}
	return label
}

// PlanGroup identifies one validated group using original candidate positions.
type PlanGroup struct {
	memberPositions   []int
	canonicalPosition int
}

// MemberPositions returns an owned, ascending list of original candidate
// positions.
func (group PlanGroup) MemberPositions() []int {
	return append([]int(nil), group.memberPositions...)
}

// CanonicalPosition returns the original position of the selected canonical
// candidate.
func (group PlanGroup) CanonicalPosition() int {
	return group.canonicalPosition
}

// Plan contains deterministic, non-overlapping reconciliation groups.
type Plan struct {
	groups []PlanGroup
}

// Groups returns a deeply owned copy ordered by each group's earliest member.
func (plan Plan) Groups() []PlanGroup {
	groups := make([]PlanGroup, len(plan.groups))
	for index, group := range plan.groups {
		groups[index] = clonePlanGroup(group)
	}
	return groups
}

// Assessment contains the safe plan and stable diagnostics for discarded
// response groups.
type Assessment struct {
	plan                Plan
	discardedGroupCount int
	issues              []Issue
}

// Plan returns an independently owned reconciliation plan.
func (assessment Assessment) Plan() Plan {
	groups := assessment.plan.Groups()
	return Plan{groups: groups}
}

// Issues returns an owned copy ordered by original response group index.
func (assessment Assessment) Issues() []Issue {
	return append([]Issue(nil), assessment.issues...)
}

// DiscardedGroupCount returns the number of response groups excluded from the
// safe plan.
func (assessment Assessment) DiscardedGroupCount() int {
	return assessment.discardedGroupCount
}

// RetryRequired reports whether any response group was discarded.
func (assessment Assessment) RetryRequired() bool {
	return assessment.discardedGroupCount > 0
}

type assessedGroup struct {
	memberPositions   []int
	canonicalPosition int
	issues            []IssueCategory
	locallyValid      bool
	conflicting       bool
}

// Assess resolves request-local candidate numbers through the retained
// preparation mapping and returns only deterministic, non-overlapping groups.
func (preparation Preparation) Assess(response ProposalResponse) Assessment {
	positionsByNumber := make(map[int]int, len(preparation.mappings))
	for _, mapping := range preparation.mappings {
		positionsByNumber[mapping.CandidateNumber] = mapping.CandidatePosition
	}

	groups := make([]assessedGroup, len(response.DuplicateGroups))
	owners := make(map[int][]int)
	for groupIndex, proposal := range response.DuplicateGroups {
		groups[groupIndex] = assessGroup(proposal, positionsByNumber)
		if !groups[groupIndex].locallyValid {
			continue
		}
		for _, position := range groups[groupIndex].memberPositions {
			owners[position] = append(owners[position], groupIndex)
		}
	}
	for _, groupIndexes := range owners {
		if len(groupIndexes) < 2 {
			continue
		}
		for _, groupIndex := range groupIndexes {
			groups[groupIndex].conflicting = true
		}
	}

	assessment := Assessment{}
	for groupIndex, group := range groups {
		for _, category := range group.issues {
			assessment.issues = append(assessment.issues, Issue{GroupIndex: groupIndex, Category: category})
		}
		if group.conflicting {
			assessment.issues = append(assessment.issues, Issue{GroupIndex: groupIndex, Category: IssueOverlappingMember})
		}
		if !group.locallyValid || group.conflicting {
			assessment.discardedGroupCount++
			continue
		}
		assessment.plan.groups = append(assessment.plan.groups, PlanGroup{
			memberPositions:   append([]int(nil), group.memberPositions...),
			canonicalPosition: group.canonicalPosition,
		})
	}
	sort.Slice(assessment.plan.groups, func(left, right int) bool {
		return assessment.plan.groups[left].memberPositions[0] < assessment.plan.groups[right].memberPositions[0]
	})
	return assessment
}

func assessGroup(proposal DuplicateGroup, positionsByNumber map[int]int) assessedGroup {
	group := assessedGroup{}
	seenNumbers := make(map[int]struct{}, len(proposal.CandidateNumbers))
	memberPositions := make(map[int]struct{}, len(proposal.CandidateNumbers))
	for _, candidateNumber := range proposal.CandidateNumbers {
		if _, repeated := seenNumbers[candidateNumber]; repeated {
			group.issues = append(group.issues, IssueRepeatedMember)
			continue
		}
		seenNumbers[candidateNumber] = struct{}{}
		position, category := resolveMember(candidateNumber, positionsByNumber)
		if category != "" {
			group.issues = append(group.issues, category)
			continue
		}
		memberPositions[position] = struct{}{}
		group.memberPositions = append(group.memberPositions, position)
	}
	if len(memberPositions) < 2 {
		group.issues = append(group.issues, IssueFewerThanTwoMembers)
	}

	canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateNumber, positionsByNumber)
	if canonicalCategory != "" {
		group.issues = append(group.issues, canonicalCategory)
	} else {
		group.canonicalPosition = canonicalPosition
		if _, member := memberPositions[canonicalPosition]; !member {
			group.issues = append(group.issues, IssueCanonicalNotMember)
		}
	}

	sort.Ints(group.memberPositions)
	group.locallyValid = len(group.issues) == 0
	return group
}

func resolveMember(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) {
	if candidateNumber <= 0 {
		return 0, IssueMemberNonPositive
	}
	position, exists := positionsByNumber[candidateNumber]
	if !exists {
		return 0, IssueMemberUnknown
	}
	return position, ""
}

func resolveCanonical(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) {
	if candidateNumber <= 0 {
		return 0, IssueCanonicalNonPositive
	}
	position, exists := positionsByNumber[candidateNumber]
	if !exists {
		return 0, IssueCanonicalUnknown
	}
	return position, ""
}

func clonePlanGroup(group PlanGroup) PlanGroup {
	return PlanGroup{
		memberPositions:   append([]int(nil), group.memberPositions...),
		canonicalPosition: group.canonicalPosition,
	}
}
