/*
 * ChatCLI - Command Line Interface for LLM interaction
 * Copyright (c) 2024 Edilson Freitas
 * License: Apache-2.0
 */
// Package diffcover computes patch (diff) coverage for Go projects from a
// `go test -coverprofile` output and a unified git diff. It exists because
// the previous quality-gate pipeline relied on a Python toolchain
// (gocover-cobertura -> diff-cover) whose silent failure modes were masking
// patch-coverage regressions. Keeping the implementation in Go means the
// gate is built, vetted, raced and tested by the same pipeline it gates.
package diffcover

import (
	"bufio"
	"fmt"
	"io"
	"strconv"
	"strings"
)

// CoverBlock is a single covered region inside a Go file as reported by
// `go test -coverprofile`. Lines outside any block are not statements and
// therefore not "executable" — comments, blanks, braces, type declarations
// without bodies. This is the same model `go tool cover` uses.
type CoverBlock struct {
	StartLine int
	EndLine   int
	NumStmts  int
	Count     int // 0 means uncovered; >0 means executed at least once
}

// Covers reports whether the block contains the given (1-based) line.
func (b CoverBlock) Covers(line int) bool {
	return line >= b.StartLine && line <= b.EndLine
}

// Profile is the parsed result of a Go coverage profile.
//
// Files are keyed by the path exactly as it appears in the profile, which
// is module-relative (e.g. `github.com/diillson/chatcli/cli/cli.go`). Use
// StripPrefixes to obtain a repo-relative path that matches `git diff`.
type Profile struct {
	Mode   string
	Blocks map[string][]CoverBlock
}

// ParseProfile reads a Go cover profile. The format is documented in
// `golang.org/x/tools/cover` but is simple enough to inline: a `mode:`
// header followed by `file:startLine.col,endLine.col numStmts count`
// records. We deliberately do not depend on x/tools to keep tools/qg's
// dependency graph empty.
//
// Profiles generated by `go test -coverpkg=./... ./...` contain DUPLICATE
// entries for the same source range because each test binary emits its
// own measurements and Go's tooling concatenates them. We merge in-flight
// taking the max count: covered>0 from any binary wins over an
// uncovered 0 from another. This matches the semantics `go tool cover`
// applies when computing %coverage from a merged profile.
func ParseProfile(r io.Reader) (*Profile, error) {
	p := &Profile{Blocks: map[string][]CoverBlock{}}
	// idx[file][block-key] -> position in p.Blocks[file]. Block-key is the
	// (startLine, endLine, numStmts) triple — the column part is not
	// significant for line-level coverage decisions.
	type key struct{ start, end, stmts int }
	idx := map[string]map[key]int{}

	sc := bufio.NewScanner(r)
	sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)

	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		if strings.HasPrefix(line, "mode:") {
			p.Mode = strings.TrimSpace(strings.TrimPrefix(line, "mode:"))
			continue
		}
		file, blk, err := parseProfileRecord(line)
		if err != nil {
			return nil, fmt.Errorf("diffcover: %w", err)
		}

		k := key{blk.StartLine, blk.EndLine, blk.NumStmts}
		fileIdx, ok := idx[file]
		if !ok {
			fileIdx = map[key]int{}
			idx[file] = fileIdx
		}
		if pos, dup := fileIdx[k]; dup {
			// Merge: max count wins so the block is considered covered
			// if any test binary executed it.
			if blk.Count > p.Blocks[file][pos].Count {
				p.Blocks[file][pos].Count = blk.Count
			}
			continue
		}
		fileIdx[k] = len(p.Blocks[file])
		p.Blocks[file] = append(p.Blocks[file], blk)
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("diffcover: read profile: %w", err)
	}
	if p.Mode == "" {
		return nil, fmt.Errorf("diffcover: profile missing mode header")
	}
	return p, nil
}

func parseProfileRecord(line string) (string, CoverBlock, error) {
	// "<file>:<sl>.<sc>,<el>.<ec> <numStmts> <count>"
	// The file portion can contain spaces in theory, but Go modules don't
	// allow that and our toolchain never produces them, so a SplitN by ":"
	// from the LEFT is correct.
	colon := strings.LastIndex(line, ":")
	if colon < 0 {
		return "", CoverBlock{}, fmt.Errorf("malformed record (no colon): %q", line)
	}
	file := line[:colon]
	rest := line[colon+1:]

	fields := strings.Fields(rest)
	if len(fields) != 3 {
		return "", CoverBlock{}, fmt.Errorf("malformed record (want 3 fields, got %d): %q", len(fields), line)
	}

	startLine, endLine, err := parseRange(fields[0])
	if err != nil {
		return "", CoverBlock{}, fmt.Errorf("record %q: %w", line, err)
	}
	numStmts, err := strconv.Atoi(fields[1])
	if err != nil {
		return "", CoverBlock{}, fmt.Errorf("record %q: parse numStmts: %w", line, err)
	}
	count, err := strconv.Atoi(fields[2])
	if err != nil {
		return "", CoverBlock{}, fmt.Errorf("record %q: parse count: %w", line, err)
	}
	return file, CoverBlock{StartLine: startLine, EndLine: endLine, NumStmts: numStmts, Count: count}, nil
}

// parseRange parses "<sl>.<sc>,<el>.<ec>" into (sl, el).
func parseRange(s string) (int, int, error) {
	comma := strings.IndexByte(s, ',')
	if comma < 0 {
		return 0, 0, fmt.Errorf("range missing comma: %q", s)
	}
	startPart, endPart := s[:comma], s[comma+1:]

	sl, err := parseLineCol(startPart)
	if err != nil {
		return 0, 0, fmt.Errorf("start: %w", err)
	}
	el, err := parseLineCol(endPart)
	if err != nil {
		return 0, 0, fmt.Errorf("end: %w", err)
	}
	return sl, el, nil
}

func parseLineCol(s string) (int, error) {
	dot := strings.IndexByte(s, '.')
	if dot < 0 {
		return 0, fmt.Errorf("missing dot: %q", s)
	}
	return strconv.Atoi(s[:dot])
}

// TotalCoverage returns the (covered, total, percent) triple computed
// directly from the profile, without invoking `go tool cover`. Source
// resolution is not needed: each block already carries its NumStmts, so
// we sum statements weighted by execution status.
//
// This matches the percentage `go tool cover -func | grep total:` would
// print when run from the right module root, but works on profiles that
// merge multiple modules — which `go tool cover -func` cannot resolve.
func (p *Profile) TotalCoverage() (covered, total int, percent float64) {
	for _, blocks := range p.Blocks {
		for _, b := range blocks {
			total += b.NumStmts
			if b.Count > 0 {
				covered += b.NumStmts
			}
		}
	}
	if total == 0 {
		return 0, 0, 0
	}
	return covered, total, 100 * float64(covered) / float64(total)
}

// StripPrefixes returns a copy of the profile with module prefixes removed
// from file paths so the result matches `git diff` output. Each prefix is
// stripped only if the path starts with it; the LONGEST matching prefix
// wins so a sub-module (`<root>/operator/...`) doesn't accidentally retain
// its parent module's prefix.
func (p *Profile) StripPrefixes(prefixes []string) *Profile {
	// Sort prefixes by length descending — longest match wins.
	sorted := append([]string(nil), prefixes...)
	for i := 0; i < len(sorted); i++ {
		for j := i + 1; j < len(sorted); j++ {
			if len(sorted[j]) > len(sorted[i]) {
				sorted[i], sorted[j] = sorted[j], sorted[i]
			}
		}
	}

	out := &Profile{Mode: p.Mode, Blocks: make(map[string][]CoverBlock, len(p.Blocks))}
	for file, blocks := range p.Blocks {
		stripped := file
		for _, pref := range sorted {
			pref = strings.TrimSuffix(pref, "/") + "/"
			if strings.HasPrefix(stripped, pref) {
				stripped = strings.TrimPrefix(stripped, pref)
				break
			}
		}
		out.Blocks[stripped] = blocks
	}
	return out
}
