// Copyright 2023 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 (
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"

	"go.chromium.org/build/siso/build"
	"go.chromium.org/build/siso/build/buildconfig"
	"go.chromium.org/build/siso/execute"
	"go.chromium.org/build/siso/hashfs"
)

// TestLoad_RejectsNestedDirOutput verifies Load errors when one step produces
// an output nested under another step's directory output. Only a single
// producer of a whole directory tree is supported, not a merge of producers.
func TestLoad_RejectsNestedDirOutput(t *testing.T) {
	for _, tc := range []struct {
		name     string
		manifest string
	}{
		{
			name: "different_producer",
			// gendir produces the whole tree gen/; gen produces a file nested
			// inside it. A second producer inside the tree is unsupported.
			manifest: `
rule gendir
  command = mkdir -p ${out}
rule gen
  command = touch ${out}

build gen/: gendir
build gen/foo.txt: gen
build all: phony gen/ gen/foo.txt
`,
		},
		{
			name: "same_producer",
			// One step declares gen/ and a file inside it. The directory output
			// already owns its whole tree, so a member must not be declared as
			// its own output even by the same step.
			manifest: `
rule gendir
  command = mkdir -p ${out} && touch ${out}foo.txt

build gen/ gen/foo.txt: gendir
build all: phony gen/
`,
		},
		{
			name: "nested_dir_output",
			// a nested directory output is itself an output inside gen/.
			manifest: `
rule gendir
  command = mkdir -p ${out}

build gen/: gendir
build gen/sub/: gendir
build all: phony gen/ gen/sub/
`,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			ctx := t.Context()
			dir := t.TempDir()
			if err := os.MkdirAll(filepath.Join(dir, "out/siso"), 0755); err != nil {
				t.Fatal(err)
			}
			t.Chdir(filepath.Join(dir, "out/siso"))
			path := build.NewPath(dir, "out/siso")
			if err := os.WriteFile(filepath.Join(dir, "out/siso/build.ninja"), []byte(tc.manifest), 0644); err != nil {
				t.Fatal(err)
			}
			_, err := Load(ctx, "build.ninja", path)
			if err == nil {
				t.Fatal("Load succeeded; want error for an output nested under a directory output")
			}
			if !strings.Contains(err.Error(), "directory output") {
				t.Errorf("Load error = %v; want it to flag the directory-output overlap", err)
			}
		})
	}
}

// TestLoad_AllowsValidDirOutputs verifies Load accepts the supported directory
// output shapes, so the nesting check is not over-broad: a consumer depends on
// the whole tree, and a sibling sharing a path prefix is not "inside" the tree.
func TestLoad_AllowsValidDirOutputs(t *testing.T) {
	for _, tc := range []struct {
		name     string
		manifest string
	}{
		{
			name: "consumer_depends_on_whole_tree",
			// gendir owns gen/; cp consumes the whole tree, not a member.
			manifest: `
rule gendir
  command = mkdir -p ${out}
rule cp
  command = cp -r ${in} ${out}

build gen/: gendir
build out.txt: cp gen/
build all: phony out.txt
`,
		},
		{
			name: "sibling_shares_prefix",
			// generated.txt shares the "gen" prefix but is not under gen/.
			manifest: `
rule gendir
  command = mkdir -p ${out}
rule gen
  command = touch ${out}

build gen/: gendir
build generated.txt: gen
build all: phony gen/ generated.txt
`,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			ctx := t.Context()
			dir := t.TempDir()
			if err := os.MkdirAll(filepath.Join(dir, "out/siso"), 0755); err != nil {
				t.Fatal(err)
			}
			t.Chdir(filepath.Join(dir, "out/siso"))
			path := build.NewPath(dir, "out/siso")
			if err := os.WriteFile(filepath.Join(dir, "out/siso/build.ninja"), []byte(tc.manifest), 0644); err != nil {
				t.Fatal(err)
			}
			if _, err := Load(ctx, "build.ninja", path); err != nil {
				t.Errorf("Load() = %v; want success for a valid directory-output graph", err)
			}
		})
	}
}

// TestOutermostPaths_Cleandead exercises execute.OutermostPaths on cleandead inputs: a directory output must carry its whole subtree as one unit.
func TestOutermostPaths_Cleandead(t *testing.T) {
	for _, tc := range []struct {
		name string
		in   []string
		want []string
	}{
		{
			name: "directory contents not judged individually",
			in:   []string{"gen/foo", "gen/foo/a.o", "gen/foo/sub/b.o"},
			want: []string{"gen/foo"},
		},
		{
			// A sibling file output sorts between the directory root and its
			// contents ('.' 0x2E < '/' 0x2F); the skip prefix must not reset
			// and expose gen/foo/a.o to individual judgement.
			name: "interleaved sibling does not expose dir contents",
			in:   []string{"gen/foo", "gen/foo.stamp", "gen/foo/a.o", "gen/foo/sub/b.o"},
			want: []string{"gen/foo", "gen/foo.stamp"},
		},
		{
			name: "independent siblings all judged",
			in:   []string{"a.o", "b.o", "c.o"},
			want: []string{"a.o", "b.o", "c.o"},
		},
		{
			name: "two directory outputs side by side",
			in:   []string{"gen/a", "gen/a/x", "gen/b", "gen/b/y"},
			want: []string{"gen/a", "gen/b"},
		},
		{
			name: "sibling with char above slash sorts after subtree",
			in:   []string{"gen/foo", "gen/foo/a.o", "gen/foo0"},
			want: []string{"gen/foo", "gen/foo0"},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			got := execute.OutermostPaths(tc.in)
			if diff := cmp.Diff(tc.want, got); diff != "" {
				t.Errorf("OutermostPaths(%v) (-want +got):\n%s", tc.in, diff)
			}
		})
	}
}

func TestTargets(t *testing.T) {
	ctx := t.Context()
	dir := t.TempDir()
	err := os.MkdirAll(filepath.Join(dir, "build/config/siso"), 0755)
	if err != nil {
		t.Fatal(err)
	}
	err = os.WriteFile(filepath.Join(dir, "build/config/siso/main.star"), []byte(`
load("@builtin//struct.star", "module")

def init(ctx):
  return module(
    "config",
    step_config = "{}",
    filegroups = {},
    handlers = {},
  )
`), 0644)
	if err != nil {
		t.Fatal(err)
	}
	config, err := buildconfig.New(ctx, "@config//main.star", map[string]string{}, map[string]fs.FS{
		"config":           os.DirFS(filepath.Join(dir, "build/config/siso")),
		"config_overrides": os.DirFS(filepath.Join(dir, ".siso_remote")),
	})
	if err != nil {
		t.Fatal(err)
	}
	err = os.MkdirAll(filepath.Join(dir, "out/siso"), 0755)
	if err != nil {
		t.Fatal(err)
	}
	t.Chdir(filepath.Join(dir, "out/siso"))
	path := build.NewPath(dir, "out/siso")
	hashFS, err := hashfs.New(ctx, hashfs.Option{})
	if err != nil {
		t.Fatal(err)
	}

	err = os.WriteFile(filepath.Join(dir, "out/siso/build.ninja"), []byte(`
rule cxx
  command =  ../../third_party/llvm-build/Release+Asserts/bin/clang++ -MMD -MF ${out}.d ${defines} ${include_dirs} ${cflags} ${cflags_cc} -c ${in} -o ${out}
  description = CXX ${out}
  depfile = ${out}.d
  deps = gcc

rule link
  command = "python3" "../../build/toolchain/gcc_link_wrapper.py" --output="${output_dir}/${target_output_name}${output_extension}" -- ../../third_party/llvm-build/Release+Asserts/bin/clang++ ${ldflags} -o "${output_dir}/${target_output_name}${output_extension}" -Wl,--start-group @"${output_dir}/${target_output_name}${output_extension}.rsp" ${solibs} -Wl,--end-group  ${libs} ${rlibs}
  description = LINK ${output_dir}/${target_output_name}${output_extension}
  rspfile = ${output_dir}/${target_output_name}${output_extension}.rsp
  rspfile_content = ${in}

build foo.o: cxx foo.cc
build bar.o: cxx bar.cc
build exe: link foo.o bar.o
build all: phony exe
`), 0644)
	if err != nil {
		t.Fatal(err)
	}
	depsLog, err := NewDepsLog(ctx, ".siso_deps")
	if err != nil {
		t.Fatal(err)
	}
	defer func() {
		err := depsLog.Close()
		if err != nil {
			t.Error(err)
		}
	}()
	stepConfig, err := NewStepConfig(ctx, config, path, "build.ninja", ".")
	if err != nil {
		t.Fatal(err)
	}

	nstate, err := Load(ctx, "build.ninja", path)
	if err != nil {
		t.Fatal(err)
	}

	g := NewGraph(ctx, "build.ninja", nstate, config, path, hashFS, stepConfig, depsLog)

	for _, tc := range []struct {
		args []string
		want []string
	}{
		{
			args: nil,
			want: []string{"out/siso/all"},
		},
		{
			args: []string{"all"},
			want: []string{"out/siso/all"},
		},
		{
			args: []string{"foo.o"},
			want: []string{"out/siso/foo.o"},
		},
		{
			args: []string{"foo.cc^"},
			want: []string{"out/siso/foo.o"},
		},
	} {
		targets, err := g.Targets(ctx, tc.args...)
		if err != nil {
			t.Errorf("g.Targets(ctx, %q)=%v, %v; want nil err", tc.args, targets, err)
			continue
		}
		var got []string
		for _, target := range targets {
			p, err := g.TargetPath(ctx, target)
			if err != nil {
				t.Errorf("g.TargetPath(%v)=%v, %v; want nil err", target, p, err)
			}
			got = append(got, p)
		}
		if diff := cmp.Diff(tc.want, got); diff != "" {
			t.Errorf("g.Targets(ctx, %q) diff -want +got:\n%s", tc.args, diff)
		}
	}
}

func TestNewStepConfig_RejectUnknownKeys(t *testing.T) {
	ctx := t.Context()
	dir := t.TempDir()
	err := os.MkdirAll(filepath.Join(dir, "build/config/siso"), 0755)
	if err != nil {
		t.Fatal(err)
	}
	err = os.WriteFile(filepath.Join(dir, "build/config/siso/main.star"), []byte(`
load("@builtin//struct.star", "module")

strict_config = True

def init(ctx):
  return module(
    "config",
    step_config = '{"unknown_key_test": "value"}',
    filegroups = {},
    handlers = {},
  )
`), 0644)
	if err != nil {
		t.Fatal(err)
	}
	config, err := buildconfig.New(ctx, "@config//main.star", map[string]string{}, map[string]fs.FS{
		"config":           os.DirFS(filepath.Join(dir, "build/config/siso")),
		"config_overrides": os.DirFS(filepath.Join(dir, ".siso_remote")),
	})
	if err != nil {
		t.Fatal(err)
	}
	err = os.MkdirAll(filepath.Join(dir, "out/siso"), 0755)
	if err != nil {
		t.Fatal(err)
	}
	t.Chdir(filepath.Join(dir, "out/siso"))
	path := build.NewPath(dir, "out/siso")

	err = os.WriteFile(filepath.Join(dir, "out/siso/build.ninja"), []byte(`
build all: phony
`), 0644)
	if err != nil {
		t.Fatal(err)
	}

	_, err = NewStepConfig(ctx, config, path, "build.ninja", ".")
	if err == nil {
		t.Error("NewStepConfig succeeded with unknown keys, but should have failed")
	}
}
