package cli

import (
	"bytes"
	"encoding/json"
	"errors"
	"io"
	"path/filepath"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)

func TestRunResultEncodesRequiredFieldsAndCounts(t *testing.T) {
	result, err := newRunResult(testResolvedPipeline(pipeline.DefaultOutputModule), testRunOutput(), "relative-output", "relative-debug")
	if err != nil {
		t.Fatal(err)
	}

	encoded, err := encodeRunResult(result)
	if err != nil {
		t.Fatal(err)
	}
	if encoded[len(encoded)-1] != '\n' || bytes.Count(encoded, []byte{'\n'}) != 1 {
		t.Fatalf("encoded result is not one newline-terminated object: %q", encoded)
	}

	var decoded map[string]any
	if err := json.Unmarshal(encoded, &decoded); err != nil {
		t.Fatal(err)
	}
	if got := decoded["schema_version"]; got != runResultSchemaVersion {
		t.Fatalf("schema_version = %q", got)
	}
	if got := decoded["run_id"]; got != "run-123" {
		t.Fatalf("run_id = %q", got)
	}
	if got := decoded["pipeline_id"]; got != "sample" {
		t.Fatalf("pipeline_id = %q", got)
	}
	if got := decoded["validation_status"]; got != "rejected" {
		t.Fatalf("validation_status = %q", got)
	}
	if got := decoded["index_file"]; got != "index.json" {
		t.Fatalf("index_file = %q", got)
	}
	if got := decoded["normalized_output_count"]; got != float64(2) {
		t.Fatalf("normalized_output_count = %v", got)
	}
	if got := decoded["rejected_output_count"]; got != float64(1) {
		t.Fatalf("rejected_output_count = %v", got)
	}
	if got := decoded["warning_group_count"]; got != float64(1) || decoded["warning_occurrence_count"] != float64(1) || decoded["diagnostic_group_count"] != float64(0) || decoded["diagnostic_occurrence_count"] != float64(0) || decoded["diagnostics_truncated"] != false {
		t.Fatalf("diagnostic counts = %#v", decoded)
	}
	if got := decoded["validation_summaries"]; got != nil {
		t.Fatalf("validation_summaries = %#v, want omitted when empty", got)
	}
	if got := decoded["output_directory"]; got != filepath.Join(mustWorkingDirectory(t), "relative-output") {
		t.Fatalf("output_directory = %q", got)
	}
	if got := decoded["debug_directory"]; got != filepath.Join(mustWorkingDirectory(t), "relative-debug") {
		t.Fatalf("debug_directory = %q", got)
	}
}

func TestRunResultRejectsInvalidRequiredValues(t *testing.T) {
	tests := []struct {
		name      string
		resolved  pipeline.ResolvedPipeline
		output    pipeline.RunOutput
		directory string
	}{
		{name: "blank run ID", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.RunID = " " }), directory: "output"},
		{name: "blank resolved pipeline ID", resolved: pipeline.ResolvedPipeline{Output: pipeline.ModuleBinding{Module: pipeline.DefaultOutputModule}}, output: testRunOutput(), directory: "output"},
		{name: "blank manifest pipeline ID", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.PipelineID = "" }), directory: "output"},
		{name: "mismatched pipeline IDs", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.PipelineID = "other" }), directory: "output"},
		{name: "blank validation status", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutputWithout(func(output *pipeline.RunOutput) { output.Manifest.ValidationStatus = " " }), directory: "output"},
		{name: "blank output directory", resolved: testResolvedPipeline(pipeline.DefaultOutputModule), output: testRunOutput(), directory: " "},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if _, err := newRunResult(tt.resolved, tt.output, tt.directory, ""); err == nil {
				t.Fatal("newRunResult() succeeded")
			}
		})
	}
}

func TestRunResultOmitsIndexFileForOtherOutputModules(t *testing.T) {
	result, err := newRunResult(testResolvedPipeline("test/output"), testRunOutputWithout(func(output *pipeline.RunOutput) {
		output.OutputFiles = nil
	}), "output", "")
	if err != nil {
		t.Fatal(err)
	}
	if result.IndexFile != "" {
		t.Fatalf("index_file = %q", result.IndexFile)
	}
	encoded, err := encodeRunResult(result)
	if err != nil {
		t.Fatal(err)
	}
	var decoded map[string]any
	if err := json.Unmarshal(encoded, &decoded); err != nil {
		t.Fatal(err)
	}
	if _, ok := decoded["index_file"]; ok {
		t.Fatalf("encoded non-JSON result contains index_file: %s", encoded)
	}
	if _, ok := decoded["debug_directory"]; ok {
		t.Fatalf("encoded result without debug capture contains debug_directory: %s", encoded)
	}
}

func TestRunResultProjectsOwnedValidationSummaries(t *testing.T) {
	output := testRunOutput()
	output.Manifest.ValidationSummaries = []artifacts.ValidationSummary{{Status: "incomplete", IncompleteValidators: []string{"validator"}, ProducerAttemptCount: 1, TerminalAction: "warn_continue"}}
	result, err := newRunResult(testResolvedPipeline(pipeline.DefaultOutputModule), output, "output", "")
	if err != nil {
		t.Fatal(err)
	}
	output.Manifest.ValidationSummaries[0].IncompleteValidators[0] = "caller mutation"
	if got := result.ValidationSummaries[0].IncompleteValidators; len(got) != 1 || got[0] != "validator" {
		t.Fatalf("result validation summaries = %#v", result.ValidationSummaries)
	}
	encoded, err := encodeRunResult(result)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Contains(encoded, []byte(`"validation_summaries":[{"status":"incomplete","incomplete_validators":["validator"],"producer_attempt_count":1,"terminal_action":"warn_continue"}]`)) {
		t.Fatalf("encoded result = %s", encoded)
	}
}

func TestRunResultRequiresOneProductionIndexFile(t *testing.T) {
	tests := []struct {
		name  string
		files []contracts.OutputFile
	}{
		{name: "missing", files: nil},
		{name: "duplicate", files: []contracts.OutputFile{{Name: "index.json"}, {Name: "index.json"}}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			output := testRunOutput()
			output.OutputFiles = tt.files
			if _, err := newRunResult(testResolvedPipeline(pipeline.DefaultOutputModule), output, "output", ""); err == nil {
				t.Fatal("newRunResult() succeeded")
			}
		})
	}
}

func TestWriteRunResultCompletesAndReportsWriterFailure(t *testing.T) {
	content := []byte("result\n")
	var target bytes.Buffer
	if err := writeRunResult(partialResultWriter{writer: &target, limit: 2}, content); err != nil {
		t.Fatal(err)
	}
	if got := target.String(); got != string(content) {
		t.Fatalf("written result = %q", got)
	}

	writerErr := errors.New("result writer failed")
	if err := writeRunResult(failingResultWriter{err: writerErr}, content); !errors.Is(err, writerErr) {
		t.Fatalf("writeRunResult() error = %v", err)
	}
	if err := writeRunResult(zeroResultWriter{}, content); !errors.Is(err, io.ErrShortWrite) {
		t.Fatalf("zero-progress error = %v", err)
	}
}

func testResolvedPipeline(outputModule string) pipeline.ResolvedPipeline {
	return pipeline.ResolvedPipeline{ID: "sample", Output: pipeline.ModuleBinding{Module: outputModule}}
}

func testRunOutput() pipeline.RunOutput {
	return pipeline.RunOutput{
		Manifest:         artifacts.RunManifest{RunID: "run-123", PipelineID: "sample", ValidationStatus: "rejected"},
		NormalizeOutputs: []contracts.SerializedOutput{{}, {}},
		Rejected:         []contracts.RejectedOutput{{}},
		Diagnostics: contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{{
			Disposition:     contracts.DiagnosticDispositionWarning,
			Category:        contracts.DiagnosticCategoryFallback,
			ReasonCode:      "fallback",
			Origin:          contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageNormalize, StepID: "step", LaneID: "lane", ModuleKey: "module"},
			OccurrenceCount: 1,
			Samples:         []contracts.DiagnosticSample{{Scope: "scope", Message: "message"}},
		}}},
		OutputFiles: []contracts.OutputFile{{Name: "index.json"}},
	}
}

func testRunOutputWithout(change func(*pipeline.RunOutput)) pipeline.RunOutput {
	output := testRunOutput()
	change(&output)
	return output
}

func mustWorkingDirectory(t *testing.T) string {
	t.Helper()
	workingDirectory, err := filepath.Abs(".")
	if err != nil {
		t.Fatal(err)
	}
	return workingDirectory
}

type partialResultWriter struct {
	writer io.Writer
	limit  int
}

func (w partialResultWriter) Write(content []byte) (int, error) {
	if len(content) > w.limit {
		content = content[:w.limit]
	}
	return w.writer.Write(content)
}

type failingResultWriter struct{ err error }

func (w failingResultWriter) Write([]byte) (int, error) { return 0, w.err }

type zeroResultWriter struct{}

func (zeroResultWriter) Write([]byte) (int, error) { return 0, nil }
