package service

// Wiki git ingest: replay git-backed wiki repos into the wikicatalog so REST
// reads can serve from the catalog while direct *.wiki.git pushes remain
// visible. The bulk MigrateAllWikis entry point is still used for legacy
// one-time imports; the single-repo path is the steady-state git-push ingest.
// Each commit's original SHA is preserved as wiki_changesets.synth_commit_sha
// so ingest is idempotent and resumable.

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"sort"
	"strings"
	"time"

	"github.com/go-git/go-git/v5/plumbing"
	"gorm.io/gorm"

	"github.com/pingcap/agent-git-service/internal/db"
	"github.com/pingcap/agent-git-service/internal/gitstore"
	"github.com/pingcap/agent-git-service/internal/wikicatalog"
)

// WikiGitIngestOptions tunes a wiki git ingest run.
type WikiGitIngestOptions struct {
	// SkipIncompatibleSlugs, when true, drops pages whose slug cannot
	// be represented by the catalog's single slug grammar.
	SkipIncompatibleSlugs bool

	// ReceivePackRepairOwnerToken identifies the live receive-pack that owns an
	// in-progress repair row. Empty tokens are used for explicit repair runs.
	ReceivePackRepairOwnerToken string
}

// WikiMigrationOptions is kept for the bulk legacy migration entry point.
type WikiMigrationOptions = WikiGitIngestOptions

// MigrateAllWikis replays every legacy wiki bare repo into the wiki
// catalog. Run during a maintenance window after AutoMigrate has
// created the wiki_* tables and before flipping REST traffic onto
// the catalog-backed read/write paths.
//
// Idempotent: each commit lands keyed by its original git SHA, so
// reruns skip commits already present in wiki_changesets.
//
// Returns the per-repo replay counts, plus the first error if any
// repo failed. Successful repos retain their progress regardless of
// later failures.
func (s *Service) MigrateAllWikis(ctx context.Context, opts WikiMigrationOptions) (MigrationReport, error) {
	report := MigrationReport{ByRepo: map[string]RepoMigrationStats{}}
	if _, err := s.GitForCtx(ctx); err != nil {
		return report, fmt.Errorf("wiki migration: resolve tenant git: %w", err)
	}
	if _, err := s.WikiCatalogForCtx(ctx); err != nil {
		return report, fmt.Errorf("wiki migration: resolve tenant catalog: %w", err)
	}

	var repos []db.Repository
	if err := s.DBForCtx(ctx).
		Where("has_wiki = ?", true).
		Order("id ASC").
		Find(&repos).Error; err != nil {
		return report, fmt.Errorf("wiki migration: list repos: %w", err)
	}

	for _, repo := range repos {
		stats, err := s.ingestOneWikiGit(ctx, repo, opts)
		report.ByRepo[repo.FullName] = stats
		if err != nil && report.FirstError == nil {
			report.FirstError = fmt.Errorf("repo %q: %w", repo.FullName, err)
		}
	}
	return report, report.FirstError
}

// MigrationReport summarizes a migration run.
type MigrationReport struct {
	ByRepo     map[string]RepoMigrationStats
	FirstError error
}

// WikiGitIngestStats captures what landed for one repo.
type WikiGitIngestStats struct {
	GitCommits   int // commits considered in this run
	NewCommits   int // commits applied during this run
	SkippedExist int // considered commits already in the catalog (resume path)
	Pages        int // catalog pages currently visible for the repo
}

// RepoMigrationStats is kept for MigrateAllWikis' legacy report shape.
type RepoMigrationStats = WikiGitIngestStats

// IngestWikiGit replays one wiki git repo into the catalog by full name. It is
// used for steady-state direct *.wiki.git pushes and for targeted repair runs.
// Because this is an explicit ingest, a missing content branch is authoritative
// and clears the corresponding catalog state.
func (s *Service) IngestWikiGit(ctx context.Context, repoFullName string, opts WikiGitIngestOptions) (WikiGitIngestStats, error) {
	rep, err := s.GetRepo(ctx, repoFullName)
	if err != nil {
		return WikiGitIngestStats{}, err
	}
	return s.ingestWikiGitAfterReceivePackLocked(ctx, rep.FullName, opts, false, true)
}

// ensureWikiCatalogCurrent is the read-path freshness hook for the wiki
// catalog. It compares the wiki repo's visible content branch
// (`wikiDefaultBranch`, matching GitHub wiki semantics) against the latest
// ingested commit recorded in the catalog. If the catalog lags, it schedules
// background git ingest instead of blocking the read path. Refreshes do not
// destructively reset catalog-originated state: Git can also lag temporarily
// after an interrupted ref publication. Explicit ingest and receive-pack own
// authoritative Git rewrites and branch deletion.
func (s *Service) ensureWikiCatalogCurrent(ctx context.Context, repoFullName string) error {
	return wikiCatalogFreshnessCacheDo(ctx, repoFullName, func() error {
		return s.ensureWikiCatalogCurrentUncached(ctx, repoFullName)
	})
}

func (s *Service) ensureWikiCatalogCurrentUncached(ctx context.Context, repoFullName string) error {
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return err
	}
	if _, err := s.WikiCatalogForCtx(ctx); err != nil {
		return err
	}
	full := wikiRepoFullName(repoFullName)
	if !git.Exists(ctx, full) {
		return nil
	}
	rep, err := s.getRepoBase(ctx, repoFullName)
	if err != nil {
		return err
	}
	last, err := s.loadLatestWikiChangesetState(ctx, rep.ID)
	if err != nil {
		return fmt.Errorf("read catalog head for %q: %w", repoFullName, err)
	}
	lastMigratedSHA := last.CommitSHA
	headSHA, err := git.ResolveContentCommit(ctx, full, wikiDefaultBranch)
	if err != nil || strings.TrimSpace(headSHA) == "" {
		branches, branchErr := git.ListBranches(ctx, full)
		if branchErr != nil {
			if err != nil {
				return fmt.Errorf("resolve wiki content commit for %q: %w", repoFullName, err)
			}
			return fmt.Errorf("list wiki branches for %q: %w", repoFullName, branchErr)
		}
		if len(branches) == 0 {
			if lastMigratedSHA != "" && last.allowMissingGitReset() {
				if err := s.resetWikiCatalogRepo(ctx, rep.ID); err != nil {
					return fmt.Errorf("reset empty wiki catalog for %q: %w", repoFullName, err)
				}
				if err := s.pruneWikiPageLabelsForMissingPages(ctx, rep.ID); err != nil {
					return fmt.Errorf("prune wiki page labels for empty wiki %q: %w", repoFullName, err)
				}
			}
			return nil
		}
		hasVisibleBranch := false
		for _, branch := range branches {
			if branch.Name == wikiDefaultBranch {
				hasVisibleBranch = true
				break
			}
		}
		if !hasVisibleBranch {
			if lastMigratedSHA != "" && last.allowMissingGitReset() {
				if err := s.resetWikiCatalogRepo(ctx, rep.ID); err != nil {
					return fmt.Errorf("reset catalog without %s for %q: %w", wikiDefaultBranch, repoFullName, err)
				}
				if err := s.pruneWikiPageLabelsForMissingPages(ctx, rep.ID); err != nil {
					return fmt.Errorf("prune wiki page labels without %s for %q: %w", wikiDefaultBranch, repoFullName, err)
				}
			}
			return nil
		}
		if err != nil {
			return fmt.Errorf("resolve wiki content commit for %q: %w", repoFullName, err)
		}
		return fmt.Errorf("resolve wiki content commit for %q: empty commit with %d branches", repoFullName, len(branches))
	}
	if strings.EqualFold(lastMigratedSHA, strings.ToLower(strings.TrimSpace(headSHA))) {
		return nil
	}
	if lastMigratedSHA != "" && !last.canIngestGitAdvance() {
		return nil
	}
	return s.kickBackgroundWikiGitIngest(ctx, rep)
}

// KickBackgroundWikiGitIngest schedules an asynchronous repo-scoped wiki git
// ingest using the caller context for repo identity lookup and the server
// lifecycle context for the background worker. Only one background ingest per
// repo runs at a time.
func (s *Service) KickBackgroundWikiGitIngest(ctx context.Context, repoFullName string) error {
	if _, err := s.GitForCtx(ctx); err != nil {
		return err
	}
	if _, err := s.WikiCatalogForCtx(ctx); err != nil {
		return err
	}
	rep, err := s.LookupRepoIdentity(ctx, repoFullName)
	if err != nil {
		return err
	}
	if rep.ID == 0 {
		return ErrNotFound
	}
	return s.kickBackgroundWikiGitIngest(ctx, rep)
}

// IngestWikiGitAfterReceivePack replays wiki git commits synchronously while
// the caller still owns the wiki repo's git write lock. This keeps direct wiki
// pushes serialized with REST writes across both Git and catalog state.
func (s *Service) IngestWikiGitAfterReceivePack(ctx context.Context, repoFullName string, opts WikiGitIngestOptions) (WikiGitIngestStats, error) {
	stats, err := s.ingestWikiGitAfterReceivePackLocked(ctx, repoFullName, opts, false, true)
	if err != nil {
		recordErr := s.withWikiCatalogWriteLockOnly(ctx, repoFullName, func(repo db.Repository) error {
			return s.recordWikiGitRepairObligationLocked(ctx, repo, false)
		})
		if recordErr != nil {
			return stats, fmt.Errorf(
				"record wiki Git repair obligation after receive-pack ingest failure for %s: %v: %w",
				repoFullName,
				err,
				recordErr,
			)
		}
		return stats, err
	}
	clearErr := s.withWikiCatalogWriteLockOnly(ctx, repoFullName, func(repo db.Repository) error {
		return s.clearWikiGitRepairObligationForOwner(ctx, repo.ID, opts.ReceivePackRepairOwnerToken)
	})
	if clearErr != nil {
		return stats, fmt.Errorf("clear wiki Git repair obligation after receive-pack ingest for %s: %w", repoFullName, clearErr)
	}
	return stats, nil
}

// IngestWikiGitAfterReceivePackLocked replays wiki git commits synchronously
// while the caller owns the wiki repo's git write lock and may already hold
// the catalog serialization mutex.
func (s *Service) IngestWikiGitAfterReceivePackLocked(ctx context.Context, repoFullName string, opts WikiGitIngestOptions) (WikiGitIngestStats, error) {
	repo, lookupErr := s.LookupRepoIdentity(ctx, repoFullName)
	if lookupErr != nil {
		return WikiGitIngestStats{}, lookupErr
	}
	if s.testWikiReceivePackIngestFailure != nil {
		if err := s.testWikiReceivePackIngestFailure(repoFullName); err != nil {
			if recordErr := s.recordWikiGitRepairObligationLocked(ctx, repo, false); recordErr != nil {
				return WikiGitIngestStats{}, fmt.Errorf(
					"record wiki Git repair obligation after receive-pack ingest failure for %s: %v: %w",
					repoFullName,
					err,
					recordErr,
				)
			}
			return WikiGitIngestStats{}, err
		}
	}
	stats, err := s.ingestWikiGitAfterReceivePackLocked(ctx, repoFullName, opts, true, true)
	if err != nil {
		if recordErr := s.recordWikiGitRepairObligationLocked(ctx, repo, false); recordErr != nil {
			return stats, fmt.Errorf(
				"record wiki Git repair obligation after receive-pack ingest failure for %s: %v: %w",
				repoFullName,
				err,
				recordErr,
			)
		}
		return stats, err
	}
	if err := s.clearWikiGitRepairObligationForOwner(ctx, repo.ID, opts.ReceivePackRepairOwnerToken); err != nil {
		return stats, fmt.Errorf("clear wiki Git repair obligation after receive-pack ingest for %s: %w", repoFullName, err)
	}
	return stats, nil
}

func (s *Service) ingestWikiGitAfterReceivePackLocked(
	ctx context.Context,
	repoFullName string,
	opts WikiGitIngestOptions,
	catalogLockHeld bool,
	gitStateAuthoritative bool,
) (WikiGitIngestStats, error) {
	var (
		stats WikiGitIngestStats
		err   error
	)
	run := func(repo db.Repository) error {
		stats, err = s.ingestOneWikiGitLockedWithMissingBranchPolicy(
			ctx,
			repo,
			opts,
			gitStateAuthoritative,
		)
		return err
	}
	if catalogLockHeld {
		repo, lookupErr := s.LookupRepoIdentity(ctx, repoFullName)
		if lookupErr != nil {
			return WikiGitIngestStats{}, lookupErr
		}
		if err := run(repo); err != nil {
			return WikiGitIngestStats{}, err
		}
		return stats, nil
	}
	lockErr := s.withWikiCatalogWriteLockOnly(ctx, repoFullName, run)
	if lockErr != nil {
		return WikiGitIngestStats{}, lockErr
	}
	return stats, nil
}

// IsWikiBackgroundGitIngestRunning reports whether a repo currently has a
// background wiki git ingest in flight.
func (s *Service) IsWikiBackgroundGitIngestRunning(ctx context.Context, repoFullName string) (bool, error) {
	rep, err := s.LookupRepoIdentity(ctx, repoFullName)
	if err != nil {
		return false, err
	}
	if rep.ID == 0 {
		return false, ErrNotFound
	}
	key, err := s.wikiRepoKey(ctx, rep)
	if err != nil {
		return false, err
	}
	return s.isWikiBackgroundGitIngestRunning(key), nil
}

func (s *Service) kickBackgroundWikiGitIngest(ctx context.Context, repo db.Repository) error {
	key, err := s.wikiRepoKey(ctx, repo)
	if err != nil {
		return err
	}
	if !s.claimWikiBackgroundGitIngest(key) {
		return nil
	}
	if s.testWikiBackgroundGitIngestStarted != nil {
		s.testWikiBackgroundGitIngestStarted(repo.FullName)
	}

	bgCtx, err := s.DetachContext(ctx)
	if err != nil {
		s.releaseWikiBackgroundGitIngest(key)
		return fmt.Errorf("detach background wiki git ingest: %w", err)
	}
	s.Wg.Add(1)
	go func() {
		defer s.Wg.Done()
		defer s.releaseWikiBackgroundGitIngest(key)

		if _, err := s.ingestOneWikiGit(bgCtx, repo, WikiGitIngestOptions{}); err != nil {
			slog.ErrorContext(bgCtx, "background wiki git ingest failed", "repo", repo.FullName, "error", err)
		}
	}()
	return nil
}

func (s *Service) ingestOneWikiGit(ctx context.Context, repo db.Repository, opts WikiGitIngestOptions) (WikiGitIngestStats, error) {
	return s.ingestWikiGitAfterReceivePackLocked(ctx, repo.FullName, opts, false, false)
}

func (s *Service) ingestOneWikiGitLocked(ctx context.Context, repo db.Repository, opts WikiGitIngestOptions) (WikiGitIngestStats, error) {
	return s.ingestOneWikiGitLockedWithMissingBranchPolicy(ctx, repo, opts, false)
}

func (s *Service) ingestOneWikiGitLockedWithMissingBranchPolicy(
	ctx context.Context,
	repo db.Repository,
	opts WikiGitIngestOptions,
	gitStateAuthoritative bool,
) (WikiGitIngestStats, error) {
	stats := WikiGitIngestStats{}
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return stats, err
	}
	full := wikiRepoFullName(repo.FullName)
	if !git.Exists(ctx, full) {
		return stats, nil
	}
	if err := s.rejectActiveWikiGitRepairObligationOwner(ctx, repo, opts.ReceivePackRepairOwnerToken); err != nil {
		return stats, err
	}
	headSHA, branchMissing, err := s.resolveWikiIngestBranchHead(ctx, full)
	if err != nil {
		return stats, fmt.Errorf("resolve wiki content commit for %q: %w", repo.FullName, err)
	}
	if branchMissing {
		if !gitStateAuthoritative {
			return stats, nil
		}
		if err := s.resetWikiCatalogRepo(ctx, repo.ID); err != nil {
			return stats, fmt.Errorf("reset wiki catalog after branch deletion: %w", err)
		}
		if err := s.pruneWikiPageLabelsForMissingPages(ctx, repo.ID); err != nil {
			return stats, fmt.Errorf("prune wiki page labels after branch deletion: %w", err)
		}
		return stats, nil
	}

	last, err := s.loadLatestWikiChangesetState(ctx, repo.ID)
	if err != nil {
		return stats, fmt.Errorf("load latest ingested SHA: %w", err)
	}
	lastIngestedSHA := last.CommitSHA
	headSHA = strings.ToLower(strings.TrimSpace(headSHA))
	if headSHA == "" {
		return stats, nil
	}

	if s.testWikiGitIngestAfterSnapshot != nil {
		s.testWikiGitIngestAfterSnapshot(repo.FullName)
	}

	if strings.EqualFold(lastIngestedSHA, headSHA) {
		stats.Pages = s.countLiveWikiPages(ctx, repo.ID)
		slog.InfoContext(ctx, "wiki git ingest: repo current",
			"repo", repo.FullName,
			"head", headSHA,
			"pages", stats.Pages,
		)
		return stats, nil
	}

	var commits []gitstore.SearchCommitInfo
	didReset := false
	switch {
	case lastIngestedSHA == "":
		commits, err = git.ListAllCommits(ctx, full, nil)
		if err != nil {
			return stats, fmt.Errorf("list commits: %w", err)
		}
		stats.GitCommits = len(commits)
	case last.canIngestGitAdvance():
		isAncestor, err := git.IsAncestor(ctx, full, lastIngestedSHA, headSHA)
		if err != nil {
			return stats, fmt.Errorf("check wiki git ancestry %s..%s: %w", lastIngestedSHA, headSHA, err)
		}
		if isAncestor {
			commits, err = git.ListCommitsRange(ctx, full, lastIngestedSHA, headSHA, nil)
			if err != nil {
				return stats, fmt.Errorf("list commits range %s..%s: %w", lastIngestedSHA, headSHA, err)
			}
			stats.GitCommits = len(commits)
		} else if gitStateAuthoritative || last.allowGitBackfillReset() {
			if err := s.resetWikiCatalogRepo(ctx, repo.ID); err != nil {
				return stats, fmt.Errorf("reset rewritten wiki catalog: %w", err)
			}
			didReset = true
			commits, err = git.ListAllCommits(ctx, full, nil)
			if err != nil {
				return stats, fmt.Errorf("list commits after reset: %w", err)
			}
			stats.GitCommits = len(commits)
		} else {
			stats.Pages = s.countLiveWikiPages(ctx, repo.ID)
			return stats, nil
		}
	default:
		stats.Pages = s.countLiveWikiPages(ctx, repo.ID)
		return stats, nil
	}

	existing, err := s.loadIngestedWikiCommitSHAs(ctx, repo.ID)
	if err != nil {
		return stats, fmt.Errorf("load ingested SHAs: %w", err)
	}

	// ListAllCommits returns commits in git log's natural order, which
	// is reverse-topological — every commit appears before its parents.
	// Plain reverse gives the chronological order we need so each
	// commit's parent has already landed in the catalog when we
	// process it. A date sort is wrong: real wiki workloads commonly
	// produce multiple commits within the same second (bulk imports,
	// rapid REST writes) and a date sort cannot break those ties.
	for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 {
		commits[i], commits[j] = commits[j], commits[i]
	}

	// Per-run caches so we avoid re-reading the same parent tree
	// twice (parent of commit N is current of commit N-1) and
	// re-resolving the same git author N times.
	st := &replayState{
		authors: map[string]*uint{},
	}
	for _, commit := range commits {
		if _, ok := existing[commit.SHA]; ok {
			stats.SkippedExist++
			continue
		}
		if err := s.replayCommitIntoCatalog(ctx, full, repo, commit, opts, st); err != nil {
			return stats, fmt.Errorf("commit %s: %w", commit.SHA, err)
		}
		stats.NewCommits++
		existing[commit.SHA] = struct{}{}
	}
	if didReset {
		if err := s.pruneWikiPageLabelsForMissingPages(ctx, repo.ID); err != nil {
			return stats, fmt.Errorf("prune wiki page labels after reset: %w", err)
		}
	}

	stats.Pages = s.countLiveWikiPages(ctx, repo.ID)

	slog.InfoContext(ctx, "wiki git ingest: repo done",
		"repo", repo.FullName,
		"git_commits", stats.GitCommits,
		"new", stats.NewCommits,
		"skipped", stats.SkippedExist,
		"pages", stats.Pages,
	)
	return stats, nil
}

func (s *Service) resolveWikiIngestBranchHead(ctx context.Context, wikiFullName string) (headSHA string, branchMissing bool, err error) {
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return "", false, err
	}
	headSHA, err = git.ResolveContentCommit(ctx, wikiFullName, wikiDefaultBranch)
	if err == nil {
		return headSHA, false, nil
	}
	if _, headErr := git.HeadSHA(ctx, wikiFullName, wikiDefaultBranch); errors.Is(headErr, plumbing.ErrReferenceNotFound) {
		return "", true, nil
	} else if headErr != nil {
		return "", false, headErr
	}
	return "", false, err
}

type wikiChangesetState struct {
	CommitSHA      string
	Source         wikicatalog.Source
	SynthFormatVer int16
}

func (s *Service) loadLatestWikiChangesetState(ctx context.Context, repoID uint) (wikiChangesetState, error) {
	return loadWikiCatalogHeadState(s.DBForCtx(ctx), repoID)
}

func loadWikiCatalogHeadState(database *gorm.DB, repoID uint) (wikiChangesetState, error) {
	var last db.WikiChangeset
	err := database.
		Table("wiki_changesets").
		Select("wiki_changesets.synth_commit_sha, wiki_changesets.source, wiki_changesets.synth_format_ver").
		Joins("JOIN wiki_repo_heads AS heads ON heads.head_changeset_id = wiki_changesets.changeset_id AND heads.repository_id = wiki_changesets.repository_id").
		Where("heads.repository_id = ?", repoID).
		Take(&last).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return wikiChangesetState{}, nil
	}
	if err != nil {
		return wikiChangesetState{}, err
	}
	return wikiChangesetState{
		CommitSHA:      strings.ToLower(strings.TrimSpace(last.SynthCommitSHA)),
		Source:         wikicatalog.Source(strings.TrimSpace(last.Source)),
		SynthFormatVer: last.SynthFormatVer,
	}, nil
}

func (s wikiChangesetState) allowGitBackfillReset() bool {
	return s.Source == wikicatalog.SourceGit || s.Source == wikicatalog.SourcePush
}

func (s wikiChangesetState) canIngestGitAdvance() bool {
	if s.allowGitBackfillReset() {
		return true
	}
	if s.Source == wikicatalog.SourceCompact {
		return false
	}
	return s.SynthFormatVer >= synthProjectionMaterialized
}

func (s wikiChangesetState) allowMissingGitReset() bool {
	// A catalog-originated commit may be durable before its ref publication
	// completes. Only Git-originated state makes a missing branch authoritative;
	// otherwise the catalog must survive so the prepared commit can be resumed.
	return s.allowGitBackfillReset()
}

func (s *Service) resetWikiCatalogRepo(ctx context.Context, repoID uint) error {
	return s.DBForCtx(ctx).Transaction(func(tx *gorm.DB) error {
		type blobRefCount struct {
			BlobSHA  string
			Refcount int64
		}
		var liveRefs []blobRefCount
		if err := tx.Model(&db.WikiPage{}).
			Select("head_blob_sha AS blob_sha, COUNT(*) AS refcount").
			Where("repository_id = ? AND deleted_at IS NULL AND body_size > ?",
				repoID, wikicatalog.MaxBodyInlineBytes).
			Group("head_blob_sha").
			Scan(&liveRefs).Error; err != nil {
			return fmt.Errorf("load live blob refs: %w", err)
		}
		for _, ref := range liveRefs {
			if strings.TrimSpace(ref.BlobSHA) == "" || ref.Refcount <= 0 {
				continue
			}
			if err := tx.Model(&db.WikiBlobRef{}).
				Where("blob_sha = ?", ref.BlobSHA).
				UpdateColumn("refcount", gorm.Expr("refcount - ?", ref.Refcount)).Error; err != nil {
				return fmt.Errorf("decrement blob ref %s: %w", ref.BlobSHA, err)
			}
		}

		pageIDs := tx.Model(&db.WikiPage{}).
			Select("page_id").
			Where("repository_id = ?", repoID)

		if err := tx.Where("repository_id = ?", repoID).Delete(&db.WikiSearchDocument{}).Error; err != nil {
			return fmt.Errorf("delete wiki search documents: %w", err)
		}
		if err := tx.Where("repository_id = ?", repoID).Delete(&db.WikiSearchProjectionTask{}).Error; err != nil {
			return fmt.Errorf("delete wiki search projection tasks: %w", err)
		}
		if err := tx.Where("source_type = ? AND source_repository_id = ?", issueReferenceSourceWikiPage, repoID).
			Delete(&db.IssueReference{}).Error; err != nil && !isMissingTableErr(err) {
			return fmt.Errorf("delete wiki issue references: %w", err)
		}
		if err := tx.Where("repository_id = ?", repoID).Delete(&db.WikiPageLink{}).Error; err != nil {
			return fmt.Errorf("delete wiki page links: %w", err)
		}
		if err := tx.Where("page_id IN (?)", pageIDs).Delete(&db.WikiPageRevision{}).Error; err != nil {
			return fmt.Errorf("delete wiki page revisions: %w", err)
		}
		if err := tx.Where("repository_id = ?", repoID).Delete(&db.WikiPage{}).Error; err != nil {
			return fmt.Errorf("delete wiki pages: %w", err)
		}
		if err := tx.Where("repository_id = ?", repoID).Delete(&db.WikiRepoHead{}).Error; err != nil {
			return fmt.Errorf("delete wiki repo head: %w", err)
		}
		if err := tx.Where("repository_id = ?", repoID).Delete(&db.WikiChangeset{}).Error; err != nil {
			return fmt.Errorf("delete wiki changesets: %w", err)
		}
		return nil
	})
}

func (s *Service) pruneWikiPageLabelsForMissingPages(ctx context.Context, repoID uint) error {
	return s.DBForCtx(ctx).Where(
		"repository_id = ? AND slug NOT IN (?)",
		repoID,
		s.DBForCtx(ctx).Model(&db.WikiPage{}).Select("slug").Where("repository_id = ? AND deleted_at IS NULL", repoID),
	).Delete(&db.WikiPageLabel{}).Error
}

func (s *Service) countLiveWikiPages(ctx context.Context, repoID uint) int {
	pageCount := int64(0)
	_ = s.DBForCtx(ctx).Model(&db.WikiPage{}).
		Where("repository_id = ? AND deleted_at IS NULL", repoID).
		Count(&pageCount)
	return int(pageCount)
}

func (s *Service) loadIngestedWikiCommitSHAs(ctx context.Context, repoID uint) (map[string]struct{}, error) {
	var existing []db.WikiChangeset
	err := s.DBForCtx(ctx).
		Select("synth_commit_sha").
		Where("repository_id = ?", repoID).
		Find(&existing).Error
	if err != nil {
		return nil, err
	}
	out := make(map[string]struct{}, len(existing))
	for _, cs := range existing {
		out[strings.ToLower(cs.SynthCommitSHA)] = struct{}{}
	}
	return out, nil
}

// replayCommitIntoCatalog computes the diff between commit and its
// first parent (or the empty tree for a root commit), constructs the
// corresponding wikicatalog.ChangeSetRequest, and applies it.
//
// Rename detection is intentionally not performed: the legacy code
// recorded renames as delete-of-old + add-of-new in a single commit,
// which the catalog replays as two changes. Page identity (page_id)
// is allocated fresh at the moment of the create, so historical
// rename chains lose page-id continuity — but since page_id is an
// internal identifier and never exposed by the legacy REST API,
// nothing observable changes.
// replayState carries forward per-run caches so git ingest does
// O(N) git tree reads instead of O(N) duplicated reads (parent of
// commit i equals current of commit i-1) and O(unique-authors)
// user lookups instead of O(N).
type replayState struct {
	prevSHA   string
	prevPaths []string
	prevBlobs map[string]string
	authors   map[string]*uint
}

func (s *Service) replayCommitIntoCatalog(ctx context.Context, full string, repo db.Repository, commit gitstore.SearchCommitInfo, opts WikiGitIngestOptions, st *replayState) error {
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return err
	}
	catalog, err := s.WikiCatalogForCtx(ctx)
	if err != nil {
		return err
	}
	// Wiki repos should hold linear history — they're written by REST
	// handlers committing one file change at a time. A merge commit
	// here means either a manual git push of an unusual workflow or a
	// gitstore bug. The diff-against-first-parent strategy used below
	// would silently drop a second-parent branch's content, so refuse
	// instead. Operators can resolve by squashing in source git
	// before ingest.
	if len(commit.ParentSHAs) > 1 {
		return fmt.Errorf("commit %s has %d parents; wiki history must be linear before ingest", commit.SHA, len(commit.ParentSHAs))
	}
	parent := ""
	if len(commit.ParentSHAs) == 1 {
		parent = commit.ParentSHAs[0]
	}

	// Reuse the previous commit's tree if the chain is linear (the
	// common case) — its current state equals this commit's parent.
	// Otherwise read fresh.
	var (
		parPaths []string
		parBlobs map[string]string
	)
	if parent != "" {
		if st.prevSHA == parent {
			parPaths = st.prevPaths
			parBlobs = st.prevBlobs
		} else {
			parPaths, err = git.ListTreeFilesAtRef(ctx, full, parent)
			if err != nil {
				return fmt.Errorf("list tree at parent %s: %w", parent, err)
			}
			parBlobs, err = git.BlobSHAs(ctx, full, parent, parPaths)
			if err != nil {
				return fmt.Errorf("blob SHAs at parent %s: %w", parent, err)
			}
		}
	}

	curPaths, err := git.ListTreeFilesAtRef(ctx, full, commit.SHA)
	if err != nil {
		return fmt.Errorf("list tree at %s: %w", commit.SHA, err)
	}
	curBlobs, err := git.BlobSHAs(ctx, full, commit.SHA, curPaths)
	if err != nil {
		return fmt.Errorf("blob SHAs at %s: %w", commit.SHA, err)
	}

	changes, err := s.diffToChanges(ctx, full, commit.SHA, curPaths, curBlobs, parBlobs, opts)
	if err != nil {
		return err
	}
	committedAt, err := parseCommitTime(commit.CommitterDate, commit.Date)
	if err != nil {
		return fmt.Errorf("parse commit time for %s: %w", commit.SHA, err)
	}
	authorID := s.resolveAuthorForGitIngestCached(ctx, commit, st)

	req := wikicatalog.ChangeSetRequest{
		RepositoryID:        repo.ID,
		AuthorID:            authorID,
		Source:              wikicatalog.SourceGit,
		Message:             commit.Message,
		Changes:             changes,
		OverrideCommitSHA:   strings.ToLower(strings.TrimSpace(commit.SHA)),
		OverrideCommittedAt: &committedAt,
	}
	if _, err := catalog.ApplyChangeSet(ctx, req); err != nil {
		return fmt.Errorf("apply: %w", err)
	}
	st.prevSHA = commit.SHA
	st.prevPaths = curPaths
	st.prevBlobs = curBlobs
	return nil
}

func (s *Service) resolveAuthorForGitIngestCached(ctx context.Context, commit gitstore.SearchCommitInfo, st *replayState) *uint {
	key := strings.ToLower(strings.TrimSpace(commit.Email)) + "|" + strings.TrimSpace(commit.Author)
	if cached, ok := st.authors[key]; ok {
		return cached
	}
	out := s.resolveAuthorForGitIngest(ctx, commit)
	st.authors[key] = out
	return out
}

// diffToChanges turns the file-set delta between parent and commit into
// wikicatalog.Change rows. Paths that are not wiki markdown paths (dotfiles,
// non-.md files) are skipped without error. A markdown path whose slug cannot
// be represented by the catalog's single slug grammar produces an error by
// default.
func (s *Service) diffToChanges(ctx context.Context, full, commitSHA string, curPaths []string, curBlobs, parBlobs map[string]string, opts WikiGitIngestOptions) ([]wikicatalog.Change, error) {
	git, err := s.GitForCtx(ctx)
	if err != nil {
		return nil, err
	}
	current := make(map[string]struct{}, len(curPaths))
	for _, p := range curPaths {
		current[p] = struct{}{}
	}
	var changes []wikicatalog.Change

	checkCompatible := func(slug, path string) (skip bool, err error) {
		if err := wikicatalog.ValidateWritable(slug); err == nil {
			return false, nil
		}
		if opts.SkipIncompatibleSlugs {
			slog.WarnContext(ctx, "wiki git ingest: skipping slug incompatible with catalog slug grammar",
				"sha", commitSHA, "path", path, "slug", slug)
			return true, nil
		}
		return false, fmt.Errorf("commit %s touches path %q whose slug %q cannot be represented by the catalog; rename the page in source git before ingesting, or set SkipIncompatibleSlugs=true to drop these pages with a warning",
			commitSHA, path, slug)
	}

	// Upserts: added or modified pages.
	for _, p := range curPaths {
		slug, ok := wikiGitIngestPathToSlug(p)
		if !ok {
			continue
		}
		if skip, err := checkCompatible(slug, p); err != nil {
			return nil, err
		} else if skip {
			continue
		}
		if parBlobs[p] == curBlobs[p] {
			continue
		}
		body, err := git.ReadFileAtRef(ctx, full, p, commitSHA)
		if err != nil {
			return nil, fmt.Errorf("read %s@%s: %w", p, commitSHA, err)
		}
		changes = append(changes, wikicatalog.Change{
			Op:   wikicatalog.OpUpsert,
			Slug: slug,
			Body: body,
		})
	}

	// Deletes: in parent, not in current.
	for p := range parBlobs {
		if _, ok := current[p]; ok {
			continue
		}
		slug, ok := wikiGitIngestPathToSlug(p)
		if !ok {
			continue
		}
		if skip, err := checkCompatible(slug, p); err != nil {
			return nil, err
		} else if skip {
			continue
		}
		changes = append(changes, wikicatalog.Change{
			Op:   wikicatalog.OpDelete,
			Slug: slug,
		})
	}

	// Stable ordering — the catalog rejects duplicate slug slots within
	// a changeset, and stable order makes failures reproducible.
	sort.Slice(changes, func(i, j int) bool {
		if changes[i].Slug != changes[j].Slug {
			return changes[i].Slug < changes[j].Slug
		}
		return changes[i].Op < changes[j].Op
	})
	return changes, nil
}

func wikiGitIngestPathToSlug(path string) (string, bool) {
	path = strings.TrimSpace(path)
	if path == "" || strings.HasPrefix(path, ".") || !strings.HasSuffix(path, wikiPageExt) {
		return "", false
	}
	return strings.TrimSuffix(path, wikiPageExt), true
}

func (s *Service) resolveAuthorForGitIngest(ctx context.Context, commit gitstore.SearchCommitInfo) *uint {
	email := strings.ToLower(strings.TrimSpace(commit.Email))
	if email != "" {
		usersByEmail := s.lookupUsersByEmailCI(ctx, []string{email})
		if u, ok := usersByEmail[email]; ok {
			id := u.ID
			return &id
		}
	}
	login := strings.TrimSpace(commit.Author)
	if login != "" {
		usersByLogin := s.GetUsersByLogins(ctx, []string{login})
		if u, ok := usersByLogin[login]; ok {
			id := u.ID
			return &id
		}
	}
	return nil
}

// parseCommitTime accepts the ISO-8601 strings emitted by git log
// %cI / %aI and returns the UTC instant. The first non-empty field
// that parses wins. If neither parses, parseCommitTime returns an
// error: silently fabricating time.Now() here would publish historical
// changesets with a present-day committed_at, which git ingest cannot
// recover from once the cutover runs.
func parseCommitTime(primary, fallback string) (time.Time, error) {
	var lastErr error
	for _, raw := range []string{primary, fallback} {
		raw = strings.TrimSpace(raw)
		if raw == "" {
			continue
		}
		t, err := time.Parse(time.RFC3339, raw)
		if err == nil {
			return t.UTC(), nil
		}
		lastErr = err
	}
	if lastErr != nil {
		return time.Time{}, fmt.Errorf("wiki git ingest: unparseable commit timestamps %q / %q: %w", primary, fallback, lastErr)
	}
	return time.Time{}, fmt.Errorf("wiki git ingest: no commit timestamp present")
}
