// Copyright 2025 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
//  1. Redistributions of source code must retain the above copyright notice, this
//     list of conditions and the following disclaimer.
//
//  2. Redistributions in binary form must reproduce the above copyright notice,
//     this list of conditions and the following disclaimer in the documentation
//     and/or other materials provided with the distribution.
//
//  3. Neither the name of the copyright holder nor the names of its
//     contributors may be used to endorse or promote products derived from
//     this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

package expectationcoverage

import (
	"context"
	"flag"
	"fmt"
	"io"
	"os"
	"runtime"
	"slices"
	"sort"
	"strings"
	"sync"
	"time"

	"dawn.googlesource.com/dawn/tools/src/cmd/cts/common"
	"dawn.googlesource.com/dawn/tools/src/cts/expectations"
	"dawn.googlesource.com/dawn/tools/src/cts/result"
	"dawn.googlesource.com/dawn/tools/src/oswrapper"
)

const maxResultsPerWorker = 200000

func init() {
	common.Register(&cmd{})
}

type cmd struct {
	flags struct {
		maxOutput               int
		checkCompatExpectations bool
		individualExpectations  bool
		ignoreSkipExpectations  bool
		cacheDir                string
		verbose                 bool
	}
}

type ChunkWithCounter struct {
	Chunk *expectations.Chunk
	Count int
}

func (c *cmd) Name() string {
	return "expectation-coverage"
}

func (c *cmd) Desc() string {
	return "checks how much test coverage is lost due to expectations"
}

func (c *cmd) RegisterFlags(ctx context.Context, cfg common.Config) ([]string, error) {
	flag.IntVar(
		&c.flags.maxOutput,
		"max-output",
		25,
		"limit output to the top X expectation groups, set to 0 for unlimited")
	flag.BoolVar(
		&c.flags.checkCompatExpectations,
		"check-compat-expectations",
		false,
		"check compat expectations instead of regular expectations")
	flag.BoolVar(
		&c.flags.individualExpectations,
		"check-individual-expectations",
		false,
		"check individual expectations instead of groups")
	flag.BoolVar(
		&c.flags.ignoreSkipExpectations,
		"ignore-skip-expectations",
		false,
		"do not check the impact of Skip expectations")
	flag.StringVar(
		&c.flags.cacheDir,
		"cache",
		common.DefaultCacheDir,
		"path to the results cache")
	flag.BoolVar(
		&c.flags.verbose,
		"verbose",
		false,
		"emit additional logging")
	return nil, nil
}

func (c *cmd) Run(ctx context.Context, cfg common.Config) error {
	individualExpectations := c.flags.individualExpectations

	// Parse expectation file
	fmt.Println("Getting trimmed expectation file content")
	startTime := time.Now()
	var expectationPath string
	if c.flags.checkCompatExpectations {
		expectationPath = common.DefaultCompatExpectationsPath(cfg.OsWrapper)
	} else {
		expectationPath = common.DefaultExpectationsPath(cfg.OsWrapper)
	}
	content, err := getTrimmedContent(expectationPath,
		individualExpectations,
		c.flags.ignoreSkipExpectations,
		c.flags.verbose,
		cfg.OsWrapper)
	if err != nil {
		return err
	}
	if c.flags.verbose {
		fmt.Printf("Took %s\n", time.Since(startTime).String())
		fmt.Printf("Got %d chunks/individual expectations\n", len(content.Chunks))
	}

	// Get ResultDB data
	fmt.Println("Getting results")
	startTime = time.Now()
	var uniqueResults result.List
	if c.flags.checkCompatExpectations {
		uniqueResults, err = common.CacheRecentUniqueSuppressedCompatResults(
			ctx, cfg, c.flags.cacheDir, cfg.OsWrapper)
	} else {
		uniqueResults, err = common.CacheRecentUniqueSuppressedCoreResults(
			ctx, cfg, c.flags.cacheDir, cfg.OsWrapper)
	}
	if err != nil {
		return err
	}
	if c.flags.verbose {
		fmt.Printf("Took %s\n", time.Since(startTime).String())
		fmt.Printf("Got %d unique results\n", len(uniqueResults))
	}

	// Process ResultDB data
	fmt.Println("Processing results")
	startTime = time.Now()
	orderedChunks := getChunksOrderedByCoverageLoss(&content, &uniqueResults)
	if c.flags.verbose {
		fmt.Printf("Took %s\n", time.Since(startTime).String())
	}

	// Output results.
	outputResults(orderedChunks, c.flags.maxOutput, individualExpectations, os.Stdout)

	return nil
}

// getTrimmedContent returns a Content with certain Chunks removed or modified
// based on the provided arguments.
func getTrimmedContent(
	expectationPath string,
	individualExpectations bool,
	ignoreSkipExpectations bool,
	verbose bool,
	fsReader oswrapper.FilesystemReader) (expectations.Content, error) {
	rawFileContentBytes, err := fsReader.ReadFile(expectationPath)
	if err != nil {
		return expectations.Content{}, err
	}
	rawFileContent := string(rawFileContentBytes[:])

	content, err := expectations.Parse(expectationPath, rawFileContent)
	if err != nil {
		return expectations.Content{}, err
	}

	// Remove any permanent Skip expectations since they are never expected to
	// be removed from the file. Also remove any pure comment chunks.
	permanentSkipContent, err := getPermanentSkipContent(expectationPath, rawFileContent)
	if err != nil {
		return expectations.Content{}, err
	}

	// Get a copy of all relevant chunks.
	var trimmedChunks []expectations.Chunk
	for _, chunk := range content.Chunks {
		if chunk.IsCommentOnly() {
			continue
		}
		if chunk.ContainedWithinList(&permanentSkipContent.Chunks) {
			continue
		}

		var maybeSkiplessChunk expectations.Chunk
		if ignoreSkipExpectations {
			maybeSkiplessChunk = expectations.Chunk{
				Comments:     chunk.Comments,
				Expectations: expectations.Expectations{},
			}
			for _, e := range chunk.Expectations {
				if slices.Contains(e.Status, string(result.Skip)) {
					continue
				}
				maybeSkiplessChunk.Expectations = append(maybeSkiplessChunk.Expectations, e)
			}
		} else {
			maybeSkiplessChunk = chunk
		}

		if maybeSkiplessChunk.IsCommentOnly() {
			continue
		}
		trimmedChunks = append(trimmedChunks, maybeSkiplessChunk)
	}

	// Split chunks into individual expectations if requested.
	var maybeSplitChunks []expectations.Chunk
	if individualExpectations {
		for _, chunk := range trimmedChunks {
			for _, e := range chunk.Expectations {
				individualChunk := expectations.Chunk{
					Comments:     chunk.Comments,
					Expectations: expectations.Expectations{e},
				}
				maybeSplitChunks = append(maybeSplitChunks, individualChunk)
			}
		}
	} else {
		maybeSplitChunks = trimmedChunks
	}
	content.Chunks = maybeSplitChunks

	return content, nil
}

// getPermanentSkipContent returns a Content only containing Chunks for
// permanent Skip expectations.
func getPermanentSkipContent(
	expectationPath string,
	rawFileContent string) (expectations.Content, error) {
	// Since the standard format for expectation files is:
	//  - Permanent Skip expectations
	//  - Temporary Skip expectations
	//  - Triaged flakes/failure expectations
	//  - Untriaged auto-generated expectations
	// Assume we care about everything up to the temporary Skip expectation
	// section.
	targetLine := "# Temporary Skip Expectations"
	var keptLines []string
	brokeEarly := false
	for _, line := range strings.Split(rawFileContent, "\n") {
		if strings.HasPrefix(line, targetLine) {
			brokeEarly = true
			break
		}
		keptLines = append(keptLines, line)
	}

	if !brokeEarly {
		fmt.Println("Unable to find permanent Skip expectations, assuming none exist")
		return expectations.Content{}, nil
	}

	permanentSkipRawContent := strings.Join(keptLines, "\n")
	permanentSkipContent, err := expectations.Parse(expectationPath, permanentSkipRawContent)
	if err != nil {
		return expectations.Content{}, err
	}

	// Omit any pure comment chunks.
	var trimmedChunks []expectations.Chunk
	for _, chunk := range permanentSkipContent.Chunks {
		if !chunk.IsCommentOnly() {
			trimmedChunks = append(trimmedChunks, chunk)
		}
	}
	permanentSkipContent.Chunks = trimmedChunks

	return permanentSkipContent, nil
}

// math.Min only works on floats, and the built-in min is not available until
// go 1.21.
func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

// getChunksOrderedByCoverageLost returns the Chunks contained within 'content'
// ordered by how many results from 'uniqueResults' are affected by expectations
// within the Chunk.
//
// Under the hood, actual processing is farmed out to goroutines to better
// handle large amounts of results.
func getChunksOrderedByCoverageLoss(
	content *expectations.Content,
	uniqueResults *result.List) []ChunkWithCounter {

	affectedChunks := make([]ChunkWithCounter, len(content.Chunks))
	for i := range content.Chunks {
		affectedChunks[i].Chunk = &(content.Chunks[i])
	}

	// Create a goroutine pool. Each worker pulls a single ChunkWithCounter from
	// the queue at a time and handles all of the processing for it.
	numWorkers := minInt(len(affectedChunks), runtime.NumCPU())
	workQueue := make(chan *ChunkWithCounter)
	waitGroup := new(sync.WaitGroup)
	waitGroup.Add(numWorkers)
	for i := 0; i < numWorkers; i++ {
		go processChunk(workQueue, uniqueResults, waitGroup)
	}

	// Each of the ChunkWithCounter will have its Count filled in place by a
	// worker when picked up.
	for i := range affectedChunks {
		workQueue <- &(affectedChunks[i])
	}
	close(workQueue)
	waitGroup.Wait()

	// Sort based on the final tally.
	sortFunc := func(i, j int) bool {
		return affectedChunks[i].Count > affectedChunks[j].Count
	}
	sort.SliceStable(affectedChunks, sortFunc)

	return affectedChunks
}

// processChunk counts how many Results in 'uniqueResults' apply to Expectations
// in a provided ChunkWithCounter that is provided via 'workQueue'. The function
// will continue to pull work from 'workQueue' until it is closed and empty, at
// which point the function will exit and signal to 'waitGroup' that it is
// finished.
//
// Under the hood, actual processing is farmed out to additional goroutines to
// better handle large amounts of results.
func processChunk(
	workQueue chan *ChunkWithCounter,
	uniqueResults *result.List,
	waitGroup *sync.WaitGroup) {

	defer waitGroup.Done()

	// Create a pool of workers to handle processing of subsets of results. Each
	// worker handles every ith result and returns the number of those results
	// that applied to an expectation within the given ChunkWithCounter.
	numWorkers := int(len(*uniqueResults)/maxResultsPerWorker) + 1
	subWorkQueues := []chan *ChunkWithCounter{}
	subResultQueues := []chan int{}
	for i := 0; i < numWorkers; i++ {
		subWorkQueues = append(subWorkQueues, make(chan *ChunkWithCounter))
		subResultQueues = append(subResultQueues, make(chan int))
		go processChunkForResultSubset(
			subWorkQueues[i],
			subResultQueues[i],
			uniqueResults,
			i,
			numWorkers)
	}

	for {
		chunkWithCounter, queueOpen := <-workQueue
		if !queueOpen {
			for _, swq := range subWorkQueues {
				close(swq)
			}
			return
		}

		for i := 0; i < numWorkers; i++ {
			subWorkQueues[i] <- chunkWithCounter
		}
		for i := 0; i < numWorkers; i++ {
			chunkWithCounter.Count += <-subResultQueues[i]
		}
	}
}

// processChunkForResultSubset counts how many Results in 'uniqueResults' apply
// to Expectations in a provided ChunkWithCounter that is provided via
// 'workQueue'. Only every 'numWorkers' element of 'uniqueResults' is processed,
// starting at the 'workNumber' element. The count for each ChunkWithCounter is
// returned via 'resultQueue' in the same order that the work was provided.
//
// The function will continue to pull work from 'workQueue' until it is closed
// and empty.
func processChunkForResultSubset(
	workQueue chan *ChunkWithCounter,
	resultQueue chan int,
	uniqueResults *result.List,
	workerNumber, numWorkers int) {

	for {
		chunkWithCounter, queueOpen := <-workQueue
		if !queueOpen {
			return
		}

		numApplicableResults := 0
		for i := workerNumber; i < len(*uniqueResults); i += numWorkers {
			r := (*uniqueResults)[i]
			for _, expectation := range chunkWithCounter.Chunk.Expectations {
				if expectation.AppliesToResult(r) {
					numApplicableResults += 1
					break
				}
			}
		}

		resultQueue <- numApplicableResults
	}
}

func outputResults(
	orderedChunks []ChunkWithCounter,
	maxChunksToOutput int,
	individualExpectations bool,
	writer io.Writer) {

	var expectationPrefix, chunkType string
	if individualExpectations {
		chunkType = "individual expectation"
		expectationPrefix = "Expectation: "
	} else {
		chunkType = "chunk"
		expectationPrefix = "First expectation: "
	}

	if maxChunksToOutput == 0 {
		fmt.Fprintln(writer, "\nComplete output:")
	} else {
		fmt.Fprintf(
			writer,
			"\nTop %d %ss contributing to test coverage loss:\n",
			maxChunksToOutput,
			chunkType)
	}

	for i, chunkWithCounter := range orderedChunks {
		if maxChunksToOutput != 0 && i == maxChunksToOutput {
			break
		}

		chunk := chunkWithCounter.Chunk
		firstExpectation := chunk.Expectations[0]
		fmt.Fprintln(writer, "")
		fmt.Fprintf(writer, "Comment: %s\n", strings.Join(chunk.Comments, "\n"))
		fmt.Fprintf(writer, "%s%s\n", expectationPrefix, firstExpectation.AsExpectationFileString())
		fmt.Fprintf(writer, "Line number: %d\n", firstExpectation.Line)
		fmt.Fprintf(writer, "Affected %d test results\n", chunkWithCounter.Count)
	}
}
