package source

import (
	"bufio"
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/BurntSushi/toml"
	"github.com/dusk-network/pituitary/internal/config"
	"github.com/dusk-network/pituitary/internal/model"
	"github.com/dusk-network/pituitary/sdk"
)

func init() {
	sdk.Register(config.AdapterFilesystem, func() sdk.Adapter {
		return &filesystemAdapter{}
	})
}

type filesystemAdapter struct{}

func (a *filesystemAdapter) Load(ctx context.Context, cfg sdk.SourceConfig) (*sdk.AdapterResult, error) {
	if ctx == nil {
		ctx = context.Background()
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}

	source := config.Source{
		Name:         cfg.Name,
		Adapter:      cfg.Adapter,
		Kind:         cfg.Kind,
		Repo:         cfg.Repo,
		Path:         cfg.Path,
		Files:        append([]string(nil), cfg.Files...),
		Include:      append([]string(nil), cfg.Include...),
		Exclude:      append([]string(nil), cfg.Exclude...),
		Options:      config.CloneSourceOptions(cfg.Options),
		ResolvedRepo: strings.TrimSpace(cfg.Repo),
		PrimaryRepo:  strings.TrimSpace(cfg.PrimaryRepoID),
		RepoRootPath: cfg.WorkspaceRoot,
		ResolvedPath: resolveFilesystemSourcePath(cfg.WorkspaceRoot, cfg.Path),
	}

	if strings.TrimSpace(source.Path) == "" {
		return nil, fmt.Errorf("path is required for adapter %q", config.AdapterFilesystem)
	}

	switch source.Kind {
	case config.SourceKindSpecBundle:
		specs, err := loadSpecBundles(ctx, cfg.WorkspaceRoot, source)
		if err != nil {
			return nil, err
		}
		return &sdk.AdapterResult{Specs: specs}, nil
	case config.SourceKindMarkdownDocs:
		docs, err := loadMarkdownDocs(ctx, cfg.WorkspaceRoot, source)
		if err != nil {
			return nil, err
		}
		return &sdk.AdapterResult{Docs: docs}, nil
	case config.SourceKindMarkdownContract:
		specs, err := loadMarkdownContracts(ctx, cfg.WorkspaceRoot, source)
		if err != nil {
			return nil, err
		}
		return &sdk.AdapterResult{Specs: specs}, nil
	default:
		return nil, fmt.Errorf("unsupported kind %q", source.Kind)
	}
}

func resolveFilesystemSourcePath(workspaceRoot, sourcePath string) string {
	if strings.TrimSpace(sourcePath) == "" {
		return ""
	}
	if filepath.IsAbs(sourcePath) {
		return filepath.Clean(sourcePath)
	}
	return filepath.Clean(filepath.Join(workspaceRoot, sourcePath))
}

type artifactOrigin struct {
	sourceName string
	repoID     string
	sourcePath string
	itemPath   string
}

func appendUniqueSpecRecords(result *LoadResult, seen map[string]artifactOrigin, source config.Source, records []model.SpecRecord) error {
	for _, record := range records {
		origin := specOrigin(source, record)
		if prior, exists := seen[record.Ref]; exists {
			return fmt.Errorf(
				"duplicate spec ref %q: %s conflicts with %s\n"+
					"  to fix: edit the Ref: line in one of the files to make it unique,\n"+
					"  or add an exclude pattern to your config to skip one of them",
				record.Ref, describeOrigin("file", origin), describeOrigin("file", prior),
			)
		}
		seen[record.Ref] = origin
		result.Specs = append(result.Specs, record)
	}
	return nil
}

func appendUniqueDocRecords(result *LoadResult, seen map[string]artifactOrigin, source config.Source, records []model.DocRecord) error {
	for _, record := range records {
		origin := docOrigin(source, record)
		if prior, exists := seen[record.Ref]; exists {
			return fmt.Errorf("duplicate doc ref %q: %s conflicts with %s", record.Ref, describeOrigin("doc", origin), describeOrigin("doc", prior))
		}
		seen[record.Ref] = origin
		result.Docs = append(result.Docs, record)
	}
	return nil
}

func specOrigin(source config.Source, record model.SpecRecord) artifactOrigin {
	itemPath := record.Metadata["bundle_path"]
	if itemPath == "" {
		itemPath = record.Metadata["path"]
	}
	return artifactOrigin{
		sourceName: source.Name,
		repoID:     source.ResolvedRepo,
		sourcePath: source.Path,
		itemPath:   itemPath,
	}
}

func docOrigin(source config.Source, record model.DocRecord) artifactOrigin {
	return artifactOrigin{
		sourceName: source.Name,
		repoID:     source.ResolvedRepo,
		sourcePath: source.Path,
		itemPath:   record.Metadata["path"],
	}
}

func describeOrigin(itemLabel string, origin artifactOrigin) string {
	if origin.repoID != "" {
		return fmt.Sprintf("source %q repo %q path %q %s %q", origin.sourceName, origin.repoID, origin.sourcePath, itemLabel, origin.itemPath)
	}
	return fmt.Sprintf("source %q path %q %s %q", origin.sourceName, origin.sourcePath, itemLabel, origin.itemPath)
}

func loadSpecBundles(ctx context.Context, workspaceRoot string, source config.Source) ([]model.SpecRecord, error) {
	bundleDirs, err := discoverSpecBundles(ctx, source)
	if err != nil {
		return nil, fmt.Errorf("source %q: %w", source.Name, err)
	}

	var records []model.SpecRecord
	for _, bundleDir := range bundleDirs {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		record, err := loadSpecBundle(workspaceRoot, source, bundleDir)
		if err != nil {
			return nil, err
		}
		records = append(records, record)
	}
	return records, nil
}

func discoverSpecBundles(ctx context.Context, source config.Source) ([]string, error) {
	bundleDirs, err := discoverSpecBundleDirs(ctx, source.ResolvedPath)
	if err != nil {
		return nil, err
	}

	filtered := bundleDirs[:0]
	for _, bundleDir := range bundleDirs {
		relBundleSpec := filepath.ToSlash(filepath.Join(workspaceRelative(source.ResolvedPath, bundleDir), "spec.toml"))
		allowed, err := sourcePathAllowed(source, relBundleSpec)
		if err != nil {
			return nil, fmt.Errorf("spec %q: %w", relBundleSpec, err)
		}
		if allowed {
			filtered = append(filtered, bundleDir)
		}
	}

	for i := range filtered {
		for j := 0; j < i; j++ {
			parent := filtered[j]
			if isNestedBundle(parent, filtered[i]) {
				return nil, fmt.Errorf("nested spec bundle %q inside %q", filepath.ToSlash(filtered[i]), filepath.ToSlash(parent))
			}
		}
	}

	return filtered, nil
}

func discoverSpecBundleDirs(ctx context.Context, root string) ([]string, error) {
	var bundleDirs []string
	err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
		if cerr := ctx.Err(); cerr != nil {
			return cerr
		}
		if err != nil {
			return err
		}
		if !d.IsDir() {
			return nil
		}
		specPath := filepath.Join(path, "spec.toml")
		info, err := os.Stat(specPath)
		if err == nil && !info.IsDir() {
			bundleDirs = append(bundleDirs, path)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}

	sort.Strings(bundleDirs)
	return bundleDirs, nil
}

func isNestedBundle(parent, child string) bool {
	rel, err := filepath.Rel(parent, child)
	if err != nil || rel == "." {
		return false
	}
	return !strings.HasPrefix(rel, "..")
}

type rawSpecBundle struct {
	ID         string   `toml:"id"`
	Title      string   `toml:"title"`
	Status     string   `toml:"status"`
	Domain     string   `toml:"domain"`
	Authors    []string `toml:"authors"`
	Tags       []string `toml:"tags"`
	Body       string   `toml:"body"`
	DependsOn  []string `toml:"depends_on"`
	Supersedes []string `toml:"supersedes"`
	RelatesTo  []string `toml:"relates_to"`
	AppliesTo  []string `toml:"applies_to"`
}

func loadSpecBundle(workspaceRoot string, source config.Source, bundleDir string) (model.SpecRecord, error) {
	specPath := filepath.Join(bundleDir, "spec.toml")
	specBytes, err := readBoundedFile(specPath, maxSpecTOMLBytes)
	if err != nil {
		return model.SpecRecord{}, fmt.Errorf("source %q bundle %q: read spec.toml: %w", source.Name, workspaceRelative(workspaceRoot, bundleDir), err)
	}

	raw, err := parseSpecBundle(specBytes)
	if err != nil {
		return model.SpecRecord{}, fmt.Errorf("source %q bundle %q: %w", source.Name, workspaceRelative(workspaceRoot, specPath), err)
	}
	if err := validateRawSpec(workspaceRoot, source.Name, bundleDir, raw); err != nil {
		return model.SpecRecord{}, err
	}

	bodyPath := filepath.Clean(filepath.Join(bundleDir, raw.Body))
	if !pathWithinRoot(bundleDir, bodyPath) {
		return model.SpecRecord{}, fmt.Errorf("source %q bundle %q body %q escapes the bundle directory", source.Name, workspaceRelative(workspaceRoot, bundleDir), raw.Body)
	}
	bodyInfo, err := os.Stat(bodyPath)
	switch {
	case err == nil && bodyInfo.IsDir():
		return model.SpecRecord{}, fmt.Errorf("source %q bundle %q body %q is a directory", source.Name, workspaceRelative(workspaceRoot, bundleDir), workspaceRelative(workspaceRoot, bodyPath))
	case err != nil:
		return model.SpecRecord{}, fmt.Errorf("source %q bundle %q body %q does not exist", source.Name, workspaceRelative(workspaceRoot, bundleDir), workspaceRelative(workspaceRoot, bodyPath))
	}

	bodyBytes, err := readBoundedFile(bodyPath, maxMarkdownBodyBytes)
	if err != nil {
		return model.SpecRecord{}, fmt.Errorf("source %q bundle %q: read body %q: %w", source.Name, workspaceRelative(workspaceRoot, bundleDir), workspaceRelative(workspaceRoot, bodyPath), err)
	}

	return model.SpecRecord{
		Ref:         raw.ID,
		Kind:        model.ArtifactKindSpec,
		Title:       raw.Title,
		Status:      raw.Status,
		Domain:      raw.Domain,
		Authors:     append([]string(nil), raw.Authors...),
		Tags:        append([]string(nil), raw.Tags...),
		Relations:   buildRelations(raw.DependsOn, raw.Supersedes, raw.RelatesTo),
		AppliesTo:   append([]string(nil), raw.AppliesTo...),
		SourceRef:   fileSourceRef(workspaceRoot, specPath),
		BodyFormat:  model.BodyFormatMarkdown,
		BodyText:    string(bodyBytes),
		ContentHash: joinedContentHash(specBytes, bodyBytes),
		Metadata: map[string]string{
			"source_name": source.Name,
			"bundle_path": workspaceRelative(workspaceRoot, bundleDir),
			"body_path":   workspaceRelative(workspaceRoot, bodyPath),
		},
	}, nil
}

func validateRawSpec(workspaceRoot, sourceName, bundleDir string, raw rawSpecBundle) error {
	var missing []string
	if raw.ID == "" {
		missing = append(missing, "id")
	}
	if raw.Title == "" {
		missing = append(missing, "title")
	}
	if raw.Status == "" {
		missing = append(missing, "status")
	} else if !isValidSpecStatus(raw.Status) {
		return fmt.Errorf("source %q bundle %q status %q is invalid", sourceName, workspaceRelative(workspaceRoot, bundleDir), raw.Status)
	}
	if raw.Domain == "" {
		missing = append(missing, "domain")
	}
	if raw.Body == "" {
		missing = append(missing, "body")
	}
	if len(missing) > 0 {
		return fmt.Errorf("source %q bundle %q missing required field(s): %s", sourceName, workspaceRelative(workspaceRoot, bundleDir), strings.Join(missing, ", "))
	}
	return nil
}

func buildRelations(dependsOn, supersedes, relatesTo []string) []model.Relation {
	relations := make([]model.Relation, 0, len(dependsOn)+len(supersedes)+len(relatesTo))
	for _, ref := range dependsOn {
		relations = append(relations, model.Relation{Type: model.RelationDependsOn, Ref: ref})
	}
	for _, ref := range supersedes {
		relations = append(relations, model.Relation{Type: model.RelationSupersedes, Ref: ref})
	}
	for _, ref := range relatesTo {
		relations = append(relations, model.Relation{Type: model.RelationRelatesTo, Ref: ref})
	}
	return relations
}

func isValidSpecStatus(status string) bool {
	switch status {
	case model.StatusDraft, model.StatusReview, model.StatusAccepted, model.StatusSuperseded, model.StatusDeprecated:
		return true
	default:
		return false
	}
}

func loadMarkdownDocs(ctx context.Context, workspaceRoot string, source config.Source) ([]model.DocRecord, error) {
	var records []model.DocRecord
	matches, err := enumerateSelectedMarkdownPaths(ctx, workspaceRoot, source, "doc")
	if err != nil {
		return nil, err
	}
	for _, match := range matches {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		bodyBytes, err := readBoundedFile(match.AbsolutePath, maxMarkdownBodyBytes)
		if err != nil {
			return nil, fmt.Errorf("source %q doc %q: read markdown: %w", source.Name, workspaceRelative(workspaceRoot, match.AbsolutePath), err)
		}
		docRef, err := docRefForPath(source.ResolvedPath, match.AbsolutePath, source.ResolvedRepo, source.PrimaryRepo)
		if err != nil {
			return nil, fmt.Errorf("source %q doc %q: %w", source.Name, workspaceRelative(workspaceRoot, match.AbsolutePath), err)
		}
		records = append(records, model.DocRecord{
			Ref:         docRef,
			Kind:        model.ArtifactKindDoc,
			Title:       docTitle(match.AbsolutePath, bodyBytes),
			SourceRef:   fileSourceRef(workspaceRoot, match.AbsolutePath),
			BodyFormat:  model.BodyFormatMarkdown,
			BodyText:    string(bodyBytes),
			ContentHash: contentHash(bodyBytes),
			Metadata: map[string]string{
				"source_name": source.Name,
				"path":        workspaceRelative(workspaceRoot, match.AbsolutePath),
			},
		})
	}
	return records, nil
}

func loadMarkdownContracts(ctx context.Context, workspaceRoot string, source config.Source) ([]model.SpecRecord, error) {
	var records []model.SpecRecord
	matches, err := enumerateSelectedMarkdownPaths(ctx, workspaceRoot, source, "contract")
	if err != nil {
		return nil, err
	}
	for _, match := range matches {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		bodyBytes, err := readBoundedFile(match.AbsolutePath, maxMarkdownBodyBytes)
		if err != nil {
			return nil, fmt.Errorf("source %q contract %q: read markdown: %w", source.Name, workspaceRelative(workspaceRoot, match.AbsolutePath), err)
		}

		record, err := inferMarkdownContract(workspaceRoot, source, match.AbsolutePath, bodyBytes)
		if err != nil {
			return nil, fmt.Errorf("source %q contract %q: %w", source.Name, workspaceRelative(workspaceRoot, match.AbsolutePath), err)
		}
		records = append(records, record)
	}
	return records, nil
}

type selectedMarkdownPath struct {
	AbsolutePath string
	RelativePath string
}

func enumerateSelectedMarkdownPaths(ctx context.Context, workspaceRoot string, source config.Source, label string) ([]selectedMarkdownPath, error) {
	matches := make([]selectedMarkdownPath, 0)
	err := filepath.WalkDir(source.ResolvedPath, func(path string, d os.DirEntry, err error) error {
		if cerr := ctx.Err(); cerr != nil {
			return cerr
		}
		if err != nil {
			return err
		}
		if d.IsDir() || filepath.Ext(path) != ".md" {
			return nil
		}
		relPath, err := filepath.Rel(source.ResolvedPath, path)
		if err != nil {
			return fmt.Errorf("source %q %s %q: resolve relative path: %w", source.Name, label, workspaceRelative(workspaceRoot, path), err)
		}
		allowed, err := sourcePathAllowed(source, relPath)
		if err != nil {
			return fmt.Errorf("source %q %s %q: %w", source.Name, label, workspaceRelative(workspaceRoot, path), err)
		}
		if !allowed {
			return nil
		}

		matches = append(matches, selectedMarkdownPath{
			AbsolutePath: path,
			RelativePath: filepath.ToSlash(relPath),
		})
		return nil
	})
	if err != nil {
		return nil, err
	}
	return matches, nil
}

func sourcePathAllowed(source config.Source, relPath string) (bool, error) {
	selection, err := evaluateSourcePathSelection(source, relPath)
	if err != nil {
		return false, err
	}
	return selection.Selected, nil
}

func docRefForPath(sourceRoot, path, repoID, primaryRepoID string) (string, error) {
	rel, err := filepath.Rel(sourceRoot, path)
	if err != nil {
		return "", err
	}
	if filepath.Ext(rel) != ".md" {
		return "", fmt.Errorf("doc path %q is not markdown", rel)
	}
	return repoScopedArtifactRef("doc://", strings.TrimSuffix(filepath.ToSlash(rel), ".md"), repoID, primaryRepoID), nil
}

func docTitle(path string, body []byte) string {
	title, _ := docTitleWithSource(path, body)
	return title
}

func docTitleWithSource(path string, body []byte) (string, string) {
	scanner := bufio.NewScanner(bytes.NewReader(body))
	scanner.Buffer(make([]byte, 0, 64*1024), maxScannerTokenSize(len(body)))
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if strings.HasPrefix(line, "# ") {
			title := strings.TrimSpace(strings.TrimPrefix(line, "# "))
			if title != "" {
				return title, "heading"
			}
		}
	}
	return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), "filename"
}

type markdownContractFields struct {
	Ref        string
	Status     string
	Domain     string
	DependsOn  []string
	Supersedes []string
	RelatesTo  []string
	AppliesTo  []string
}

func inferMarkdownContract(workspaceRoot string, source config.Source, path string, body []byte) (model.SpecRecord, error) {
	fields := inferMarkdownContractFields(body)
	fallbackRef, err := markdownContractRefForPath(workspaceRoot, path, source.ResolvedRepo, source.PrimaryRepo)
	if err != nil {
		return model.SpecRecord{}, err
	}
	title, titleSource := docTitleWithSource(path, body)

	refSource := "explicit"
	ref := strings.TrimSpace(fields.Ref)
	if ref == "" {
		ref = fallbackRef
		refSource = "path"
	}

	statusSource := "explicit"
	status := normalizeInferredStatus(fields.Status)
	if status == "" {
		status = model.StatusDraft
		statusSource = "default"
	}

	domain := strings.ToLower(strings.TrimSpace(fields.Domain))
	metadata := map[string]string{
		"source_name":   source.Name,
		"path":          workspaceRelative(workspaceRoot, path),
		"source_kind":   config.SourceKindMarkdownContract,
		"ref_source":    refSource,
		"status_source": statusSource,
		"path_ref":      fallbackRef,
	}
	confidence := inferMarkdownContractConfidence(fields, titleSource, refSource, statusSource)
	metadata, err = model.EncodeInferenceConfidence(metadata, confidence)
	if err != nil {
		return model.SpecRecord{}, err
	}

	return model.SpecRecord{
		Ref:         ref,
		Kind:        model.ArtifactKindSpec,
		Title:       title,
		Status:      status,
		Domain:      domain,
		Relations:   buildRelations(fields.DependsOn, fields.Supersedes, fields.RelatesTo),
		AppliesTo:   uniqueStringValues(fields.AppliesTo),
		SourceRef:   fileSourceRef(workspaceRoot, path),
		BodyFormat:  model.BodyFormatMarkdown,
		BodyText:    string(body),
		ContentHash: contentHash(body),
		Metadata:    metadata,
		Inference:   confidence,
	}, nil
}

func inferMarkdownContractConfidence(fields markdownContractFields, titleSource, refSource, statusSource string) *model.InferenceConfidence {
	fieldScores := []model.InferenceFieldConfidence{
		{
			Name:   "title",
			Source: titleSource,
			Score:  scoreForInferenceSource(titleSource, 0.9, 0.45),
		},
		{
			Name:   "ref",
			Source: refSource,
			Score:  scoreForInferenceSource(refSource, 0.95, 0.55),
		},
		{
			Name:   "status",
			Source: statusSource,
			Score:  scoreForInferenceSource(statusSource, 0.9, 0.35),
		},
		{
			Name:   "domain",
			Source: sourceForScalarField(strings.TrimSpace(fields.Domain)),
			Score:  scoreForPresence(strings.TrimSpace(fields.Domain) != "", 0.85, 0.25),
		},
		{
			Name:   "applies_to",
			Source: sourceForListField(fields.AppliesTo),
			Score:  scoreForPresence(len(fields.AppliesTo) > 0, 0.9, 0.2),
		},
	}
	total := 0.0
	for i := range fieldScores {
		fieldScores[i].Level = model.ConfidenceLevelFromScore(fieldScores[i].Score)
		total += fieldScores[i].Score
	}
	score := total / float64(len(fieldScores))

	return &model.InferenceConfidence{
		Kind:    config.SourceKindMarkdownContract,
		Level:   model.ConfidenceLevelFromScore(score),
		Score:   score,
		Reasons: inferenceReasons(fieldScores),
		Fields:  fieldScores,
	}
}

func scoreForInferenceSource(source string, explicitScore, fallbackScore float64) float64 {
	switch strings.TrimSpace(source) {
	case "explicit", "heading":
		return explicitScore
	default:
		return fallbackScore
	}
}

func scoreForPresence(present bool, presentScore, missingScore float64) float64 {
	if present {
		return presentScore
	}
	return missingScore
}

func sourceForListField(values []string) string {
	if len(values) > 0 {
		return "explicit"
	}
	return "missing"
}

func sourceForScalarField(value string) string {
	if strings.TrimSpace(value) != "" {
		return "explicit"
	}
	return "missing"
}

func inferenceReasons(fields []model.InferenceFieldConfidence) []string {
	reasons := make([]string, 0, len(fields))
	for _, field := range fields {
		switch {
		case field.Name == "ref" && field.Source == "path":
			reasons = append(reasons, "ref inferred from path")
		case field.Name == "title" && field.Source == "filename":
			reasons = append(reasons, "title inferred from filename")
		case field.Name == "status" && field.Source == "default":
			reasons = append(reasons, "status missing; defaulted to draft")
		case field.Name == "domain" && field.Source == "missing":
			reasons = append(reasons, "domain missing")
		case field.Name == "applies_to" && field.Source == "missing":
			reasons = append(reasons, "applies_to missing")
		}
	}
	return reasons
}

func inferMarkdownContractFields(body []byte) markdownContractFields {
	var (
		fields     markdownContractFields
		activeList string
	)

	lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n")
	for _, raw := range lines {
		line := strings.TrimSpace(raw)
		switch {
		case line == "", line == "---":
			if activeList != "" && line == "" {
				activeList = ""
			}
			continue
		case activeList != "":
			if !strings.HasPrefix(line, "- ") {
				activeList = ""
			} else {
				assignMarkdownContractListField(&fields, activeList, []string{normalizeMarkdownMetadataValue(strings.TrimSpace(strings.TrimPrefix(line, "- ")))})
				continue
			}
		}

		key, value, ok := strings.Cut(line, ":")
		if !ok {
			continue
		}

		key = normalizeMarkdownContractKey(key)
		if !isMarkdownContractField(key) {
			continue
		}

		value = strings.TrimSpace(value)
		if value == "" {
			if isMarkdownContractListField(key) {
				activeList = key
			}
			continue
		}
		if isMarkdownContractListField(key) {
			assignMarkdownContractListField(&fields, key, parseMarkdownMetadataList(value))
			continue
		}
		assignMarkdownContractScalarField(&fields, key, normalizeMarkdownMetadataValue(value))
	}

	fields.Ref = strings.TrimSpace(fields.Ref)
	fields.Status = normalizeInferredStatus(fields.Status)
	fields.Domain = strings.TrimSpace(fields.Domain)
	fields.DependsOn = uniqueStringValues(fields.DependsOn)
	fields.Supersedes = uniqueStringValues(fields.Supersedes)
	fields.RelatesTo = uniqueStringValues(fields.RelatesTo)
	fields.AppliesTo = uniqueStringValues(fields.AppliesTo)
	return fields
}

func normalizeMarkdownContractKey(key string) string {
	key = strings.ToLower(strings.TrimSpace(key))
	key = strings.ReplaceAll(key, "-", "_")
	key = strings.ReplaceAll(key, " ", "_")
	switch key {
	case "spec_ref":
		return "ref"
	case "dependson", "related", "related_refs":
		return "depends_on"
	case "relatesto", "related_to":
		return "relates_to"
	case "appliesto":
		return "applies_to"
	default:
		return key
	}
}

func isMarkdownContractField(key string) bool {
	switch key {
	case "id", "ref", "status", "domain", "depends_on", "supersedes", "relates_to", "applies_to":
		return true
	default:
		return false
	}
}

func isMarkdownContractListField(key string) bool {
	switch key {
	case "depends_on", "supersedes", "relates_to", "applies_to":
		return true
	default:
		return false
	}
}

func assignMarkdownContractScalarField(fields *markdownContractFields, key, value string) {
	switch key {
	case "id", "ref":
		if fields.Ref == "" {
			fields.Ref = value
		}
	case "status":
		fields.Status = value
	case "domain":
		fields.Domain = strings.ToLower(value)
	}
}

func assignMarkdownContractListField(fields *markdownContractFields, key string, values []string) {
	switch key {
	case "depends_on":
		fields.DependsOn = append(fields.DependsOn, values...)
	case "supersedes":
		fields.Supersedes = append(fields.Supersedes, values...)
	case "relates_to":
		fields.RelatesTo = append(fields.RelatesTo, values...)
	case "applies_to":
		fields.AppliesTo = append(fields.AppliesTo, values...)
	}
}

func parseMarkdownMetadataList(value string) []string {
	value = strings.TrimSpace(value)
	value = strings.TrimPrefix(value, "[")
	value = strings.TrimSuffix(value, "]")
	if value == "" {
		return nil
	}

	parts := strings.Split(value, ",")
	items := make([]string, 0, len(parts))
	for _, part := range parts {
		normalized := normalizeMarkdownMetadataValue(part)
		if normalized == "" {
			continue
		}
		items = append(items, normalized)
	}
	if len(items) > 0 {
		return items
	}

	normalized := normalizeMarkdownMetadataValue(value)
	if normalized == "" {
		return nil
	}
	return []string{normalized}
}

func normalizeMarkdownMetadataValue(value string) string {
	value = strings.TrimSpace(value)
	value = strings.Trim(value, "\"'`")
	return strings.TrimSpace(value)
}

func normalizeInferredStatus(value string) string {
	value = strings.ToLower(strings.TrimSpace(value))
	switch value {
	case model.StatusDraft, model.StatusReview, model.StatusAccepted, model.StatusSuperseded, model.StatusDeprecated:
		return value
	default:
		return ""
	}
}

func uniqueStringValues(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	result := make([]string, 0, len(values))
	for _, value := range values {
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		result = append(result, value)
	}
	return result
}

func markdownContractRefForPath(workspaceRoot, path, repoID, primaryRepoID string) (string, error) {
	rel, err := filepath.Rel(workspaceRoot, path)
	if err != nil {
		return "", err
	}
	if filepath.Ext(rel) != ".md" {
		return "", fmt.Errorf("contract path %q is not markdown", rel)
	}
	return repoScopedArtifactRef("contract://", strings.TrimSuffix(filepath.ToSlash(rel), ".md"), repoID, primaryRepoID), nil
}

func repoScopedArtifactRef(prefix, relativePath, repoID, primaryRepoID string) string {
	relativePath = strings.TrimPrefix(strings.TrimSpace(relativePath), "/")
	repoID = strings.TrimSpace(repoID)
	primaryRepoID = strings.TrimSpace(primaryRepoID)
	if repoID == "" || repoID == primaryRepoID {
		return prefix + relativePath
	}
	return prefix + repoID + "/" + relativePath
}

func parseSpecBundle(contents []byte) (rawSpecBundle, error) {
	var spec rawSpecBundle
	metadata, err := toml.NewDecoder(bytes.NewReader(contents)).Decode(&spec)
	if err != nil {
		return rawSpecBundle{}, err
	}
	if undecoded := metadata.Undecoded(); len(undecoded) > 0 {
		return rawSpecBundle{}, unknownSpecBundleFieldError(metadata, undecoded)
	}
	return spec, nil
}

// unknownSpecBundleFieldError returns a stable error for the lexicographically
// first unknown field in a spec.toml bundle, distinguishing arrays from
// scalars via metadata.Type so downstream messages match pre-refactor UX.
func unknownSpecBundleFieldError(metadata toml.MetaData, keys []toml.Key) error {
	names := make([]string, 0, len(keys))
	types := map[string]string{}
	for _, key := range keys {
		if len(key) == 0 {
			continue
		}
		name := strings.Join(key, ".")
		if _, seen := types[name]; seen {
			continue
		}
		names = append(names, name)
		types[name] = metadata.Type(key...)
	}
	if len(names) == 0 {
		// Defensive: reachable only if the decoder reports undecoded keys that
		// are all empty, which BurntSushi/toml should never produce. Keep the
		// diagnostic explicit so the root cause is traceable rather than
		// silently emitting a blank field name.
		return fmt.Errorf("internal: decoder returned undecoded keys with no name components")
	}
	sort.Strings(names)
	first := names[0]
	if types[first] == "Array" {
		return fmt.Errorf("unsupported array field %q", first)
	}
	return fmt.Errorf("unsupported field %q", first)
}

func fileSourceRef(workspaceRoot, path string) string {
	return "file://" + workspaceRelative(workspaceRoot, path)
}

func workspaceRelative(workspaceRoot, path string) string {
	rel, err := filepath.Rel(workspaceRoot, path)
	if err != nil {
		return filepath.ToSlash(path)
	}
	return filepath.ToSlash(rel)
}

func joinedContentHash(parts ...[]byte) string {
	hasher := sha256.New()
	for i, part := range parts {
		if i > 0 {
			_, _ = hasher.Write([]byte{0})
		}
		_, _ = hasher.Write(part)
	}
	return hex.EncodeToString(hasher.Sum(nil))
}

func contentHash(body []byte) string {
	return joinedContentHash(body)
}

func pathWithinRoot(root, path string) bool {
	realRoot, err := EvalSymlinksBestEffort(root)
	if err != nil {
		return false
	}
	realPath, err := EvalSymlinksBestEffort(path)
	if err != nil {
		return false
	}
	rel, err := filepath.Rel(realRoot, realPath)
	if err != nil {
		return false
	}
	return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}

// EvalSymlinksBestEffort resolves symlinks along the longest existing prefix of
// path and re-appends any remaining components. It lets callers perform a
// symlink-safe containment check even when path does not yet exist on disk
// (e.g., a file that is about to be created). Exported so cmd/ and
// internal/app/ share one canonical implementation.
func EvalSymlinksBestEffort(path string) (string, error) {
	abs, err := filepath.Abs(path)
	if err != nil {
		return "", err
	}
	current := filepath.Clean(abs)
	var tail []string
	for {
		if _, statErr := os.Lstat(current); statErr == nil {
			break
		}
		parent := filepath.Dir(current)
		if parent == current {
			return current, nil
		}
		tail = append([]string{filepath.Base(current)}, tail...)
		current = parent
	}
	resolved, err := filepath.EvalSymlinks(current)
	if err != nil {
		return "", err
	}
	for _, seg := range tail {
		resolved = filepath.Join(resolved, seg)
	}
	return filepath.Clean(resolved), nil
}

func maxScannerTokenSize(size int) int {
	if size < 64*1024 {
		return 64 * 1024
	}
	return size + 1
}

// Size caps for indexed content. These bound the memory cost of reading any
// single spec/body so a crafted input in a shared workspace cannot trigger an
// OOM during `pituitary index`.
const (
	maxSpecTOMLBytes     = 1 * 1024 * 1024  // 1 MiB
	maxMarkdownBodyBytes = 10 * 1024 * 1024 // 10 MiB
)

// readBoundedFile reads path in full but rejects files larger than maxBytes.
// The read itself is wrapped in an io.LimitReader, so a file that is swapped
// or grows between open-time and read-time still cannot trigger an unbounded
// allocation.
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
	// #nosec G304 -- callers validate path containment; LimitReader enforces the allocation bound.
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer f.Close()
	return readAllLimited(f, maxBytes, filepath.ToSlash(path))
}

// readAllLimited reads up to maxBytes+1 bytes from r and returns an error when
// the source exceeds maxBytes. The extra byte is the "one past the limit"
// sentinel that tells us the source was longer than allowed.
func readAllLimited(r io.Reader, maxBytes int64, label string) ([]byte, error) {
	data, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
	if err != nil {
		return nil, err
	}
	if int64(len(data)) > maxBytes {
		return nil, fmt.Errorf("%s exceeds %d-byte size limit", label, maxBytes)
	}
	return data, nil
}
