package kb

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/binary"
	"encoding/hex"
	"errors"
	"fmt"
	"strings"
	"time"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

var (
	ErrSourceContentMigrationConflict = errors.New("kb source content migration conflict")
	ErrSourceContentMappingNotFound   = errors.New("kb source content mapping not found")
)

const (
	SourceSearchProjectionMaxDocuments     = 2_000
	SourceSearchProjectionMaxDocumentBytes = 64 << 10
	SourceSearchProjectionMaxTextBytes     = int64(8 << 20)
	SourceSearchProjectionMaxResults       = 50
)

type SourceSearchDocumentInput struct {
	Path       string
	Title      string
	SearchText string
}

type SourceSearchDocumentHit struct {
	Path       string
	Title      string
	SearchText string
}

type ProjectSourceContentMappingParams struct {
	ID                        string
	WorkspaceID               string
	KnowledgeBaseID           string
	SourceID                  string
	SourceRevisionID          string
	RepositoryRole            string
	RepositoryID              string
	CanonicalBranch           string
	CommitSHA                 string
	ContentID                 string
	ContentVersionID          string
	RootDigest                string
	FileCount                 int
	SizeBytes                 int64
	SourceCommittedAt         time.Time
	SearchProjectionDigest    string
	SearchProjectionTruncated bool
	SearchDocuments           []SourceSearchDocumentInput
	VerifiedAt                time.Time
}

// ProjectSourceContentMapping atomically records one verified legacy import
// and attaches its immutable ContentVersion to the selected source
// revision. It has no job queue, retry cursor, lease, or source write fence.
// A caller retries the same exact mapping after any ambiguous failure.
func (s *Store) ProjectSourceContentMapping(ctx context.Context, params ProjectSourceContentMappingParams) (SourceContentMapping, error) {
	if err := s.ensureDB(); err != nil {
		return SourceContentMapping{}, err
	}
	normalizeProjectSourceContentMapping(&params)
	if err := validateProjectSourceContentMapping(params); err != nil {
		return SourceContentMapping{}, err
	}

	mapping := SourceContentMapping{
		ID: params.ID, WorkspaceID: params.WorkspaceID, KnowledgeBaseID: params.KnowledgeBaseID,
		SourceID: params.SourceID, SourceRevisionID: params.SourceRevisionID,
		RepositoryRole: params.RepositoryRole, RepositoryID: params.RepositoryID,
		CanonicalBranch: params.CanonicalBranch, CommitSHA: params.CommitSHA,
		ContentID: params.ContentID, ContentVersionID: params.ContentVersionID,
		RootDigest: params.RootDigest, FileCount: params.FileCount, SizeBytes: params.SizeBytes, SourceCommittedAt: params.SourceCommittedAt,
		SearchProjectionDigest: params.SearchProjectionDigest, SearchDocumentCount: len(params.SearchDocuments),
		SearchTextBytes: sourceSearchDocumentBytes(params.SearchDocuments), SearchProjectionTruncated: params.SearchProjectionTruncated,
		SearchProjectedAt: &params.VerifiedAt,
		VerifiedAt:        &params.VerifiedAt, CreatedAt: params.VerifiedAt, UpdatedAt: params.VerifiedAt,
	}

	var projected SourceContentMapping
	err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		var revision SourceRevision
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
			Where("workspace_id = ? AND knowledge_base_id = ? AND source_id = ? AND id = ?",
				params.WorkspaceID, params.KnowledgeBaseID, params.SourceID, params.SourceRevisionID).
			First(&revision).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return ErrSourceContentMigrationConflict
			}
			return err
		}
		if !strings.EqualFold(strings.TrimSpace(revision.SourceCommitSHA), params.CommitSHA) {
			return ErrSourceContentMigrationConflict
		}

		var existing SourceContentMapping
		err := tx.Where("ags_repository_id = ? AND ags_commit_sha = ?", params.RepositoryID, params.CommitSHA).First(&existing).Error
		switch {
		case err == nil:
			if !sameSourceContentMapping(existing, mapping) {
				return ErrSourceContentMigrationConflict
			}
			mapping = existing
		case errors.Is(err, gorm.ErrRecordNotFound):
			if err := tx.Create(&mapping).Error; err != nil {
				return fmt.Errorf("create kb source content mapping: %w", err)
			}
			if len(params.SearchDocuments) > 0 {
				documents := make([]SourceSearchDocument, 0, len(params.SearchDocuments))
				for index, document := range params.SearchDocuments {
					documents = append(documents, SourceSearchDocument{
						MappingID: mapping.ID, Ordinal: index, WorkspaceID: mapping.WorkspaceID,
						KnowledgeBaseID: mapping.KnowledgeBaseID, SourceID: mapping.SourceID, SourceRevisionID: mapping.SourceRevisionID,
						ContentID: mapping.ContentID, ContentVersionID: mapping.ContentVersionID,
						Path: document.Path, Title: document.Title, SearchText: document.SearchText,
						TextBytes: len(document.SearchText), CreatedAt: params.VerifiedAt,
					})
				}
				if err := tx.CreateInBatches(documents, 200).Error; err != nil {
					return fmt.Errorf("create KB source search projection: %w", err)
				}
			}
		default:
			return err
		}

		alreadyProjected := revision.SourceContentID == mapping.ContentID &&
			revision.SourceContentVersionID == mapping.ContentVersionID && revision.SourceContentVerifiedAt != nil
		if alreadyProjected {
			projected = mapping
			return nil
		}
		if revision.SourceContentID != "" || revision.SourceContentVersionID != "" || revision.SourceContentVerifiedAt != nil {
			return ErrSourceContentMigrationConflict
		}
		result := tx.Model(&SourceRevision{}).
			Where("workspace_id = ? AND knowledge_base_id = ? AND source_id = ? AND id = ? AND source_commit_sha = ? AND source_content_id = '' AND source_content_version_id = '' AND source_content_verified_at IS NULL",
				params.WorkspaceID, params.KnowledgeBaseID, params.SourceID, params.SourceRevisionID, revision.SourceCommitSHA).
			Updates(map[string]any{
				"source_content_id": mapping.ContentID, "source_content_version_id": mapping.ContentVersionID,
				"source_content_verified_at": params.VerifiedAt, "updated_at": params.VerifiedAt,
			})
		if result.Error != nil {
			return fmt.Errorf("project kb source content mapping: %w", result.Error)
		}
		if result.RowsAffected != 1 {
			return ErrSourceContentMigrationConflict
		}
		projected = mapping
		return nil
	})
	return projected, err
}

func (s *Store) GetSourceContentMapping(ctx context.Context, repositoryID, commitSHA string) (SourceContentMapping, error) {
	if err := s.ensureDB(); err != nil {
		return SourceContentMapping{}, err
	}
	var mapping SourceContentMapping
	err := s.db.WithContext(ctx).
		Where("ags_repository_id = ? AND ags_commit_sha = ?", strings.TrimSpace(repositoryID), strings.ToLower(strings.TrimSpace(commitSHA))).
		First(&mapping).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return SourceContentMapping{}, ErrSourceContentMappingNotFound
	}
	return mapping, err
}

func (s *Store) GetSourceRevision(ctx context.Context, workspaceID, knowledgeBaseID, sourceID, revisionID string) (SourceRevision, error) {
	if err := s.ensureDB(); err != nil {
		return SourceRevision{}, err
	}
	var revision SourceRevision
	err := s.db.WithContext(ctx).
		Where("workspace_id = ? AND knowledge_base_id = ? AND source_id = ? AND id = ?",
			strings.TrimSpace(workspaceID), strings.TrimSpace(knowledgeBaseID), strings.TrimSpace(sourceID), strings.TrimSpace(revisionID)).
		First(&revision).Error
	return revision, err
}

// ListSourceContentMappingsForRevisions resolves only the explicitly selected
// source revisions. Retrieval uses it to verify that a native authority has a
// complete immutable mapping without issuing one mapping query per source.
func (s *Store) ListSourceContentMappingsForRevisions(ctx context.Context, workspaceID string, revisionIDs []string) ([]SourceContentMapping, error) {
	if err := s.ensureDB(); err != nil {
		return nil, err
	}
	revisionIDs = uniqueTrimmedStrings(revisionIDs)
	if len(revisionIDs) == 0 {
		return []SourceContentMapping{}, nil
	}
	var mappings []SourceContentMapping
	if err := s.db.WithContext(ctx).
		Where("workspace_id = ? AND source_revision_id IN ?", strings.TrimSpace(workspaceID), revisionIDs).
		Limit(len(revisionIDs)).
		Find(&mappings).Error; err != nil {
		return nil, fmt.Errorf("list KB source content mappings for revisions: %w", err)
	}
	return mappings, nil
}

// GetSourceContentMappingByVersion resolves one retained exact version for
// citation replay. ContentVersionID is unique, while workspace and source are
// repeated here as an authorization/scope fence rather than inferred from the
// opaque version identifier.
func (s *Store) GetSourceContentMappingByVersion(ctx context.Context, workspaceID, sourceID, contentVersionID string) (SourceContentMapping, error) {
	if err := s.ensureDB(); err != nil {
		return SourceContentMapping{}, err
	}
	var mapping SourceContentMapping
	err := s.db.WithContext(ctx).
		Where("workspace_id = ? AND source_id = ? AND content_version_id = ?", strings.TrimSpace(workspaceID), strings.TrimSpace(sourceID), strings.TrimSpace(contentVersionID)).
		First(&mapping).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return SourceContentMapping{}, ErrSourceContentMappingNotFound
	}
	if err != nil {
		return SourceContentMapping{}, fmt.Errorf("get KB source content mapping by version: %w", err)
	}
	return mapping, nil
}

// ListSourceContentMappingsByVersions resolves the caller-selected exact
// versions in one bounded query. ContentVersionID is unique; workspace,
// source, and content remain explicit authorization fences.
func (s *Store) ListSourceContentMappingsByVersions(ctx context.Context, workspaceID, sourceID, contentID string, contentVersionIDs []string) ([]SourceContentMapping, error) {
	if err := s.ensureDB(); err != nil {
		return nil, err
	}
	versionIDs := uniqueTrimmedStrings(contentVersionIDs)
	if len(versionIDs) == 0 {
		return []SourceContentMapping{}, nil
	}
	var mappings []SourceContentMapping
	if err := s.db.WithContext(ctx).
		Select("workspace_id", "knowledge_base_id", "source_id", "repository_role", "content_id", "content_version_id").
		Where("workspace_id = ? AND source_id = ? AND content_id = ? AND content_version_id IN ?",
			strings.TrimSpace(workspaceID), strings.TrimSpace(sourceID), strings.TrimSpace(contentID), versionIDs).
		Limit(len(versionIDs)).
		Find(&mappings).Error; err != nil {
		return nil, fmt.Errorf("list KB source content mappings by versions: %w", err)
	}
	return mappings, nil
}

const sourceContentRepositoryRolePresenceQuery = `
SELECT
	EXISTS(SELECT 1 FROM knowledge_base_source_content_mappings WHERE workspace_id = @workspace_id AND knowledge_base_id = @knowledge_base_id AND source_id = @source_id AND content_id = @content_id AND repository_role = @canonical LIMIT 1) AS has_canonical,
	EXISTS(SELECT 1 FROM knowledge_base_source_content_mappings WHERE workspace_id = @workspace_id AND knowledge_base_id = @knowledge_base_id AND source_id = @source_id AND content_id = @content_id AND repository_role = @insights LIMIT 1) AS has_insights,
	EXISTS(SELECT 1 FROM knowledge_base_source_content_mappings WHERE workspace_id = @workspace_id AND knowledge_base_id = @knowledge_base_id AND source_id = @source_id AND content_id = @content_id AND repository_role = @mirror LIMIT 1) AS has_mirror,
	EXISTS(SELECT 1 FROM knowledge_base_source_content_mappings WHERE workspace_id = @workspace_id AND knowledge_base_id = @knowledge_base_id AND source_id = @source_id AND content_id = @content_id AND repository_role < @canonical LIMIT 1) AS has_invalid_before,
	EXISTS(SELECT 1 FROM knowledge_base_source_content_mappings WHERE workspace_id = @workspace_id AND knowledge_base_id = @knowledge_base_id AND source_id = @source_id AND content_id = @content_id AND repository_role > @canonical AND repository_role < @insights LIMIT 1) AS has_invalid_canonical_insights,
	EXISTS(SELECT 1 FROM knowledge_base_source_content_mappings WHERE workspace_id = @workspace_id AND knowledge_base_id = @knowledge_base_id AND source_id = @source_id AND content_id = @content_id AND repository_role > @insights AND repository_role < @mirror LIMIT 1) AS has_invalid_insights_mirror,
	EXISTS(SELECT 1 FROM knowledge_base_source_content_mappings WHERE workspace_id = @workspace_id AND knowledge_base_id = @knowledge_base_id AND source_id = @source_id AND content_id = @content_id AND repository_role > @mirror LIMIT 1) AS has_invalid_after`

type sourceContentRepositoryRolePresence struct {
	HasCanonical               bool `gorm:"column:has_canonical"`
	HasInsights                bool `gorm:"column:has_insights"`
	HasMirror                  bool `gorm:"column:has_mirror"`
	HasInvalidBefore           bool `gorm:"column:has_invalid_before"`
	HasInvalidCanonicalInsight bool `gorm:"column:has_invalid_canonical_insights"`
	HasInvalidInsightMirror    bool `gorm:"column:has_invalid_insights_mirror"`
	HasInvalidAfter            bool `gorm:"column:has_invalid_after"`
}

// ListSourceContentRepositoryRolesByContent resolves the closed set of roles
// represented in one exact native lineage. Each known role and each gap around
// the three known role values is an independent index seek. Runtime work is
// therefore bounded by seven seeks rather than by the lineage's version
// history; an unknown persisted role is returned as a fail-closed sentinel.
func (s *Store) ListSourceContentRepositoryRolesByContent(ctx context.Context, workspaceID, knowledgeBaseID, sourceID, contentID string) ([]string, error) {
	if err := s.ensureDB(); err != nil {
		return nil, err
	}
	var presence sourceContentRepositoryRolePresence
	if err := s.db.WithContext(ctx).Raw(sourceContentRepositoryRolePresenceQuery, sourceContentRepositoryRolePresenceArgs(
		workspaceID, knowledgeBaseID, sourceID, contentID,
	)...).Scan(&presence).Error; err != nil {
		return nil, fmt.Errorf("list KB source content repository roles: %w", err)
	}
	roles := make([]string, 0, 4)
	if presence.HasCanonical {
		roles = append(roles, "canonical")
	}
	if presence.HasInsights {
		roles = append(roles, "insights")
	}
	if presence.HasMirror {
		roles = append(roles, "mirror")
	}
	if presence.HasInvalidBefore || presence.HasInvalidCanonicalInsight || presence.HasInvalidInsightMirror || presence.HasInvalidAfter {
		roles = append(roles, "__invalid_repository_role__")
	}
	return roles, nil
}

func sourceContentRepositoryRolePresenceArgs(workspaceID, knowledgeBaseID, sourceID, contentID string) []any {
	return []any{
		sql.Named("workspace_id", strings.TrimSpace(workspaceID)),
		sql.Named("knowledge_base_id", strings.TrimSpace(knowledgeBaseID)),
		sql.Named("source_id", strings.TrimSpace(sourceID)),
		sql.Named("content_id", strings.TrimSpace(contentID)),
		sql.Named("canonical", "canonical"),
		sql.Named("insights", "insights"),
		sql.Named("mirror", "mirror"),
	}
}

// ListSourceContentDocumentsForVersion returns only the bounded importer-owned
// projection for one exact source version. KQA may evaluate a POSIX expression
// over these rows, but it never opens or scans a Content Store tree for search.
func (s *Store) ListSourceContentDocumentsForVersion(ctx context.Context, workspaceID, sourceID, contentID, contentVersionID string) ([]SourceSearchDocument, error) {
	if err := s.ensureDB(); err != nil {
		return nil, err
	}
	var rows []SourceSearchDocument
	if err := s.db.WithContext(ctx).
		Where("workspace_id = ? AND source_id = ? AND content_id = ? AND content_version_id = ?", strings.TrimSpace(workspaceID), strings.TrimSpace(sourceID), strings.TrimSpace(contentID), strings.TrimSpace(contentVersionID)).
		Order("ordinal ASC").
		Limit(SourceSearchProjectionMaxDocuments).
		Find(&rows).Error; err != nil {
		return nil, fmt.Errorf("list KB source search projection documents: %w", err)
	}
	return rows, nil
}

// SearchSourceContentDocuments searches only one exact imported ContentVersion.
// The candidate set is bounded by the importer-owned projection limits.
func (s *Store) SearchSourceContentDocuments(ctx context.Context, workspaceID, sourceID, contentID, contentVersionID, query string, limit, offset int) ([]SourceSearchDocumentHit, bool, error) {
	if err := s.ensureDB(); err != nil {
		return nil, false, err
	}
	workspaceID = strings.TrimSpace(workspaceID)
	sourceID = strings.TrimSpace(sourceID)
	contentID = strings.TrimSpace(contentID)
	contentVersionID = strings.TrimSpace(contentVersionID)
	query = strings.ToLower(strings.TrimSpace(query))
	if workspaceID == "" || sourceID == "" || contentID == "" || contentVersionID == "" || query == "" ||
		limit <= 0 || limit > SourceSearchProjectionMaxResults || offset < 0 {
		return nil, false, fmt.Errorf("KB source search projection scope and bounds are required")
	}
	if offset >= SourceSearchProjectionMaxDocuments {
		return []SourceSearchDocumentHit{}, false, nil
	}
	var rows []SourceSearchDocument
	err := s.db.WithContext(ctx).
		Where("workspace_id = ? AND source_id = ? AND content_id = ? AND content_version_id = ?", workspaceID, sourceID, contentID, contentVersionID).
		Where("INSTR(LOWER(title), ?) > 0 OR INSTR(LOWER(path), ?) > 0 OR INSTR(LOWER(search_text), ?) > 0", query, query, query).
		Order(clause.Expr{SQL: "CASE WHEN INSTR(LOWER(title), ?) > 0 OR INSTR(LOWER(path), ?) > 0 THEN 0 ELSE 1 END", Vars: []any{query, query}, WithoutParentheses: true}).
		Order("ordinal ASC").
		Offset(offset).
		Limit(limit + 1).
		Find(&rows).Error
	if err != nil {
		return nil, false, fmt.Errorf("search KB source content projection: %w", err)
	}
	hasMore := len(rows) > limit
	if hasMore {
		rows = rows[:limit]
	}
	hits := make([]SourceSearchDocumentHit, 0, len(rows))
	for _, row := range rows {
		hits = append(hits, SourceSearchDocumentHit{Path: row.Path, Title: row.Title, SearchText: row.SearchText})
	}
	return hits, hasMore, nil
}

func normalizeProjectSourceContentMapping(params *ProjectSourceContentMappingParams) {
	params.ID = strings.TrimSpace(params.ID)
	params.WorkspaceID = strings.TrimSpace(params.WorkspaceID)
	params.KnowledgeBaseID = strings.TrimSpace(params.KnowledgeBaseID)
	params.SourceID = strings.TrimSpace(params.SourceID)
	params.SourceRevisionID = strings.TrimSpace(params.SourceRevisionID)
	params.RepositoryRole = strings.TrimSpace(params.RepositoryRole)
	params.RepositoryID = strings.TrimSpace(params.RepositoryID)
	params.CanonicalBranch = strings.TrimSpace(params.CanonicalBranch)
	params.CommitSHA = strings.ToLower(strings.TrimSpace(params.CommitSHA))
	params.ContentID = strings.TrimSpace(params.ContentID)
	params.ContentVersionID = strings.TrimSpace(params.ContentVersionID)
	params.RootDigest = strings.ToLower(strings.TrimSpace(params.RootDigest))
	params.SearchProjectionDigest = strings.ToLower(strings.TrimSpace(params.SearchProjectionDigest))
	for index := range params.SearchDocuments {
		params.SearchDocuments[index].Path = strings.Trim(strings.TrimSpace(params.SearchDocuments[index].Path), "/")
		params.SearchDocuments[index].Title = strings.TrimSpace(params.SearchDocuments[index].Title)
	}
	params.SourceCommittedAt = params.SourceCommittedAt.UTC()
	params.VerifiedAt = params.VerifiedAt.UTC()
}

func validateProjectSourceContentMapping(params ProjectSourceContentMappingParams) error {
	if params.ID == "" || params.WorkspaceID == "" || params.KnowledgeBaseID == "" || params.SourceID == "" || params.SourceRevisionID == "" ||
		params.RepositoryRole == "" || params.RepositoryID == "" || params.CanonicalBranch == "" || !validSourceMigrationHex(params.CommitSHA, 20) ||
		params.ContentID == "" || params.ContentVersionID == "" || !validSourceMigrationHex(params.RootDigest, 32) || params.FileCount <= 0 ||
		params.SizeBytes < 0 || !validSourceMigrationHex(params.SearchProjectionDigest, 32) ||
		!strings.EqualFold(params.SearchProjectionDigest, SourceSearchProjectionDigest(params.SearchDocuments, params.SearchProjectionTruncated)) ||
		len(params.SearchDocuments) > SourceSearchProjectionMaxDocuments || sourceSearchDocumentBytes(params.SearchDocuments) > SourceSearchProjectionMaxTextBytes ||
		!validSourceSearchDocuments(params.SearchDocuments) || params.SourceCommittedAt.IsZero() || params.VerifiedAt.IsZero() {
		return fmt.Errorf("kb source content mapping identity and verification are required")
	}
	return nil
}

func sameSourceContentMapping(left, right SourceContentMapping) bool {
	return left.WorkspaceID == right.WorkspaceID && left.KnowledgeBaseID == right.KnowledgeBaseID && left.SourceID == right.SourceID &&
		left.SourceRevisionID == right.SourceRevisionID && left.RepositoryRole == right.RepositoryRole && left.RepositoryID == right.RepositoryID &&
		left.CanonicalBranch == right.CanonicalBranch && strings.EqualFold(left.CommitSHA, right.CommitSHA) && left.ContentID == right.ContentID &&
		left.ContentVersionID == right.ContentVersionID && strings.EqualFold(left.RootDigest, right.RootDigest) && left.FileCount == right.FileCount &&
		left.SizeBytes == right.SizeBytes && left.SourceCommittedAt.Equal(right.SourceCommittedAt) &&
		strings.EqualFold(left.SearchProjectionDigest, right.SearchProjectionDigest) && left.SearchDocumentCount == right.SearchDocumentCount &&
		left.SearchTextBytes == right.SearchTextBytes && left.SearchProjectionTruncated == right.SearchProjectionTruncated &&
		left.SearchProjectedAt != nil && left.VerifiedAt != nil
}

func sourceSearchDocumentBytes(documents []SourceSearchDocumentInput) int64 {
	var total int64
	for _, document := range documents {
		total += int64(len(document.SearchText))
	}
	return total
}

// SourceSearchProjectionDigest binds the exact ordered search documents and
// truncation marker recorded with one immutable source ContentVersion.
func SourceSearchProjectionDigest(documents []SourceSearchDocumentInput, truncated bool) string {
	digest := sha256.New()
	var length [8]byte
	for _, document := range documents {
		for _, value := range []string{document.Path, document.Title, document.SearchText} {
			binary.BigEndian.PutUint64(length[:], uint64(len(value)))
			_, _ = digest.Write(length[:])
			_, _ = digest.Write([]byte(value))
		}
	}
	if truncated {
		_, _ = digest.Write([]byte{1})
	} else {
		_, _ = digest.Write([]byte{0})
	}
	return hex.EncodeToString(digest.Sum(nil))
}

func validSourceSearchDocuments(documents []SourceSearchDocumentInput) bool {
	seen := make(map[string]struct{}, len(documents))
	for _, document := range documents {
		if document.Path == "" || document.Title == "" || len(document.Title) > 255 || len(document.SearchText) > SourceSearchProjectionMaxDocumentBytes {
			return false
		}
		if _, exists := seen[document.Path]; exists {
			return false
		}
		seen[document.Path] = struct{}{}
	}
	return true
}

func validSourceMigrationHex(value string, decodedBytes int) bool {
	if len(value) != decodedBytes*2 {
		return false
	}
	decoded, err := hex.DecodeString(value)
	return err == nil && len(decoded) == decodedBytes
}
