package index

import (
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
	"unicode"
)

const chunkSoftLimitBytes = 4 * 1024

func ChunkSource(source SourceRecord, parsed ParsedSource) []Chunk {
	return ChunkSourceWithOptions(source, parsed, ChunkOptions{})
}

func ChunkSourceWithOptions(source SourceRecord, parsed ParsedSource, options ChunkOptions) []Chunk {
	options = normalizeChunkOptions(options)
	body := source.Body
	if body == "" {
		return nil
	}
	blocks := chunkBlocksWithOptions(body, parsed, options)
	var chunks []Chunk
	for _, block := range blocks {
		if block.start >= block.end {
			continue
		}
		text := body[block.start:block.end]
		normalizedText := normalizeChunkText(text)
		if normalizedText == "" {
			continue
		}
		outbound, resolved := linksInRange(parsed.Links, parsed.StableIDs, block.start, block.end)
		chunk := Chunk{
			RepoID:            source.RepoID,
			SourceID:          source.ID,
			RecordID:          sourceRecordID(source),
			SnapshotID:        source.SnapshotID,
			ContentHash:       parsed.ContentHash,
			ByteStart:         block.start,
			ByteEnd:           block.end,
			LineStart:         lineForOffset(parsed.LineStarts, block.start),
			LineEnd:           lineForOffset(parsed.LineStarts, maxInt(block.start, block.end-1)),
			HeadingPath:       headingAtLine(parsed.Headings, lineForOffset(parsed.LineStarts, block.start)),
			Text:              text,
			NormalizedText:    normalizedText,
			InheritedMetadata: inheritedMetadata(source, parsed),
			OutboundLinks:     uniqueStrings(outbound),
			ResolvedAliases:   resolved,
			Policy:            options.Policy,
		}
		chunk.ID = chunkIDWithOptions(source.ID, parsed.ContentHash, block.start, source.SnapshotID, options)
		chunk.CitationAnchorID = anchorID(source.ID, parsed.ContentHash, block.start, "chunk")
		chunks = append(chunks, chunk)
	}
	return chunks
}

type chunkBlock struct {
	start int
	end   int
}

func normalizeChunkOptions(options ChunkOptions) ChunkOptions {
	if options.Policy == "" {
		options.Policy = ChunkPolicyHeading
	}
	if options.MaxBytes <= 0 {
		options.MaxBytes = chunkSoftLimitBytes
	}
	if options.WindowBytes <= 0 {
		options.WindowBytes = options.MaxBytes
	}
	if options.OverlapBytes < 0 {
		options.OverlapBytes = 0
	}
	if options.OverlapBytes >= options.WindowBytes {
		options.OverlapBytes = options.WindowBytes / 4
	}
	return options
}

func chunkBlocksWithOptions(body string, parsed ParsedSource, options ChunkOptions) []chunkBlock {
	if options.Policy == ChunkPolicySlidingWindow {
		return slidingWindowBlocks(body, parsed, options)
	}
	return headingBlocks(body, parsed, options.MaxBytes)
}

func chunkBlocks(body string, parsed ParsedSource) []chunkBlock {
	return headingBlocks(body, parsed, chunkSoftLimitBytes)
}

func headingBlocks(body string, parsed ParsedSource, maxBytes int) []chunkBlock {
	lines := sourceLines(body)
	startLine := 0
	if parsed.Frontmatter.Valid && parsed.FrontmatterEnd > 0 {
		startLine = lineIndexForOffset(lines, parsed.FrontmatterEnd)
	}
	var blocks []chunkBlock
	chunkStart := -1
	chunkEnd := -1
	inFence := false
	for i := startLine; i < len(lines); i++ {
		line := lines[i]
		if chunkStart < 0 {
			chunkStart = line.start
			chunkEnd = line.end
		}
		isHeading := atxHeadingLine(line.text)
		if isHeading && chunkStart >= 0 && line.start > chunkStart && !inFence {
			blocks = append(blocks, chunkBlock{start: chunkStart, end: chunkEnd})
			chunkStart = line.start
		}
		lineStartsFence := fenceLine(line.text)
		lineEndsFence := lineStartsFence && inFence
		if !inFence && line.start > chunkStart && line.end-chunkStart > maxBytes {
			blocks = append(blocks, splitOversizedBlock(lines, chunkStart, chunkEnd, maxBytes)...)
			chunkStart = line.start
		}
		chunkEnd = line.end
		if lineStartsFence {
			if lineEndsFence {
				inFence = false
			} else {
				inFence = true
			}
		}
	}
	if chunkStart >= 0 && chunkStart < chunkEnd {
		blocks = append(blocks, splitOversizedBlock(lines, chunkStart, chunkEnd, maxBytes)...)
	}
	return blocks
}

func splitOversizedBlock(lines []sourceLine, start, end, maxBytes int) []chunkBlock {
	if maxBytes <= 0 || end-start <= maxBytes {
		return []chunkBlock{{start: start, end: end}}
	}
	var blocks []chunkBlock
	blockStart := start
	blockEnd := start
	inFence := false
	lastParagraphEnd := -1
	for _, line := range lines {
		if line.end <= start || line.start >= end {
			continue
		}
		if fenceLine(line.text) {
			inFence = !inFence
		}
		if strings.TrimSpace(line.text) == "" && !inFence {
			lastParagraphEnd = line.end
		}
		if line.end-blockStart > maxBytes && line.start > blockStart {
			splitEnd := blockEnd
			if lastParagraphEnd > blockStart && lastParagraphEnd < line.end {
				splitEnd = lastParagraphEnd
			}
			blocks = append(blocks, chunkBlock{start: blockStart, end: splitEnd})
			blockStart = splitEnd
		}
		blockEnd = line.end
	}
	if blockStart < end {
		blocks = append(blocks, chunkBlock{start: blockStart, end: end})
	}
	return blocks
}

func slidingWindowBlocks(body string, parsed ParsedSource, options ChunkOptions) []chunkBlock {
	lines := sourceLines(body)
	startLine := 0
	if parsed.Frontmatter.Valid && parsed.FrontmatterEnd > 0 {
		startLine = lineIndexForOffset(lines, parsed.FrontmatterEnd)
	}
	var blocks []chunkBlock
	for i := startLine; i < len(lines); {
		start := lines[i].start
		end := lines[i].end
		j := i + 1
		for j < len(lines) && lines[j].end-start <= options.WindowBytes {
			end = lines[j].end
			j++
		}
		blocks = append(blocks, chunkBlock{start: start, end: end})
		if j >= len(lines) {
			break
		}
		nextStart := maxInt(start+1, end-options.OverlapBytes)
		next := j
		for next > startLine && lines[next-1].start >= nextStart {
			next--
		}
		if next <= i {
			next = i + 1
		}
		i = next
	}
	return blocks
}

type sourceLine struct {
	start int
	end   int
	text  string
}

func sourceLines(body string) []sourceLine {
	var lines []sourceLine
	start := 0
	for i := 0; i < len(body); i++ {
		if body[i] == '\n' {
			lines = append(lines, sourceLine{start: start, end: i + 1, text: strings.TrimSuffix(body[start:i+1], "\n")})
			start = i + 1
		}
	}
	if start < len(body) {
		lines = append(lines, sourceLine{start: start, end: len(body), text: body[start:]})
	}
	return lines
}

func lineIndexForOffset(lines []sourceLine, offset int) int {
	for i, line := range lines {
		if line.end > offset {
			return i
		}
	}
	return len(lines)
}

func atxHeadingLine(line string) bool {
	return headingRe.MatchString(strings.TrimSuffix(line, "\r"))
}

func fenceLine(line string) bool {
	trimmed := strings.TrimSpace(line)
	return strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~")
}

func inheritedMetadata(source SourceRecord, parsed ParsedSource) map[string]string {
	metadata := mergeMetadata(source.Metadata, parsed.Frontmatter.Values)
	if metadata == nil {
		metadata = map[string]string{}
	}
	metadata["source_id"] = source.ID
	metadata["kind"] = source.Kind
	metadata["path"] = source.Path
	if source.RepoID != "" {
		metadata["repo_id"] = source.RepoID
	}
	if recordID := sourceRecordID(source); recordID != "" {
		metadata["record_id"] = recordID
	}
	if source.SnapshotID != "" {
		metadata["snapshot_id"] = source.SnapshotID
	}
	if source.RemoteRevision != "" {
		metadata["remote_revision"] = source.RemoteRevision
	}
	if source.SyncRevision != "" {
		metadata["sync_revision"] = source.SyncRevision
	}
	if source.SyncEventID != "" {
		metadata["sync_event_id"] = source.SyncEventID
	}
	if !source.UpdatedAt.IsZero() {
		metadata["source_updated_at"] = source.UpdatedAt.UTC().Format(time.RFC3339Nano)
	}
	metadata["indexed_at"] = time.Now().UTC().Format(time.RFC3339Nano)
	return metadata
}

func sourceRecordID(source SourceRecord) string {
	if source.RecordID != "" {
		return source.RecordID
	}
	return source.ID
}

func linksInRange(links []Link, stableIDs []StableID, start, end int) ([]string, map[string]string) {
	var outbound []string
	resolved := map[string]string{}
	for _, link := range links {
		if link.ByteStart >= start && link.ByteStart < end {
			outbound = append(outbound, link.Target)
			resolved[link.Raw] = link.Target
		}
	}
	for _, id := range stableIDs {
		if id.ByteStart >= start && id.ByteStart < end {
			outbound = append(outbound, id.ID)
			resolved[id.ID] = id.ID
		}
	}
	return outbound, resolved
}

func normalizeChunkText(text string) string {
	text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
	var b strings.Builder
	inFence := false
	space := false
	for _, line := range strings.SplitAfter(text, "\n") {
		if fenceLine(strings.TrimSuffix(line, "\n")) {
			if space && b.Len() > 0 {
				b.WriteByte(' ')
				space = false
			}
			b.WriteString(lowerASCII(line))
			inFence = !inFence
			continue
		}
		if inFence {
			if space && b.Len() > 0 {
				b.WriteByte(' ')
				space = false
			}
			b.WriteString(lowerASCII(line))
			continue
		}
		for _, r := range line {
			if unicode.IsSpace(r) {
				space = true
				continue
			}
			if space && b.Len() > 0 {
				b.WriteByte(' ')
			}
			space = false
			if r >= 'A' && r <= 'Z' {
				r += 'a' - 'A'
			}
			b.WriteRune(r)
		}
	}
	return strings.TrimSpace(b.String())
}

func lowerASCII(text string) string {
	var b strings.Builder
	for _, r := range text {
		if r >= 'A' && r <= 'Z' {
			r += 'a' - 'A'
		}
		b.WriteRune(r)
	}
	return b.String()
}

func chunkID(sourceID, contentHash string, byteStart int) string {
	return chunkIDWithOptions(sourceID, contentHash, byteStart, "", ChunkOptions{})
}

func chunkIDWithOptions(sourceID, contentHash string, byteStart int, snapshotID string, options ChunkOptions) string {
	options = normalizeChunkOptions(options)
	sum := sha256.Sum256([]byte(strings.Join([]string{sourceID, contentHash, string(options.Policy), strconv.Itoa(byteStart), strconv.Itoa(options.MaxBytes), strconv.Itoa(options.WindowBytes), strconv.Itoa(options.OverlapBytes), snapshotID}, "\x00")))
	return hex.EncodeToString(sum[:])
}

func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}