package mdiff_test

import (
	"bytes"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/creachadair/mds/mdiff"
	"github.com/creachadair/mds/mstr"
	"github.com/creachadair/mds/slice"
	gocmp "github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"

	_ "embed"
)

// Type satisfaction checks.
var (
	_ mdiff.FormatFunc = mdiff.Normal
	_ mdiff.FormatFunc = mdiff.Context
	_ mdiff.FormatFunc = mdiff.Unified
)

var (
	// These example input files were copied from
	//
	// https://www.gnu.org/software/diffutils/manual/html_node/Sample-diff-Input.html

	//go:embed testdata/lhs.txt
	lhs string

	//go:embed testdata/rhs.txt
	rhs string

	// The comparison files were generated by running the diff command on the
	// above input files. The timestamps and paths recorded in the output are
	// relative to the package directory.

	// Old-style diff output: diff testdata/lhs.txt testdata/rhs.txt
	//go:embed testdata/odiff.txt
	odiff string

	// Unified diff output (3 lines): diff -u testdata/lhs.txt testdata/rhs.txt
	//go:embed testdata/udiff.txt
	udiff string

	// Context diff output (3 lines): diff -c testdata/lhs.txt testdata/rhs.txt
	//go:embed testdata/cdiff.txt
	cdiff string

	// Git patch output: git diff -p.
	//
	// To reproduce this output:
	//    cp mdiff/testdata/{rhs,tmp}.txt
	//    cp mdiff/testdata/{lhs,rhs}.txt
	//    mv mdiff/testdata/{tmp,lhs}.txt
	//    git diff -p > mdiff/testdata/gdiff.txt
	//
	//go:embed testdata/gdiff.txt
	gdiff string

	lhsLines = mstr.Lines(lhs)
	rhsLines = mstr.Lines(rhs)
)

func TestDiff(t *testing.T) {
	t.Run("Empty", func(t *testing.T) {
		d := mdiff.New(nil, nil)
		if diff := gocmp.Diff(d, &mdiff.Diff{}, cmpopts.EquateEmpty()); diff != "" {
			t.Errorf("Diff of empty (-got, +want):\n%s", diff)
		}
	})

	t.Run("Equal", func(t *testing.T) {
		input := strings.Fields("no king rules forever my son")
		if diff := gocmp.Diff(mdiff.New(input, input), &mdiff.Diff{
			Left:  input,
			Right: input,
		}, cmpopts.EquateEmpty()); diff != "" {
			t.Errorf("Diff of equal (-got, +want):\n%s", diff)
		}
	})
}

func TestRegression(t *testing.T) {
	t.Run("#12", func(t *testing.T) {
		const contextWindow = 3

		// Produce a chunk with a longer overlap than the size of the context
		// window.  Without the fix, this will trigger a panic in unification.
		lhs := lines("X", "Y", "Z", "", "a", "b", "c")
		rhs := lines("X", "Y", "Z", "", "", "a", "", "b", "", "c", "")

		d := mdiff.New(lhs, rhs)
		t.Log("-- Before context")
		logChunks(t, d.Chunks)
		d.AddContext(contextWindow).Unify()
		t.Log("-- After unification")
		logChunks(t, d.Chunks)
	})
}

func TestNoAlias(t *testing.T) {
	// The documentation promises that adding context and unifying does not
	// disturb the original edit sequence.
	d := mdiff.New(lhsLines, rhsLines)

	before := fmt.Sprint(d.Edits)
	d.AddContext(6).Unify()
	if fmt.Sprint(d.Edits) != before {
		t.Errorf("Edits were altered:\n got %v,\nwant %s", d.Edits, before)
	}
}

func TestFormat(t *testing.T) {
	t.Run("Normal", func(t *testing.T) {
		d := mdiff.New(lhsLines, rhsLines)
		logDiff(t, d)

		var buf bytes.Buffer
		d.Format(&buf, mdiff.Normal, nil)
		if got := buf.String(); got != odiff {
			t.Errorf("Normal diff disagrees with testdata.\nGot:\n%s\n\nWant:\n%s", got, odiff)
		}
	})

	t.Run("Context", func(t *testing.T) {
		d := mdiff.New(lhsLines, rhsLines).AddContext(3).Unify()
		logDiff(t, d)

		// This is the timestamp recorded for the testdata file. If you edit the
		// file, you may need to update this.
		when := time.Date(2024, 3, 16, 18, 53, 15, 123450000, time.UTC)

		var buf bytes.Buffer
		d.Format(&buf, mdiff.Context, &mdiff.FileInfo{
			Left:       "testdata/lhs.txt",
			LeftTime:   when,
			Right:      "testdata/rhs.txt",
			RightTime:  when,
			TimeFormat: time.ANSIC,
		})
		if got := buf.String(); got != cdiff {
			t.Errorf("Context diff disagrees with testdata.\nGot:\n%s\n\nWant:\n%s", got, cdiff)
		}
	})

	t.Run("Unified", func(t *testing.T) {
		d := mdiff.New(lhsLines, rhsLines).AddContext(3).Unify()

		// This is the timestamp recorded for the testdata file.  If you edit the
		// file, you may need to update this.
		when := time.Date(2024, 3, 16, 17, 47, 40, 123450000, time.UTC)

		var buf bytes.Buffer
		d.Format(&buf, mdiff.Unified, &mdiff.FileInfo{
			Left:      "testdata/lhs.txt",
			LeftTime:  when,
			Right:     "testdata/rhs.txt",
			RightTime: when,
		})
		if got := buf.String(); got != udiff {
			t.Errorf("Unified diff disagrees with testdata.\nGot:\n%s\n\nWant:\n%s", got, udiff)
		}
	})

	t.Run("Unified/NoTime", func(t *testing.T) {
		d := mdiff.New(lhsLines, rhsLines).AddContext(3).Unify()

		var buf bytes.Buffer
		d.Format(&buf, mdiff.Unified, &mdiff.FileInfo{Left: "a/fuzzy", Right: "b/wuzzy"})
		lines := mstr.Lines(buf.String())
		if diff := gocmp.Diff(slice.Head(lines, 2), []string{
			"--- a/fuzzy",
			"+++ b/wuzzy",
		}); diff != "" {
			t.Errorf("Header (-got, +want):\n%s", diff)
		}
		t.Logf("Diff:\n%s\n...", strings.Join(slice.Head(lines, 5), "\n"))
	})

	t.Run("Empty/Normal", func(t *testing.T) {
		empty := mdiff.New(lhsLines, lhsLines)
		var buf bytes.Buffer
		empty.Format(&buf, mdiff.Normal, nil)
		if got := buf.String(); got != "" {
			t.Errorf("Format: got:\n%s\nwant empty", got)
		}
	})

	t.Run("Empty/Context", func(t *testing.T) {
		empty := mdiff.New(lhsLines, lhsLines).AddContext(3).Unify()
		var buf bytes.Buffer
		empty.Format(&buf, mdiff.Context, nil)
		if got := buf.String(); got != "" {
			t.Errorf("Format: got:\n%s\nwant empty", got)
		}
	})

	t.Run("Empty/Unified", func(t *testing.T) {
		empty := mdiff.New(lhsLines, lhsLines).AddContext(3).Unify()
		var buf bytes.Buffer
		empty.Format(&buf, mdiff.Unified, nil)
		if got := buf.String(); got != "" {
			t.Errorf("Format: got:\n%s\nwant empty", got)
		}
	})
}

func TestRead(t *testing.T) {
	t.Run("Normal", func(t *testing.T) {
		p, err := mdiff.Read(strings.NewReader(odiff))
		if err != nil {
			t.Fatalf("Read: unexpected error: %v", err)
		}
		logChunks(t, p.Chunks)

		// The output should round-trip
		var buf bytes.Buffer
		if err := p.Format(&buf, mdiff.Normal); err != nil {
			t.Errorf("Format: unexpected error: %v", err)
		}
		if got := buf.String(); got != odiff {
			t.Errorf("Read: got:\n%s\nwant:\n%s", got, odiff)
		}
	})

	t.Run("Unified", func(t *testing.T) {
		p, err := mdiff.ReadUnified(strings.NewReader(udiff))
		if err != nil {
			t.Fatalf("ReadUnified: unexpected error: %v", err)
		}
		if p.FileInfo == nil {
			t.Error("Missing file header")
		} else {
			t.Logf("Header: %v", p.FileInfo)
		}
		logChunks(t, p.Chunks)

		// The output should round-trip.
		var buf bytes.Buffer
		if err := p.Format(&buf, mdiff.Unified); err != nil {
			t.Errorf("Format: unexpected error: %v", err)
		}
		if got := buf.String(); got != udiff {
			t.Errorf("ReadUnified: got:\n%s\nwant:\n%s", got, udiff)
		}
	})

	t.Run("Git", func(t *testing.T) {
		// An input with no patch should report an error.
		t.Run("Empty", func(t *testing.T) {
			p, err := mdiff.ReadGitPatch(strings.NewReader("nonsense\n"))
			if err == nil || !strings.Contains(err.Error(), "no patches found") {
				t.Fatalf("ReadGitPatch: got %+v, %v; want 'no patches found'", p, err)
			}
		})

		t.Run("Full", func(t *testing.T) {
			u, err := mdiff.ReadUnified(strings.NewReader(udiff))
			if err != nil {
				t.Fatalf("ReadUnified: unexpected error: %v", err)
			}

			ps, err := mdiff.ReadGitPatch(strings.NewReader(gdiff))
			if err != nil {
				t.Fatalf("ReadGitPatch: unexpected error: %v", err)
			}
			if len(ps) == 0 {
				t.Fatal("Surprisingly, no patches were reported")
			}
			for i, p := range ps {
				t.Logf("-- Patch %d", i+1)
				if p.FileInfo == nil {
					t.Error("Missing file header")
				} else {
					t.Logf("   Header: %v", p.FileInfo)
				}
				logChunks(t, p.Chunks)
			}

			// Render the first patch back out, it should look like the unified
			// diff it came from (with the header shimmed).
			var buf bytes.Buffer
			if err := u.Format(&buf, mdiff.Unified); err != nil {
				t.Errorf("Format: unexpected error: %v", err)
			}
			if diff := gocmp.Diff(buf.String(), udiff); diff != "" {
				t.Errorf("Git patch output (-got, +want):\n%s", diff)
			}
		})
	})
}

func logDiff(t *testing.T, d *mdiff.Diff) {
	t.Helper()
	t.Logf("Input left: %d lines, right: %d lines; diff has %d edits",
		len(d.Left), len(d.Right), len(d.Edits))

	t.Log("Original edits:")
	for i, e := range d.Edits {
		t.Logf("%d: %v", i+1, e)
	}
	logChunks(t, d.Chunks)
}

func logChunks(t *testing.T, cs []*mdiff.Chunk) {
	t.Log("Chunks:")
	for i, c := range cs {
		t.Logf("%d: %d edits -%d,%d +%d,%d", i+1, len(c.Edits),
			c.LStart, c.LEnd-c.LStart, c.RStart, c.REnd-c.RStart)
		for j, e := range c.Edits {
			t.Logf(" E %d: %+v\n", j+1, e)
		}
	}
}

func lines(ss ...string) []string { return ss }
