package index

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"sort"
	"strings"
	"time"
)

type Builder struct {
	options ChunkOptions
}

type previousIndexStateReader interface {
	ListIndexStates(context.Context, ChunkQuery) ([]IndexState, error)
}

func NewBuilder(options ChunkOptions) Builder {
	return Builder{options: normalizeChunkOptions(options)}
}

func FullBuild(ctx context.Context, reader SourceReader, writer DerivedWriter) (BuildReport, error) {
	return NewBuilder(ChunkOptions{}).FullBuild(ctx, reader, writer)
}

func FullBuildWithOptions(ctx context.Context, reader SourceReader, writer DerivedWriter, options ChunkOptions) (BuildReport, error) {
	return NewBuilder(options).FullBuild(ctx, reader, writer)
}

func IncrementalBuild(ctx context.Context, reader SourceReader, writer DerivedWriter) (BuildReport, error) {
	return NewBuilder(ChunkOptions{}).IncrementalBuild(ctx, reader, writer)
}

func IncrementalBuildWithOptions(ctx context.Context, reader SourceReader, writer DerivedWriter, options ChunkOptions) (BuildReport, error) {
	return NewBuilder(options).IncrementalBuild(ctx, reader, writer)
}

func (b Builder) FullBuild(ctx context.Context, reader SourceReader, writer DerivedWriter) (BuildReport, error) {
	return b.build(ctx, reader, writer, false)
}

func (b Builder) IncrementalBuild(ctx context.Context, reader SourceReader, writer DerivedWriter) (BuildReport, error) {
	return b.build(ctx, reader, writer, true)
}

func (b Builder) build(ctx context.Context, reader SourceReader, writer DerivedWriter, incremental bool) (BuildReport, error) {
	sources, err := reader.ListSources(ctx)
	if err != nil {
		return BuildReport{}, err
	}
	aliasIndex, diagnostics, collidingSources := buildAliasIndex(sources)
	if len(diagnostics) > 0 {
		if err := writer.WriteDiagnostics(ctx, diagnostics); err != nil {
			return BuildReport{}, err
		}
	}
	previousHashes := map[string]string{}
	if incremental {
		previousHashes = previousIndexStateHashes(ctx, writer, b.options.Policy)
	}
	report := BuildReport{Diagnostics: diagnostics, CollisionCount: len(diagnostics)}
	for _, source := range sources {
		if err := ctx.Err(); err != nil {
			return report, err
		}
		contentHash := ContentHash(source.Body)
		if incremental && previousHashes[indexStateHashKey(source.RepoID, source.ID, sourceRecordID(source), source.SnapshotID, b.options.Policy)] == contentHash {
			report.SkippedCount++
			continue
		}
		parsed := ParseSource(source)
		parsed.Diagnostics = append(parsed.Diagnostics, diagnosticsForSource(diagnostics, source.ID)...)
		if collidingSources[source.ID] {
			report.ProcessedCount++
			report.Diagnostics = append(report.Diagnostics, parsed.Diagnostics...)
			continue
		}
		derived := b.deriveSource(source, parsed, aliasIndex)
		if err := writer.ReplaceSourceDerived(ctx, derived); err != nil {
			return report, err
		}
		report.ProcessedCount++
		report.RewrittenRowCount += derived.RewrittenRowCount
		report.BrokenLinkCount += len(derived.BrokenLinks)
		report.Diagnostics = append(report.Diagnostics, parsed.Diagnostics...)
	}
	return report, nil
}

func previousIndexStateHashes(ctx context.Context, reader any, policy ChunkPolicy) map[string]string {
	stateReader, ok := reader.(previousIndexStateReader)
	if !ok {
		return map[string]string{}
	}
	states, err := stateReader.ListIndexStates(ctx, ChunkQuery{Policy: policy})
	if err != nil {
		return map[string]string{}
	}
	hashes := map[string]string{}
	for _, state := range states {
		hashes[indexStateHashKey(state.RepoID, state.SourceID, state.RecordID, state.SnapshotID, state.Policy)] = state.ContentHash
	}
	return hashes
}

func indexStateHashKey(repoID, sourceID, recordID, snapshotID string, policy ChunkPolicy) string {
	if policy == "" {
		policy = ChunkPolicyHeading
	}
	return repoID + "\x00" + sourceID + "\x00" + recordID + "\x00" + snapshotID + "\x00" + string(policy)
}

func (b Builder) deriveSource(source SourceRecord, parsed ParsedSource, aliasIndex map[string][]string) SourceDerived {
	anchors := BuildCitationAnchors(source, parsed)
	chunks := ChunkSourceWithOptions(source, parsed, b.options)
	anchorByStart := map[int]string{}
	for _, anchor := range anchors {
		anchorByStart[anchor.ByteStart] = anchor.ID
	}
	var links []DerivedLink
	var backlinks []BacklinkRow
	var broken []BrokenLink
	for _, link := range parsed.Links {
		key := aliasKey("id", link.Target)
		matches := aliasIndex[key]
		if len(matches) == 0 && strings.HasPrefix(link.Target, "#") {
			matches = []string{source.ID}
		}
		if len(matches) == 0 && strings.Contains(link.Target, ".md") {
			broken = append(broken, brokenLink(source, link, "unresolved_relative_path"))
			continue
		}
		if len(matches) == 0 {
			broken = append(broken, brokenLink(source, link, "unresolved_alias"))
			continue
		}
		if len(matches) > 1 {
			broken = append(broken, brokenLink(source, link, "ambiguous_alias"))
			continue
		}
		anchorID := anchorByStart[link.ByteStart]
		derived := DerivedLink{SourceID: source.ID, TargetID: matches[0], RawTarget: link.Target, Text: link.Text, Kind: link.Kind, LineStart: link.LineStart, LineEnd: link.LineEnd, SourceHash: parsed.ContentHash, AnchorID: anchorID}
		links = append(links, derived)
		backlinks = append(backlinks, BacklinkRow{SourceID: source.ID, TargetID: matches[0], Text: link.Text, Kind: link.Kind, LineStart: link.LineStart, LineEnd: link.LineEnd})
	}
	for i := range chunks {
		if chunks[i].CitationAnchorID == "" {
			chunks[i].CitationAnchorID = anchorByStart[chunks[i].ByteStart]
		}
	}
	ledger := []SourceLedgerRow{{SourceID: source.ID, Kind: source.Kind, Path: source.Path, Title: source.Title, Status: source.Status, ContentHash: parsed.ContentHash}}
	return SourceDerived{
		SourceID:          source.ID,
		ContentHash:       parsed.ContentHash,
		Links:             links,
		Backlinks:         backlinks,
		BrokenLinks:       broken,
		CitationAnchors:   anchors,
		Chunks:            chunks,
		SourceLedgerRows:  ledger,
		TaskBacklogRows:   taskRows(source, parsed, anchors),
		TrackRows:         trackRows(source, parsed, anchors),
		AcceptanceRows:    acceptanceRows(source, parsed, anchors),
		OpenQuestionRows:  openQuestionRows(source, parsed, anchors),
		IndexState:        IndexState{RepoID: source.RepoID, SourceID: source.ID, RecordID: sourceRecordID(source), SnapshotID: source.SnapshotID, ContentHash: parsed.ContentHash, RemoteRevision: source.RemoteRevision, SyncRevision: source.SyncRevision, SyncEventID: source.SyncEventID, SourceUpdatedAt: source.UpdatedAt.UTC(), Policy: b.options.Policy, IndexedAt: time.Now().UTC(), ChunkCount: len(chunks), CitationCount: len(anchors)},
		Diagnostics:       parsed.Diagnostics,
		RewrittenRowCount: 1 + len(links) + len(broken) + len(anchors) + len(chunks),
	}
}

func buildAliasIndex(sources []SourceRecord) (map[string][]string, []CollisionDiagnostic, map[string]bool) {
	index := map[string][]string{}
	seenID := map[string]string{}
	dedup := map[string]map[string]bool{}
	for _, source := range sources {
		ids := append([]Alias{{Type: "id", ID: source.ID}}, source.Aliases...)
		ids = append(ids, source.RemoteAliases...)
		for _, alias := range ids {
			if alias.ID == "" {
				continue
			}
			key := aliasKey(alias.Type, alias.ID)
			if dedup[key] == nil {
				dedup[key] = map[string]bool{}
			}
			if dedup[key][source.ID] {
				continue
			}
			dedup[key][source.ID] = true
			index[key] = append(index[key], source.ID)
			if alias.Type == "id" {
				if existing := seenID[alias.ID]; existing != "" && existing != source.ID {
					index[key] = uniqueStrings(append(index[key], existing, source.ID))
				} else {
					seenID[alias.ID] = source.ID
				}
			}
		}
	}
	var diagnostics []CollisionDiagnostic
	colliding := map[string]bool{}
	for key, matches := range index {
		matches = uniqueStrings(matches)
		index[key] = matches
		if len(matches) > 1 {
			kind := "ambiguous_alias"
			if strings.HasPrefix(key, "id:") {
				kind = "duplicate_stable_id"
			}
			for _, sourceID := range matches {
				diagnostics = append(diagnostics, CollisionDiagnostic{SourceID: sourceID, Kind: kind, Key: strings.TrimPrefix(key, "id:"), Message: key + " maps to multiple sources"})
				if kind == "duplicate_stable_id" {
					colliding[sourceID] = true
				}
			}
		}
	}
	return index, diagnostics, colliding
}

func diagnosticsForSource(all []CollisionDiagnostic, sourceID string) []CollisionDiagnostic {
	var out []CollisionDiagnostic
	for _, diagnostic := range all {
		if diagnostic.SourceID == sourceID {
			out = append(out, diagnostic)
		}
	}
	return out
}

func ContentHash(body string) string {
	sum := sha256.Sum256([]byte(body))
	return hex.EncodeToString(sum[:])
}

func stableHash(parts ...string) string {
	h := sha256.New()
	for _, part := range parts {
		h.Write([]byte(part))
		h.Write([]byte{0})
	}
	return hex.EncodeToString(h.Sum(nil))
}

func aliasKey(aliasType, id string) string {
	if aliasType == "" {
		aliasType = "id"
	}
	return aliasType + ":" + id
}

func uniqueStrings(values []string) []string {
	seen := map[string]bool{}
	var out []string
	for _, value := range values {
		if value == "" || seen[value] {
			continue
		}
		seen[value] = true
		out = append(out, value)
	}
	sort.Strings(out)
	return out
}

func brokenLink(source SourceRecord, link Link, reason string) BrokenLink {
	return BrokenLink{SourceID: source.ID, SourcePath: source.Path, RawTarget: link.Target, Text: link.Text, Reason: reason, LineStart: link.LineStart, LineEnd: link.LineEnd}
}