package nix

import (
	"bytes"
	"context"
	"io"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"tangled.org/core/api/tangled"
	"tangled.org/core/spindle/config"
	"tangled.org/core/spindle/models"
	"tangled.org/core/workflow"
)

func TestInitWorkflow(t *testing.T) {
	e := &Engine{cfg: &config.Config{}}
	wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: "{}"}, tangled.Pipeline{})
	require.NoError(t, err)
	require.Len(t, wf.Steps, 1)
	assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())
	assert.Equal(t, models.StepKindSystem, wf.Steps[0].Kind())
}

func TestInitWorkflowCompiledProductionWorkflow(t *testing.T) {
	e := &Engine{cfg: &config.Config{}}
	raw := "engine: nix\nwhen:\n  - event: push\n    branch: [main]\nclone:\n  skip: false\n"
	compiled := (&workflow.Compiler{}).Compile([]workflow.Workflow{{
		Name:   ".tangled/workflows/ci.yml",
		Engine: "nix",
		Raw:    raw,
	}})
	require.Len(t, compiled.Workflows, 1)
	require.Equal(t, raw, compiled.Workflows[0].Raw)

	wf, err := e.InitWorkflow(*compiled.Workflows[0], tangled.Pipeline{})
	require.NoError(t, err)
	require.Len(t, wf.Steps, 1)
	assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())
}

func TestInitWorkflowRejectsCustomSteps(t *testing.T) {
	e := &Engine{cfg: &config.Config{}}
	_, err := e.InitWorkflow(tangled.Pipeline_Workflow{
		Name: "flake",
		Raw:  "steps:\n  - name: nope\n    command: echo nope\n",
	}, tangled.Pipeline{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "unknown field")
}

// a generic workflow document (engine/when/clone, no engine-specific keys)
// must initialize, only structural keys like `steps` are rejected
func TestInitWorkflowAcceptsGenericKeys(t *testing.T) {
	e := &Engine{cfg: &config.Config{}}
	raw := "engine: nix\nwhen:\n  - event: push\n    branch: [main]\nclone:\n  skip: false\n  depth: 1\n"
	wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake", Raw: raw}, tangled.Pipeline{})
	require.NoError(t, err)
	require.Len(t, wf.Steps, 1)
	assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())

	_, err = e.InitWorkflow(tangled.Pipeline_Workflow{
		Name: "flake",
		Raw:  raw + "steps:\n  - name: nope\n    command: echo nope\n",
	}, tangled.Pipeline{})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "unknown field")
}

// every eval/metadata invocation needs the flakes features flags because the
// sandbox HOME has no nix.conf
func TestEvalArgv(t *testing.T) {
	e := &Engine{cfg: &config.Config{}}
	exe, argv := e.evalArgv([]string{"flake", "metadata", "--json", "."})
	assert.Equal(t, containerPrlimit, exe)
	require.GreaterOrEqual(t, len(argv), 6)
	assert.True(t, strings.HasPrefix(argv[0], "--as="))
	assert.Equal(t, containerNix, argv[1])
	assert.Equal(t, "--extra-experimental-features", argv[2])
	assert.Equal(t, "nix-command flakes", argv[3])
	assert.Equal(t, []string{"flake", "metadata", "--json", "."}, argv[4:])
}

func TestNixStringLit(t *testing.T) {
	cases := map[string]string{
		"plain":             `"plain"`,
		"dots.and%percent":  `"dots.and%percent"`,
		`quote"injection`:   `"quote\"injection"`,
		`interp${pwn}`:      `"interp\${pwn}"`,
		`back\slash`:        `"back\\slash"`,
		`"; throw "escaped`: `"\"; throw \"escaped"`,
		"newline\ninside":   "\"newline\ninside\"", // literal newlines are legal in nix strings
	}
	for in, want := range cases {
		assert.Equal(t, want, nixStringLit(in), "input %q", in)
	}
}

// metacharacter attr names stay structured: the installable is only the
// trusted category.system parent, the apply lambda sees just the escaped
// name, display is for humans
func TestOutputCandidateMetachars(t *testing.T) {
	cand := outputCandidate{
		kind:     targetSystemOutput,
		category: "checks",
		system:   "x86_64-linux",
		name:     `unit.100%"cov${throw}`,
	}
	installable, apply, err := cand.drvTarget()
	require.NoError(t, err)
	assert.Equal(t, ".#checks.x86_64-linux", installable)
	assert.Contains(t, apply, nixStringLit(cand.name))
	assert.NotContains(t, apply, cand.name)
	assert.NotContains(t, installable, cand.name)
	assert.Equal(t, `.#checks.x86_64-linux.unit.100%"cov${throw}`, cand.display())

	_, _, err = outputCandidate{kind: targetKind(99), name: "x"}.drvTarget()
	require.Error(t, err)
}

// only validated system strings may reach an installable, anything else is
// refused before it becomes argv
func TestParentInstallable(t *testing.T) {
	installable, err := parentInstallable("packages", "x86_64-linux")
	require.NoError(t, err)
	assert.Equal(t, ".#packages.x86_64-linux", installable)

	installable, err = parentInstallable("nixosConfigurations", "")
	require.NoError(t, err)
	assert.Equal(t, ".#nixosConfigurations", installable)

	for _, system := range []string{
		`x86_64-linux"; throw "`,
		"x86_64-linux ${throw 1}",
		"x86_64-linux --option",
		"x86_64-linux/x",
		"x86_64 linux",
	} {
		_, err := parentInstallable("packages", system)
		require.Error(t, err, "system %q", system)
		assert.Contains(t, err.Error(), "unsafe system")
	}
}

// a flake that doesn't define a probed category is "absent", not broken;
// other evaluation failures must not be swallowed
func TestIsMissingAttrError(t *testing.T) {
	// verbatim nix 2.x output for `nix eval .#checks.x86_64-linux` on a flake
	// without checks
	assert.True(t, isMissingAttrError(
		"error: flake 'path:/workdir' does not provide attribute 'packages.x86_64-linux.checks.x86_64-linux', 'legacyPackages.x86_64-linux.checks.x86_64-linux' or 'checks.x86_64-linux'"))
	// older nix phrasing
	assert.True(t, isMissingAttrError("error: attribute 'homeConfigurations' missing"))

	assert.False(t, isMissingAttrError("error: syntax error, unexpected end of file"))
	assert.False(t, isMissingAttrError("error: flake output is not a derivation"))
	assert.False(t, isMissingAttrError(""))
}

func TestCheckOutputLimit(t *testing.T) {
	require.NoError(t, checkOutputLimit(0, 100))
	require.NoError(t, checkOutputLimit(100, 100))
	err := checkOutputLimit(101, 100)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "101")
	assert.Contains(t, err.Error(), "100")
}

func TestMaxOutputsDefault(t *testing.T) {
	assert.Equal(t, 100, (&Engine{cfg: &config.Config{}}).maxOutputs())
	assert.Equal(t, 3, (&Engine{cfg: &config.Config{NixPipelines: config.NixPipelines{MaxOutputs: 3}}}).maxOutputs())
}

func TestMaxLogWriterOverflow(t *testing.T) {
	var buf bytes.Buffer
	canceled := false
	mw := &maxLogWriter{limit: 10, cancel: func() { canceled = true }}
	w := &limitedStreamWriter{parent: mw, out: &buf}
	n, err := w.Write([]byte("0123456789"))
	require.NoError(t, err)
	require.Equal(t, 10, n)
	assert.False(t, mw.exceeded)

	n, err = w.Write([]byte("overflow"))
	require.Error(t, err)
	assert.Equal(t, 0, n)
	assert.Contains(t, err.Error(), "exceeded the 10 byte limit")
	assert.True(t, mw.exceeded)
	assert.True(t, canceled)
	assert.Equal(t, "0123456789", buf.String())

	_, err = w.Write([]byte("more"))
	require.Error(t, err)
	assert.Equal(t, "0123456789", buf.String())
}

func TestMaxLogWriterTruncatesPartialWrite(t *testing.T) {
	var buf bytes.Buffer
	mw := &maxLogWriter{limit: 5}
	w := &limitedStreamWriter{parent: mw, out: &buf}
	n, err := w.Write([]byte("0123456789"))
	require.Error(t, err)
	assert.Equal(t, 5, n)
	assert.Equal(t, "01234", buf.String())
	assert.True(t, mw.exceeded)
}

func TestAuthorizePathInput(t *testing.T) {
	workspace := t.TempDir()
	outside := t.TempDir()

	require.NoError(t, os.MkdirAll(filepath.Join(workspace, "sub", "dir"), 0o755))
	require.NoError(t, authorizePathInput("a", map[string]any{"path": "sub/dir"}, workspace))
	require.NoError(t, authorizePathInput("b", map[string]any{"path": "not/there/yet"}, workspace))
	require.NoError(t, authorizePathInput("c", map[string]any{"path": "."}, workspace))

	require.Error(t, authorizePathInput("d", map[string]any{"path": "/etc/passwd"}, workspace))
	require.Error(t, authorizePathInput("e", map[string]any{"path": "../outside"}, workspace))
	require.Error(t, authorizePathInput("f", map[string]any{"path": "sub/../../outside"}, workspace))

	require.Error(t, authorizePathInput("g", map[string]any{}, workspace))

	require.NoError(t, os.Symlink(filepath.Join(workspace, "sub"), filepath.Join(workspace, "inner-link")))
	require.NoError(t, authorizePathInput("h", map[string]any{"path": "inner-link/dir"}, workspace))

	// a lexically-inside path can still escape through a symlink
	require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "escape")))
	err := authorizePathInput("i", map[string]any{"path": "escape"}, workspace)
	require.Error(t, err)
	assert.Contains(t, err.Error(), "symlink")

	require.NoError(t, os.MkdirAll(filepath.Join(workspace, "deep"), 0o755))
	require.NoError(t, os.Symlink(outside, filepath.Join(workspace, "deep", "escape")))
	require.Error(t, authorizePathInput("j", map[string]any{"path": "deep/escape"}, workspace))
}

func TestInitWorkflowWithClone(t *testing.T) {
	e := &Engine{cfg: &config.Config{Server: config.Server{Dev: true}}}
	repoName := "my-repo"
	repoDid := "did:plc:repo"
	tpl := tangled.Pipeline{TriggerMetadata: &tangled.Pipeline_TriggerMetadata{
		Kind: string(workflow.TriggerKindPush),
		Push: &tangled.Pipeline_PushTriggerData{
			Ref:    "refs/heads/main",
			NewSha: "1234567890abcdef1234567890abcdef12345678",
		},
		Repo: &tangled.Pipeline_TriggerRepo{
			Knot: "example.com", Did: "did:plc:owner", Repo: &repoName, RepoDid: &repoDid,
		},
	}}
	wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tpl)
	require.NoError(t, err)
	require.Len(t, wf.Steps, 2)
	assert.IsType(t, models.CloneStep{}, wf.Steps[0])
	assert.Equal(t, "Evaluate flake outputs", wf.Steps[1].Name())
}

func TestBaseBwrapArgs(t *testing.T) {
	e := &Engine{
		nixBinPath:     "/nix/store/nix/bin/nix",
		bashBinPath:    "/nix/store/bash/bin/bash",
		gitBinPath:     "/nix/store/git/bin/git",
		prlimitBinPath: "/nix/store/prlimit/bin/prlimit",
	}
	args := e.baseBwrapArgs(addlFields{
		workspaceDir: "/tmp/host/workspace",
		homeDir:      "/tmp/host/home",
	})
	argString := strings.Join(args, " ")
	for _, expected := range []string{
		"--die-with-parent", "--new-session", "--unshare-all --share-net",
		"--ro-bind /nix/store /nix/store",
		"--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket",
		"--proc /proc", "--dev /dev", "--tmpfs /tmp",
		"--bind /tmp/host/workspace /workdir", "--bind /tmp/host/home /home",
		"--symlink /nix/store/nix/bin/nix /usr/bin/nix",
		"--symlink /nix/store/bash/bin/bash /usr/bin/bash",
		"--symlink /nix/store/bash/bin/bash /bin/sh",
		"--symlink /nix/store/git/bin/git /usr/bin/git",
		"--symlink /nix/store/prlimit/bin/prlimit /usr/bin/prlimit",
		"--chdir /workdir",
	} {
		assert.Contains(t, argString, expected)
	}
	assert.NotContains(t, argString, "nixpkgs#")
	assert.NotContains(t, argString, " shell ")
}

type mockWorkflowLogger struct {
	stdout strings.Builder
	stderr strings.Builder
}

func (m *mockWorkflowLogger) Close() error { return nil }
func (m *mockWorkflowLogger) DataWriter(_ int, stream string) io.Writer {
	if stream == "stderr" {
		return &m.stderr
	}
	return &m.stdout
}
func (m *mockWorkflowLogger) ControlWriter(_ int, _ models.Step, _ models.StepStatus) io.Writer {
	return io.Discard
}

func TestIntegrationRealFlakeBuilds(t *testing.T) {
	if os.Getenv("RUN_NIX_INTEGRATION_TEST") != "true" {
		t.Skip("set RUN_NIX_INTEGRATION_TEST=true to run")
	}

	cfg := &config.Config{NixPipelines: config.NixPipelines{WorkDirBase: t.TempDir()}}
	e, err := New(cfg)
	require.NoError(t, err)
	wf, err := e.InitWorkflow(tangled.Pipeline_Workflow{Name: "flake"}, tangled.Pipeline{})
	require.NoError(t, err)
	wid := models.WorkflowId{PipelineId: models.PipelineId{Knot: "test-knot", Rkey: "test-rkey"}, Name: "flake"}
	logger := &mockWorkflowLogger{}
	require.NoError(t, e.SetupWorkflow(context.Background(), wid, wf, logger))
	defer e.DestroyWorkflow(context.Background(), wid)

	addl := wf.Data.(addlFields)
	flake := `{
  inputs.nixpkgs.url = "nixpkgs";
  outputs = { nixpkgs, ... }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      packages.${system}.hello = pkgs.runCommand "spindle-package" {} "echo package > $out";
      checks.${system}."unit.100%cov" = pkgs.runCommand "spindle-check" {} "echo check > $out";
      formatter.${system} = pkgs.hello;
      devShells.${system}.ci = pkgs.mkShell {};
    };
}
`
	require.NoError(t, os.WriteFile(filepath.Join(addl.workspaceDir, "flake.nix"), []byte(flake), 0o600))

	// the loop bound is evaluated per iteration, so build steps appended by
	// the discovery step run in the same pass
	for idx := 0; idx < len(wf.Steps); idx++ {
		require.NoError(t, e.RunStep(context.Background(), wid, wf, idx, nil, logger), logger.stderr.String())
	}

	require.GreaterOrEqual(t, len(wf.Steps), 5, "discovery + packages/checks/formatter/devShells builds")
	assert.Equal(t, "Evaluate flake outputs", wf.Steps[0].Name())
	var names []string
	for _, step := range wf.Steps[1:] {
		s, ok := step.(Step)
		require.True(t, ok, "generated step %q is a nix engine Step", step.Name())
		require.NotEmpty(t, s.drvPath, s.Name())
		assert.Contains(t, s.Command(), "^*", s.Name())
		names = append(names, s.Name())
	}
	for _, fragment := range []string{"packages", "checks", "formatter", "devShells", "unit.100%cov"} {
		assert.Condition(t, func() bool { return containsName(names, fragment) }, fragment)
	}

	require.NoError(t, e.DestroyWorkflow(context.Background(), wid))
	assert.NoDirExists(t, addl.workspaceDir)
	assert.NoDirExists(t, addl.homeDir)
	assert.NoError(t, e.DestroyWorkflow(context.Background(), wid))
}

func containsName(names []string, fragment string) bool {
	for _, name := range names {
		if strings.Contains(name, fragment) {
			return true
		}
	}
	return false
}
