// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package ninjabuild

import (
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"text/tabwriter"
	"time"

	"go.chromium.org/build/siso/build"
	"go.chromium.org/build/siso/build/buildconfig"
	"go.chromium.org/build/siso/hashfs"
	"go.chromium.org/build/siso/o11y/clog"
	"go.chromium.org/build/siso/o11y/trace"
	"go.chromium.org/build/siso/sync/semaphore"
	"go.chromium.org/build/siso/toolsupport/ninjautil"
	"go.chromium.org/build/siso/ui"
)

type RunNinjaOpts struct {
	// whether to perform cleandead or not.
	Cleandead bool

	// subtool name.
	// if "cleandead", it returns after cleandead performed.
	Subtool string

	// enable statusz (for `siso ps`)
	EnableStatusz bool
}

// BuildError indicates that the build failed during the execution of build
// steps. It wraps the underlying error to distinguish build execution failures
// from other error types.
type BuildError struct {
	Err error
}

func (b BuildError) Error() string {
	return b.Err.Error()
}

func (b BuildError) Unwrap() error {
	return b.Err
}

func initNinjaLogWriter(bopts *build.Options, outDir string) error {
	if bopts.NinjaLogWriter != nil {
		return nil
	}
	if outDir != "" {
		err := os.MkdirAll(outDir, 0755)
		if err != nil {
			return err
		}
	}
	ninjaLogWriter, err := ninjautil.InitializeNinjaLog(outDir)
	if err != nil {
		return err
	}
	bopts.NinjaLogWriter = ninjaLogWriter
	return nil
}

// CheckManifest quickly checks build.ninja is up-to-date by reading
// toplevel build.ninja file (without reading all build graph in subninja)
// and building build.ninja only.
// This optimizes for build.ninja generated by GN or so, which has build rule
// for build.ninja in main build.ninja file.
// Even if this assumption failed e.g. soong doesn't have such build rule,
// Run will rebuild manifest after reading all build.ninja files.
func CheckManifest(ctx context.Context, filename string, buildPath *build.Path, config *buildconfig.Config, hashFS *hashfs.HashFS, localDepsLog *DepsLog, bopts *build.Options) (bool, error) {
	defer trace.Begin(ctx, "ninjabuild.CheckManifest").End()
	started := time.Now()
	defer func() {
		ui.Default.PrintLines("")
		clog.Infof(ctx, "check build ninja in %s", time.Since(started))
	}()

	graph, err := loadCheckGraph(ctx, filename, buildPath, config, hashFS, localDepsLog, bopts, started)
	if err != nil {
		return false, err
	}
	if graph == nil {
		// will check later with full build ninja in Run.
		return false, nil
	}

	err = rebuildManifest(ctx, graph, *bopts)
	if errors.Is(err, build.ErrManifestModified) {
		clog.Warningf(ctx, "rebuild manifest %s: %v", filename, err)
		return true, nil
	}
	if errors.Is(err, build.ErrNoTarget) {
		// android soong doesn't have build target for ninja files.
		// ignore no target error.
		clog.Infof(ctx, "no target for %q", filename)
		return false, nil
	}
	if err != nil {
		// failed to build ninja files.
		// e.g. failed to run `gn gen` b/481012408
		clog.Warningf(ctx, "check build ninja: rebuild manifest %v", err)
		return false, err
	}
	return false, nil
}

// loadCheckGraph builds the Graph used by CheckManifest.
// Returns (nil, nil) when the manifest could not be parsed.
func loadCheckGraph(ctx context.Context, filename string, buildPath *build.Path, config *buildconfig.Config, hashFS *hashfs.HashFS, localDepsLog *DepsLog, bopts *build.Options, started time.Time) (*Graph, error) {
	nstate := ninjautil.NewState()
	p := ninjautil.NewManifestParser(nstate)
	if err := p.LoadSingle(ctx, filename); err != nil {
		clog.Warningf(ctx, "check build ninja: load %v", err)
		return nil, nil
	}
	clog.Infof(ctx, "check build ninja: load file in %s", time.Since(started))

	builddir := nstate.Binding("builddir")
	// We initialize ninja_log here instead of at the caller side (ninja.go) because
	// the location of the .ninja_log depends on the `builddir` binding from the manifest.
	// CheckManifest is the first place where the top-level manifest is parsed,
	// so it's the earliest and most accurate place to know the builddir and initialize the log.
	if err := initNinjaLogWriter(bopts, builddir); err != nil {
		return nil, err
	}
	clog.Infof(ctx, "check build ninja: initialize ninja log in %s", time.Since(started))

	// zero step config. no remote exec for gn gen?
	stepConfig := &StepConfig{}
	return NewGraph(ctx, filename, nstate, config, buildPath, hashFS, stepConfig, localDepsLog), nil
}

// Run runs a ninja build.
// It returns build statistics and an error if the build fails.
func Run(ctx context.Context, graph *Graph, bopts build.Options, targets []string, nopts RunNinjaOpts) (build.Stats, error) {
	defer trace.Begin(ctx, "ninjabuild.Run").End()
	err := graph.globals.hashFS.WaitReady(ctx)
	if err != nil {
		return build.Stats{}, err
	}
	spin := ui.Default.NewSpinner()

	builddir := graph.Binding("builddir")
	clog.Infof(ctx, "builddir=%q", builddir)
	err = initNinjaLogWriter(&bopts, builddir)
	if err != nil {
		return build.Stats{}, err
	}
	if closer, ok := bopts.NinjaLogWriter.(io.Closer); ok {
		defer func() {
			cerr := closer.Close()
			clog.Infof(ctx, "close .ninja_log: %v", cerr)
		}()
	}

	for {
		clog.Infof(ctx, "build starts")
		stats, err := doBuild(ctx, graph, bopts, nopts, targets...)
		if errors.Is(err, build.ErrManifestModified) {
			if bopts.DryRun {
				return stats, nil
			}
			clog.Infof(ctx, "build manifest was modified")
			spin.Start("reloading")
			err := graph.Reload(ctx)
			if err != nil {
				spin.Stop(err)
				return stats, err
			}
			spin.Stop(nil)
			clog.Infof(ctx, "reload done. build retry")
			continue
		}
		clog.Infof(ctx, "build finished: %v", err)
		if bopts.ResultstoreUploader != nil {
			if err != nil {
				bopts.ResultstoreUploader.AddBuildLog(fmt.Sprintf("build failed: %v\n", err))
			} else {
				bopts.ResultstoreUploader.AddBuildLog("build succeeded\n")
			}
		}
		return stats, err
	}
}

func rebuildManifest(ctx context.Context, graph *Graph, bopts build.Options) error {
	defer trace.Begin(ctx, "ninjabuild.rebuildManifest").End()
	_, err := graph.Targets(ctx, graph.Filename())
	if err != nil {
		clog.Warningf(ctx, "don't rebuild manifest: no target for %s: %v", graph.Filename(), err)
		return nil
	}
	clog.Infof(ctx, "rebuild manifest")
	mfbopts := bopts
	mfbopts.Clobber = false
	mfbopts.Prepare = false
	mfbopts.RebuildManifest = graph.Filename()
	mfb, err := build.New(ctx, graph, mfbopts)
	if err != nil {
		return err
	}

	err = mfb.Build(ctx, "rebuild manifest", graph.Filename())
	cerr := mfb.Close()
	if cerr != nil {
		return fmt.Errorf("failed to close builder: %w", cerr)
	}
	return err
}

func dumpResourceUsageTable(semaStats []semaphore.Stat) string {
	var lsb, usb strings.Builder
	var needToShow bool
	ltw := tabwriter.NewWriter(&lsb, 10, 8, 1, ' ', tabwriter.AlignRight)
	utw := tabwriter.NewWriter(&usb, 10, 8, 1, ' ', tabwriter.AlignRight)
	fmt.Fprintf(ltw, "resource/capa\tused(err)\twait-avg\t|   s m |\tserv-avg\t|   s m |\t\n")
	fmt.Fprintf(utw, "resource/capa\tused(err)\twait-avg\t|   s m |\tserv-avg\t|   s m |\t\n")
	for _, t := range semaStats {
		fmt.Fprintf(ltw, "%s\t%d(%d)\t%s\t%s\t%s\t%s\t\n", t.Name, t.N, t.NErr, t.WaitAvg().Round(time.Millisecond), histogram(t.WaitBuckets), t.ServAvg().Round(time.Millisecond), histogram(t.ServBuckets))
		// bucket 5 = [1m,10m)
		// bucket 6 = [10m,*)
		if t.WaitBuckets[5] > 0 || t.WaitBuckets[6] > 0 || t.ServBuckets[5] > 0 || t.ServBuckets[6] > 0 {
			needToShow = true
			fmt.Fprintf(utw, "%s\t%d(%d)\t%s\t%s\t%s\t%s\t\n", t.Name, t.N, t.NErr, ui.FormatDuration(t.WaitAvg()), histogram(t.WaitBuckets), ui.FormatDuration(t.ServAvg()), histogram(t.ServBuckets))
		}
	}
	ltw.Flush()
	utw.Flush()
	if needToShow {
		ui.Default.Printf("%s", usb.String())
	}
	return lsb.String()
}

var histchar = [...]string{"▂", "▃", "▄", "▅", "▆", "▇", "█"}

func histogram(b [7]int) string {
	max := 0
	for _, n := range b {
		if max < n {
			max = n
		}
	}
	var sb strings.Builder
	sb.WriteRune('|')
	for _, n := range b {
		if n <= 0 {
			sb.WriteRune(' ')
			continue
		}
		i := len(histchar) * n / (max + 1)
		sb.WriteString(histchar[i])
	}
	sb.WriteRune('|')
	return sb.String()
}

func doBuild(ctx context.Context, graph *Graph, bopts build.Options, nopts RunNinjaOpts, args ...string) (stats build.Stats, err error) {
	err = rebuildManifest(ctx, graph, bopts)
	if err != nil {
		return stats, err
	}
	stateDir := graph.StateDir()
	if bopts.ResultstoreUploader != nil {
		err := bopts.ResultstoreUploader.NewConfiguration(ctx, "default", graph.ConfigProperties())
		if err != nil {
			return stats, err
		}
		bopts.ResultstoreUploader.HashFS = bopts.HashFS
		bopts.ResultstoreUploader.REAPIClient = bopts.REAPIClient

		ents, err := bopts.HashFS.Entries(ctx, bopts.Path.AbsBase(), []string{filepath.Join(stateDir, ".siso_config"), filepath.Join(stateDir, ".siso_filegroups")})
		if err != nil {
			return stats, err
		}
		err = bopts.ResultstoreUploader.UploadFiles(ctx, ents)
		if err != nil {
			return stats, err
		}
	}

	if !bopts.DryRun && nopts.Cleandead {
		spin := ui.Default.NewSpinner()
		spin.Start("cleaning deadfiles")
		n, total, err := graph.CleanDead(ctx)
		if err != nil {
			spin.Stop(err)
			return stats, err
		}
		if nopts.Subtool == "cleandead" {
			spin.Done("%d/%d generated files", n, total)
			return stats, nil
		}
		spin.Stop(nil)
	}

	b, err := build.New(ctx, graph, bopts)
	if err != nil {
		return stats, err
	}
	hctx, cancel := context.WithCancel(ctx)
	defer cancel()
	if nopts.EnableStatusz {
		go func() {
			err := build.NewStatuszServer(hctx, b, stateDir)
			if err != nil {
				clog.Warningf(ctx, "statusz: %v", err)
			}
		}()
	}

	defer func(ctx context.Context) {
		cerr := b.Close()
		if cerr != nil {
			clog.Warningf(ctx, "failed to close builder: %v", cerr)
		}
	}(ctx)
	// prof := newCPUProfiler(ctx, "build")
	err = b.Build(ctx, "build", args...)
	// prof.stop(ctx)

	if err != nil {
		if errors.As(err, &build.MissingSourceError{}) {
			return stats, err
		}
		if errors.As(err, &build.DependencyCycleError{}) {
			return stats, err
		}
	}

	tstats := b.TraceStats()
	for _, ts := range tstats {
		clog.Infof(ctx, "%s: n=%d avg=%s max=%s", ts.Name, ts.N, ts.Avg(), ts.Max)
	}
	semaStats := b.SemaStats()
	if len(semaStats) > 0 {
		rut := dumpResourceUsageTable(semaStats)
		clog.Infof(ctx, "resource usage table:\n%s", rut)
		if bopts.ResultstoreUploader != nil {
			bopts.ResultstoreUploader.AddBuildLog(rut + "\n")
		}
	}
	stats = b.Stats()
	clog.Infof(ctx, "stats=%#v", stats)
	if err != nil {
		return stats, BuildError{Err: err}
	}
	if bopts.REAPIClient == nil {
		return stats, err
	}
	// TODO(b/266518906): wait for completion of uploading manifest
	return stats, err
}
