package cli

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/buildinfo"
)

func TestCommandHelpSpellingsWriteUsageToStdout(t *testing.T) {
	tests := [][]string{nil, {"help"}, {"--help"}, {"-h"}}
	for _, args := range tests {
		name := "no arguments"
		if len(args) > 0 {
			name = args[0]
		}
		t.Run(name, func(t *testing.T) {
			var stdout, stderr bytes.Buffer
			code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t))
			if code != 0 || !strings.Contains(stdout.String(), "Usage:") || stderr.Len() != 0 {
				t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
			}
		})
	}
}

func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) {
	tests := []struct {
		name string
		args []string
		want string
	}{
		{name: "unknown command", args: []string{"unknown"}, want: "unknown command"},
		{name: "missing config subcommand", args: []string{"config"}, want: "config requires a subcommand"},
		{name: "unknown pipelines subcommand", args: []string{"pipelines", "unknown"}, want: "unknown pipelines subcommand"},
		{name: "malformed run flag", args: []string{"run", "demo", "--chunk_cache", "invalid"}, want: "not supported"},
		{name: "unknown flag", args: []string{"config", "validate", "--unknown"}, want: "flag provided but not defined"},
		{name: "version arguments", args: []string{"--version", "extra"}, want: "--version does not accept arguments"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var stdout, stderr bytes.Buffer
			code := RunWithOptions(tt.args, &stdout, &stderr, commandContractOptions(t))
			if code != 2 || !strings.Contains(stderr.String(), tt.want) || stdout.Len() != 0 {
				t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
			}
		})
	}
}

func TestCommandVersionOutput(t *testing.T) {
	previous := buildinfo.Override
	t.Cleanup(func() { buildinfo.Override = previous })

	tests := []struct {
		name       string
		override   string
		wantCode   int
		wantStdout string
		wantStderr string
	}{
		{name: "development", wantStdout: "notarius development\n"},
		{name: "release override", override: "v1.2.3", wantStdout: "notarius v1.2.3\n"},
		{name: "invalid override", override: "release", wantCode: 1, wantStderr: "notarius: build version override is not a stable release tag\n"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			buildinfo.Override = tt.override
			var stdout, stderr bytes.Buffer
			code := RunWithOptions([]string{"--version"}, &stdout, &stderr, Options{})
			if code != tt.wantCode || stdout.String() != tt.wantStdout || stderr.String() != tt.wantStderr {
				t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
			}
		})
	}
}

func TestConfigDiscoveryPrefersExplicitPathThenEnvironment(t *testing.T) {
	explicit := writeCommandConfig(t, "explicit", "alpha")
	environment := writeCommandConfig(t, "environment", "beta")
	lookup := func(name string) (string, bool) {
		if name == "NOTARIUS_CONFIG" {
			return environment, true
		}
		return "", false
	}

	var stdout, stderr bytes.Buffer
	code := RunWithOptions([]string{"pipelines", "list", "--config", explicit}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
	if code != 0 || stdout.String() != "alpha\nexplicit\n" || stderr.Len() != 0 {
		t.Fatalf("explicit config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
	if code != 0 || stdout.String() != "beta\nenvironment\n" || stderr.Len() != 0 {
		t.Fatalf("environment config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}
}

func TestConfigDiscoveryUsesCompiledDefaultOnlyWhenAvailable(t *testing.T) {
	info, statErr := os.Stat(defaultConfigPath)
	if statErr != nil && !os.IsNotExist(statErr) {
		t.Fatalf("stat compiled default config: %v", statErr)
	}
	if statErr == nil && !info.Mode().IsRegular() {
		t.Skipf("compiled default config has unexpected host state: %s", info.Mode())
	}

	path, err := discoverConfigPath("", commandContractOptions(t))
	if statErr == nil {
		if err != nil || path != defaultConfigPath {
			t.Fatalf("discoverConfigPath() = %q, %v; want compiled default", path, err)
		}
		return
	}
	if err == nil || !strings.Contains(err.Error(), "config file not found") {
		t.Fatalf("discoverConfigPath() error = %v, want documented not-found context", err)
	}
}

func TestConfigLoadingFailuresReturnOneWithPathContext(t *testing.T) {
	missing := filepath.Join(t.TempDir(), "missing.yml")
	var stdout, stderr bytes.Buffer
	code := RunWithOptions([]string{"config", "validate", "--config", missing}, &stdout, &stderr, commandContractOptions(t))
	if code != 1 || !strings.Contains(stderr.String(), missing) || stdout.Len() != 0 {
		t.Fatalf("missing config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	malformed := filepath.Join(t.TempDir(), "malformed.yml")
	if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
		t.Fatal(err)
	}
	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"config", "validate", "--config", malformed}, &stdout, &stderr, commandContractOptions(t))
	if code != 1 || !strings.Contains(stderr.String(), malformed) || !strings.Contains(stderr.String(), "parse config file") || stdout.Len() != 0 {
		t.Fatalf("malformed config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}
}

func TestConfigValidateResolvesPipelineAndChecksSelection(t *testing.T) {
	configPath := writeResolvableCommandConfig(t)
	options := commandContractOptions(t)

	var stdout, stderr bytes.Buffer
	code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells"}, &stdout, &stderr, options)
	if code != 0 || !strings.Contains(stdout.String(), "valid for pipeline \"demo\"") || stderr.Len() != 0 {
		t.Fatalf("valid resolution: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "missing"}, &stdout, &stderr, options)
	if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") {
		t.Fatalf("unknown pipeline: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "missing"}, &stdout, &stderr, options)
	if code != 1 || !strings.Contains(stderr.String(), "lane \"missing\"") {
		t.Fatalf("unknown lane: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "spells"}, &stdout, &stderr, options)
	if code != 2 || !strings.Contains(stderr.String(), "--only requires --pipeline") {
		t.Fatalf("missing pipeline for only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells,,other"}, &stdout, &stderr, options)
	if code != 2 || !strings.Contains(stderr.String(), "--only must contain") {
		t.Fatalf("malformed only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}
}

func TestConfigValidatePipelineDefaultProfileIsOffline(t *testing.T) {
	configPath := writeCommandConfigContent(t, `version: 4
pipelines:
  demo:
    llm_profile: dnd-extraction
    input: seriatim
    artifacts:
      spells:
        extract: dnd/spells
`)
	var stdout, stderr bytes.Buffer
	code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo"}, &stdout, &stderr, Options{})
	if code != 0 || !strings.Contains(stdout.String(), "valid for pipeline \"demo\"") || stderr.Len() != 0 {
		t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}
}

func TestPipelinesListSortsNormalizedIDsInTextAndJSON(t *testing.T) {
	configPath := writeCommandConfig(t, " zeta ", "alpha")
	options := commandContractOptions(t)
	var stdout, stderr bytes.Buffer
	code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, options)
	if code != 0 || stdout.String() != "alpha\nzeta\n" || stderr.Len() != 0 {
		t.Fatalf("text list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, options)
	var payload struct {
		Pipelines []string `json:"pipelines"`
	}
	if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
		t.Fatalf("JSON list = %q: %v", stdout.String(), err)
	}
	if code != 0 || len(payload.Pipelines) != 2 || payload.Pipelines[0] != "alpha" || payload.Pipelines[1] != "zeta" || stderr.Len() != 0 {
		t.Fatalf("JSON list: code=%d payload=%#v stderr=%q", code, payload, stderr.String())
	}
}

func TestRemovedStructuralFlagsAndRuntimeFailuresKeepExitClasses(t *testing.T) {
	configPath := writeResolvableCommandConfig(t)
	options := commandContractOptions(t)

	var stdout, stderr bytes.Buffer
	code := RunWithOptions([]string{"run", "demo", "--input", "missing-input", "--config", configPath, "--diagnostics-dir", t.TempDir()}, &stdout, &stderr, options)
	if code != 2 || !strings.Contains(stderr.String(), "flag provided but not defined") {
		t.Fatalf("removed flag: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}

	stdout.Reset()
	stderr.Reset()
	code = RunWithOptions([]string{"run", "missing", "--input", "missing-input", "--config", configPath, "--chunk_cache", "bypass"}, &stdout, &stderr, options)
	if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") || stdout.Len() != 0 {
		t.Fatalf("valid-runtime failure: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
	}
}

func commandContractOptions(t *testing.T) Options {
	return commandContractOptionsWithLookup(t, emptyLookup)
}

func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string, bool)) Options {
	t.Helper()
	components, err := newProductionComponents()
	if err != nil {
		t.Fatal(err)
	}
	return Options{
		Catalog:    catalogFromRegistries(components.registries),
		Registries: components.registries,
		LookupEnv:  lookup,
	}
}

func writeCommandConfig(t *testing.T, firstID, secondID string) string {
	t.Helper()
	content := fmt.Sprintf("version: 4\npipelines:\n  %q:\n    input: seriatim\n  %q:\n    input: seriatim\n", firstID, secondID)
	return writeCommandConfigContent(t, content)
}

func writeResolvableCommandConfig(t *testing.T) string {
	t.Helper()
	return writeCommandConfigContent(t, `version: 4
pipelines:
  demo:
    input: seriatim
    artifacts:
      spells:
        extract: dnd/spells
`)
}

func writeCommandConfigContent(t *testing.T, content string) string {
	t.Helper()
	path := filepath.Join(t.TempDir(), "config.yml")
	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	return path
}
