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

package query

import (
	"flag"
	"os"
	"path/filepath"
	"sort"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/testing/protocmp"

	"go.chromium.org/build/siso/build"
	"go.chromium.org/build/siso/hashfs"
	fspb "go.chromium.org/build/siso/hashfs/proto"
	"go.chromium.org/build/siso/reapi/digest"
	pb "go.chromium.org/build/siso/toolsupport/ciderutil/proto"
)

func TestIDEAnalysis(t *testing.T) {
	ctx := t.Context()
	topDir := t.TempDir()
	t.Chdir(topDir)

	dir := filepath.Join(topDir, "out/siso")
	err := os.MkdirAll(dir, 0755)
	if err != nil {
		t.Fatal(err)
	}
	buildNinjaFilename := filepath.Join(dir, "build.ninja")
	setupBuildNinja(t, buildNinjaFilename)

	fsStateFilename := filepath.Join(dir, ".siso_fs_state")
	barH := `// generated file`
	barSummaryH := `// generated file (summary)`
	testPBH := `// Generated by the protocol buffer compiler.  DO NOT EDIT!`
	setupFileState(t, topDir, fsStateFilename, map[string]fileState{
		filepath.Join(topDir, "base/test/test.proto"): {
			content: "test.proto",
		},
		filepath.Join(topDir, "foo/foo.cc"): {
			content: `
#include "bar/bar.h"
#include "base/test/test.pb.h"
#include "foo.h"
`,
		},
		filepath.Join(topDir, "foo/foo.h"): {
			content: "// foo.h",
		},
		filepath.Join(topDir, "foo/baz.h"): {
			content: "// baz.h",
		},
		filepath.Join(topDir, "out/siso/build.ninja.d"): {
			content: `build.ninja.stamp: ../../.gn ../../BUILD.gn ../../foo/BUILD.gn ../../foo/foo.gni ../../chrome/VERSION gen/foo/foo.build_metadata
`,
		},
		filepath.Join(topDir, "out/siso/foo"): {
			content:   "foo binary",
			mtime:     3 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "out/siso/gen/bar/bar.h"): {
			content:   barH,
			mtime:     1 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "out/siso/gen/bar/bar-summary.h"): {
			content:   barSummaryH,
			mtime:     1 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "out/siso/gen/base/test/test.pb.h"): {
			content:   testPBH,
			mtime:     1 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "out/siso/obj/foo.o"): {
			content:   "foo obj",
			mtime:     2 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "out/siso/obj/foo.inputdeps.stamp"): {
			mtime:     1 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "out/siso/obj/protoc.o"): {
			content:   "protoc obj",
			mtime:     1 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "out/siso/protoc"): {
			content:   "protoc",
			mtime:     1 * time.Millisecond,
			generated: true,
		},
		filepath.Join(topDir, "protoc/protoc.cc"): {
			content: "// protoc.cc",
		},
		filepath.Join(topDir, "tools/protoc_wrapper/protoc_wrapper.py"): {
			content: "protoc_wrapper.py",
		},
	})

	t.Run("foo.cc", func(t *testing.T) {
		// This c.analysis call is equivalent with invocation of
		//  ./build/util/ide_query --out-dir out/siso --source foo/foo.cc

		c := &ideAnalysisCommand{}
		flagSet := flag.NewFlagSet("ideanalysis", flag.ContinueOnError)
		c.SetFlags(flagSet)
		t.Chdir(filepath.Join(topDir, "out/siso"))
		got, err := c.analyze(ctx, build.NewPath(topDir, "out/siso"), []string{"../../foo/foo.cc^"})
		if err != nil {
			t.Errorf(`analyze(ctx, "../../foo/foo.cc^")=%v, %v; want nil err`, got, err)
		}
		t.Logf("got=%s", got)

		want := &pb.IdeAnalysis{
			BuildOutDir: "out/siso",
			WorkingDir:  "out/siso",
			Results: []*pb.AnalysisResult{
				{
					SourceFilePath: "../../foo/foo.cc",
					Status: &pb.AnalysisResult_Status{
						Code: pb.AnalysisResult_Status_CODE_OK,
					},
					UnitId: "obj/foo.o",
					Invalidation: &pb.Invalidation{
						FilePaths: []string{
							"chrome/VERSION",
						},
						Wildcards: []*pb.Invalidation_Wildcard{
							{
								Suffix:         proto.String(".gn"),
								CanCrossFolder: proto.Bool(true),
							},
							{
								Suffix:         proto.String(".gni"),
								CanCrossFolder: proto.Bool(true),
							},
						},
					},
				},
			},
			Units: []*pb.BuildableUnit{
				{
					Id: "gen/bar/bar-summary.h",
					SourceFilePaths: []string{
						"../../bar/bar.in",
					},
					CompilerArguments: []string{
						"python3",
						"gen.py",
						"../../bar/bar.in",
						"gen/bar/bar-summary.h",
						"gen/bar/bar.h",
					},
					GeneratedFiles: []*pb.GeneratedFile{
						{
							Path:     "gen/bar/bar-summary.h",
							Contents: []byte(barSummaryH),
						},
						{
							Path:     "gen/bar/bar.h",
							Contents: []byte(barH),
						},
					},
				},
				{
					Id: "gen/base/test/test.pb.h",
					SourceFilePaths: []string{
						"../../tools/protoc_wrapper/protoc_wrapper.py",
						"../../base/test/test.proto",
					},
					CompilerArguments: []string{
						"python3",
						"../../tools/protoc_wrapper/protoc_wrapper.py",
						"test.proto",
						"--protoc",
						"./protoc",
						"--proto-in-dir",
						"../../base/test",
						"--cc-out-dir",
						"gen/base/test",
					},
					GeneratedFiles: []*pb.GeneratedFile{
						{
							Path:     "gen/base/test/test.pb.h",
							Contents: []byte(testPBH),
						},
					},
				},
				{
					Id:       "obj/foo.o",
					Language: pb.Language_LANGUAGE_CPP,
					SourceFilePaths: []string{
						"../../foo/foo.cc",
					},
					CompilerArguments: []string{
						"../../toolchain/clang++",
						"-MMD",
						"-MF",
						"obj/foo.o.d",
						"-Igen",
						"--sysroot=../../sysroot",
						"-c",
						"../../foo/foo.cc",
						"-o",
						"obj/foo.o",
					},
					DependencyIds: []string{
						"gen/bar/bar-summary.h",
						"gen/base/test/test.pb.h",
					},
				},
			},
		}
		if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
			t.Errorf(`analysis(ctx, "../../foo/foo.cc^") diff -want +got:\n%s`, diff)
		}
	})

	t.Run("foo.h", func(t *testing.T) {
		// This c.analysis call is equivalent with invocation of
		//  ./build/util/ide_query --out-dir out/siso --source foo/foo.h

		// pick foo.cc that includes foo.h

		c := &ideAnalysisCommand{}
		flagSet := flag.NewFlagSet("ideanalysis", flag.ContinueOnError)
		c.SetFlags(flagSet)
		t.Chdir(filepath.Join(topDir, "out/siso"))
		got, err := c.analyze(ctx, build.NewPath(topDir, "out/siso"), []string{"../../foo/foo.h^"})
		if err != nil {
			t.Errorf(`analyze(ctx, "../../foo/foo.h^")=%v, %v; want nil err`, got, err)
		}
		t.Logf("got=%s", got)

		want := &pb.IdeAnalysis{
			BuildOutDir: "out/siso",
			WorkingDir:  "out/siso",
			Results: []*pb.AnalysisResult{
				{
					SourceFilePath: "../../foo/foo.h",
					Status: &pb.AnalysisResult_Status{
						Code: pb.AnalysisResult_Status_CODE_OK,
					},
					UnitId: "obj/foo.o",
					Invalidation: &pb.Invalidation{
						FilePaths: []string{
							"chrome/VERSION",
						},
						Wildcards: []*pb.Invalidation_Wildcard{
							{
								Suffix:         proto.String(".gn"),
								CanCrossFolder: proto.Bool(true),
							},
							{
								Suffix:         proto.String(".gni"),
								CanCrossFolder: proto.Bool(true),
							},
						},
					},
				},
			},
			Units: []*pb.BuildableUnit{
				{
					Id: "gen/bar/bar-summary.h",
					SourceFilePaths: []string{
						"../../bar/bar.in",
					},
					CompilerArguments: []string{
						"python3",
						"gen.py",
						"../../bar/bar.in",
						"gen/bar/bar-summary.h",
						"gen/bar/bar.h",
					},
					GeneratedFiles: []*pb.GeneratedFile{
						{
							Path:     "gen/bar/bar-summary.h",
							Contents: []byte(barSummaryH),
						},
						{
							Path:     "gen/bar/bar.h",
							Contents: []byte(barH),
						},
					},
				},
				{
					Id: "gen/base/test/test.pb.h",
					SourceFilePaths: []string{
						"../../tools/protoc_wrapper/protoc_wrapper.py",
						"../../base/test/test.proto",
					},
					CompilerArguments: []string{
						"python3",
						"../../tools/protoc_wrapper/protoc_wrapper.py",
						"test.proto",
						"--protoc",
						"./protoc",
						"--proto-in-dir",
						"../../base/test",
						"--cc-out-dir",
						"gen/base/test",
					},
					GeneratedFiles: []*pb.GeneratedFile{
						{
							Path:     "gen/base/test/test.pb.h",
							Contents: []byte(testPBH),
						},
					},
				},
				{
					Id:       "obj/foo.o",
					Language: pb.Language_LANGUAGE_CPP,
					SourceFilePaths: []string{
						"../../foo/foo.cc",
					},
					CompilerArguments: []string{
						"../../toolchain/clang++",
						"-MMD",
						"-MF",
						"obj/foo.o.d",
						"-Igen",
						"--sysroot=../../sysroot",
						"-c",
						"../../foo/foo.cc",
						"-o",
						"obj/foo.o",
					},
					DependencyIds: []string{
						"gen/bar/bar-summary.h",
						"gen/base/test/test.pb.h",
					},
				},
			},
		}
		if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
			t.Errorf(`analysis(ctx, "../../foo/foo.h^") diff -want +got:\n%s`, diff)
		}
	})

	t.Run("baz.h", func(t *testing.T) {
		// This c.analysis call is equivalent with invocation of
		//  ./build/util/ide_query --out-dir out/siso --source foo/baz.h

		// pick foo.cc as fallback for baz.h

		c := &ideAnalysisCommand{}
		flagSet := flag.NewFlagSet("ideanalysis", flag.ContinueOnError)
		c.SetFlags(flagSet)
		t.Chdir(filepath.Join(topDir, "out/siso"))
		got, err := c.analyze(ctx, build.NewPath(topDir, "out/siso"), []string{"../../foo/baz.h^"})
		if err != nil {
			t.Errorf(`analyze(ctx, "../../foo/baz.h^")=%v, %v; want nil err`, got, err)
		}
		t.Logf("got=%s", got)

		want := &pb.IdeAnalysis{
			BuildOutDir: "out/siso",
			WorkingDir:  "out/siso",
			Results: []*pb.AnalysisResult{
				{
					SourceFilePath: "../../foo/baz.h",
					Status: &pb.AnalysisResult_Status{
						Code: pb.AnalysisResult_Status_CODE_OK,
					},
					UnitId: "obj/foo.o",
					Invalidation: &pb.Invalidation{
						FilePaths: []string{
							"chrome/VERSION",
						},
						Wildcards: []*pb.Invalidation_Wildcard{
							{
								Suffix:         proto.String(".gn"),
								CanCrossFolder: proto.Bool(true),
							},
							{
								Suffix:         proto.String(".gni"),
								CanCrossFolder: proto.Bool(true),
							},
						},
					},
				},
			},
			Units: []*pb.BuildableUnit{
				{
					Id: "gen/bar/bar-summary.h",
					SourceFilePaths: []string{
						"../../bar/bar.in",
					},
					CompilerArguments: []string{
						"python3",
						"gen.py",
						"../../bar/bar.in",
						"gen/bar/bar-summary.h",
						"gen/bar/bar.h",
					},
					GeneratedFiles: []*pb.GeneratedFile{
						{
							Path:     "gen/bar/bar-summary.h",
							Contents: []byte(barSummaryH),
						},
						{
							Path:     "gen/bar/bar.h",
							Contents: []byte(barH),
						},
					},
				},
				{
					Id: "gen/base/test/test.pb.h",
					SourceFilePaths: []string{
						"../../tools/protoc_wrapper/protoc_wrapper.py",
						"../../base/test/test.proto",
					},
					CompilerArguments: []string{
						"python3",
						"../../tools/protoc_wrapper/protoc_wrapper.py",
						"test.proto",
						"--protoc",
						"./protoc",
						"--proto-in-dir",
						"../../base/test",
						"--cc-out-dir",
						"gen/base/test",
					},
					GeneratedFiles: []*pb.GeneratedFile{
						{
							Path:     "gen/base/test/test.pb.h",
							Contents: []byte(testPBH),
						},
					},
				},
				{
					Id:       "obj/foo.o",
					Language: pb.Language_LANGUAGE_CPP,
					SourceFilePaths: []string{
						"../../foo/foo.cc",
					},
					CompilerArguments: []string{
						"../../toolchain/clang++",
						"-MMD",
						"-MF",
						"obj/foo.o.d",
						"-Igen",
						"--sysroot=../../sysroot",
						"-c",
						"../../foo/foo.cc",
						"-o",
						"obj/foo.o",
					},
					DependencyIds: []string{
						"gen/bar/bar-summary.h",
						"gen/base/test/test.pb.h",
					},
				},
			},
		}
		if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
			t.Errorf(`analysis(ctx, "../../foo/baz.h^") diff -want +got:\n%s`, diff)
		}
	})

	t.Run("bar.h", func(t *testing.T) {
		// This c.analysis call is equivalent with invocation of
		//  ./build/util/ide_query --out-dir out/siso --source foo/bar.h

		// fail since foo/bar.h doesn't exist

		c := &ideAnalysisCommand{}
		flagSet := flag.NewFlagSet("ideanalysis", flag.ContinueOnError)
		c.SetFlags(flagSet)
		t.Chdir(filepath.Join(topDir, "out/siso"))
		got, err := c.analyze(ctx, build.NewPath(topDir, "out/siso"), []string{"../../foo/bar.h^"})
		if err != nil {
			t.Errorf(`analyze(ctx, "../../foo/bar.h^")=%v, %v; want nil err`, got, err)
		}
		t.Logf("got=%s", got)

		want := &pb.IdeAnalysis{
			BuildOutDir: "out/siso",
			WorkingDir:  "out/siso",
			Results: []*pb.AnalysisResult{
				{
					SourceFilePath: "../../foo/bar.h",
					Status: &pb.AnalysisResult_Status{
						Code:          pb.AnalysisResult_Status_CODE_NOT_FOUND,
						StatusMessage: proto.String(`unknown target "../../foo/bar.h^"`),
					},
				},
			},
		}
		if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
			t.Errorf(`analysis(ctx, "../../foo/bar.h^") diff -want +got:\n%s`, diff)
		}
	})
}

func setupBuildNinja(t *testing.T, fname string) {
	t.Helper()
	err := os.WriteFile(fname, []byte(`
cflags = -Igen --sysroot=../../sysroot

rule stamp
  command = touch ${out}

rule gen
  command = python3 gen.py ${in} ${out}

rule proto_gen_test_proto
  command = python3 ../../tools/protoc_wrapper/protoc_wrapper.py test.proto --protoc ./protoc --proto-in-dir ../../base/test --cc-out-dir gen/base/test

rule cxx
  command = ../../toolchain/clang++ -MMD -MF ${out}.d ${cflags} -c ${in} -o ${out}
  dep = gcc
  depfile = ${out}.d
rule link
  command = clang++ @${rspfile} -o ${out}
  rspfile = ${out}.rsp
  rspfile_content = ${in}

build obj/protoc.o: cxx ../../protoc/protoc.cc
build protoc: link obj/protoc.o
build gen/bar/bar-summary.h gen/bar/bar.h: gen ../../bar/bar.in
build gen/base/test/test.pb.h: proto_gen_test_proto | ../../tools/protoc_wrapper/protoc_wrapper.py protoc ../../base/test/test.proto
build obj/foo.inputdeps.stamp: stamp gen/bar/bar.h gen/base/test/test.pb.h
build obj/foo.o: cxx ../../foo/foo.cc | obj/foo.inputdeps.stamp
build foo: link obj/foo.o

`), 0644)
	if err != nil {
		t.Fatal(err)
	}
}

type fileState struct {
	content   string
	mtime     time.Duration
	generated bool
}

func setupFileState(t *testing.T, topdir, fname string, files map[string]fileState) {
	t.Helper()
	ctx := t.Context()
	srcMtime := time.Now().Add(-10 * time.Second)
	cmdHash := []byte("someCommandHash")
	state := &fspb.State{
		Entries: []*fspb.Entry{
			{
				Id: &fspb.FileID{
					ModTime: srcMtime.UnixNano(),
				},
				Name: filepath.ToSlash(topdir),
			},
		},
	}
	seen := map[string]bool{
		topdir: true,
	}

	var mkdirAll func(dirname string, mtime time.Time)
	mkdirAll = func(dirname string, mtime time.Time) {
		err := os.MkdirAll(dirname, 0755)
		if err != nil {
			t.Fatal(err)
		}
		if seen[dirname] {
			return
		}
		seen[dirname] = true
		state.Entries = append(state.Entries, &fspb.Entry{
			Id: &fspb.FileID{
				ModTime: mtime.UnixNano(),
			},
			Name: filepath.ToSlash(filepath.Clean(dirname)),
		})
		mkdirAll(filepath.Dir(dirname), mtime)
	}
	writeFile := func(fname, content string, mtime time.Time, generated bool) {
		mkdirAll(filepath.Dir(fname), mtime)
		d := digest.FromBytes(fname, []byte(content))
		ent := &fspb.Entry{
			Id: &fspb.FileID{
				ModTime: mtime.UnixNano(),
			},
			Name: filepath.ToSlash(filepath.Clean(fname)),
			Digest: &fspb.Digest{
				Hash:      d.Digest().Hash,
				SizeBytes: d.Digest().SizeBytes,
			},
		}
		if generated {
			ent.CmdHash = cmdHash
			ent.UpdatedTime = mtime.UnixNano()
		}
		state.Entries = append(state.Entries, ent)
		err := os.WriteFile(fname, []byte(content), 0644)
		if err != nil {
			t.Fatal(err)
		}
	}
	for fname, fstate := range files {
		mtime := srcMtime.Add(fstate.mtime)
		writeFile(fname, fstate.content, mtime, fstate.generated)
	}
	sort.Slice(state.Entries, func(i, j int) bool {
		return state.Entries[i].Name < state.Entries[j].Name
	})
	opts := hashfs.Option{
		StateFile:     fname,
		CompressLevel: 1,
	}
	err := hashfs.Save(ctx, state, opts)
	if err != nil {
		t.Fatal(err)
	}
}
