package convert

import (
	"context"
	"errors"
	"strings"
	"sync"
	"testing"
	"time"
)

// fakeRunner records the args it was invoked with and replays canned
// responses. Lets us assert command lines + binary refs + scratch
// dirs without needing actual pandoc.
type fakeRunner struct {
	mu         sync.Mutex
	calls      [][]string
	binaries   []string
	stdin      [][]byte
	scratchDir []string
	resp       []byte
	err        error
}

func (f *fakeRunner) Run(_ context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls = append(f.calls, append([]string(nil), cmd...))
	f.binaries = append(f.binaries, binary)
	f.stdin = append(f.stdin, append([]byte(nil), stdin...))
	f.scratchDir = append(f.scratchDir, scratchDir)
	return f.resp, f.err
}

func (f *fakeRunner) lastCall() (string, []string) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if len(f.calls) == 0 {
		return "", nil
	}
	return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1]
}

func TestConvert_Directions(t *testing.T) {
	cases := []struct {
		from, to string
		wantArgs []string // substrings that must appear in the pandoc command
		wantErr  bool
	}{
		{"docx", "md", []string{"--from=docx", "--to=gfm", "--wrap=none"}, false},
		{"html", "md", []string{"--from=html", "--to=gfm", "--wrap=none"}, false},
		{"docx", "html", []string{"--from=docx", "--to=html5", "--embed-resources"}, false},
		{"html", "docx", []string{"--from=html", "--to=docx"}, false},
		{"md", "docx", []string{"--from=markdown+yaml_metadata_block", "--to=docx"}, false},
		{"md", "html", []string{"--from=markdown+yaml_metadata_block", "--to=html5"}, false},
		{"docx", "pdf", nil, true},  // pdf is markdown-only
		{"docx", "docx", nil, true}, // same-format is unsupported
		{"html", "html", nil, true},
	}
	for _, c := range cases {
		t.Run(c.from+"_to_"+c.to, func(t *testing.T) {
			f := &fakeRunner{resp: []byte("OUT")}
			InstallRunner(f)
			t.Cleanup(func() { InstallRunner(nil) })
			SetBinaries("pandoc", "chromium-browser")

			_, err := Convert(context.Background(), c.from, c.to, []byte("x"), Metadata{}, TemplateSet{})
			if c.wantErr {
				if err == nil {
					t.Fatalf("Convert(%s→%s): expected error, got nil", c.from, c.to)
				}
				return
			}
			if err != nil {
				t.Fatalf("Convert(%s→%s): %v", c.from, c.to, err)
			}
			binary, call := f.lastCall()
			if binary != "pandoc" {
				t.Errorf("expected pandoc, got %q", binary)
			}
			for _, want := range c.wantArgs {
				if !contains(call, want) {
					t.Errorf("Convert(%s→%s) missing %q in %v", c.from, c.to, want, call)
				}
			}
			// To-markdown directions inline images via the lua filter.
			if c.to == "md" {
				if !hasPrefArg(call, "--lua-filter=") || !hasSuffArg(call, "inline-media.lua") {
					t.Errorf("Convert(%s→md) missing inline-media.lua filter: %v", c.from, call)
				}
			}
		})
	}
}

// hasPrefArg / hasSuffArg report whether any arg has the given prefix/suffix.
func hasPrefArg(args []string, prefix string) bool {
	for _, a := range args {
		if strings.HasPrefix(a, prefix) {
			return true
		}
	}
	return false
}

func hasSuffArg(args []string, suffix string) bool {
	for _, a := range args {
		if strings.HasSuffix(a, suffix) {
			return true
		}
	}
	return false
}

func TestToDocx_UsesPandocBinary(t *testing.T) {
	f := &fakeRunner{resp: []byte("FAKE-DOCX")}
	InstallRunner(f)
	t.Cleanup(func() { InstallRunner(nil) })
	SetBinaries("pandoc", "chromium-browser")

	out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
		Title:  "Hello",
		Client: "Acme",
	})
	if err != nil {
		t.Fatalf("ToDocx: %v", err)
	}
	if string(out) != "FAKE-DOCX" {
		t.Errorf("unexpected output: %q", out)
	}
	binary, call := f.lastCall()
	if binary != "pandoc" {
		t.Errorf("expected pandoc binary, got %q", binary)
	}
	if !contains(call, "--to=docx") {
		t.Errorf("missing --to=docx: %v", call)
	}
	if !contains(call, "title=Hello") {
		t.Errorf("missing title metadata: %v", call)
	}
	if !contains(call, "client=Acme") {
		t.Errorf("missing client metadata: %v", call)
	}
	// Last arg must be "-" so pandoc reads from stdin.
	if call[len(call)-1] != "-" {
		t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
	}
	// ToDocx is stdin → stdout — no scratch dir needed.
	if f.scratchDir[len(f.scratchDir)-1] != "" {
		t.Errorf("ToDocx should not need a scratch dir, got %q", f.scratchDir[len(f.scratchDir)-1])
	}
}

func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) {
	f := &fakeRunner{resp: []byte("<html>fake</html>")}
	InstallRunner(f)
	t.Cleanup(func() { InstallRunner(nil) })
	SetBinaries("pandoc", "chromium-browser")

	_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}, TemplateSet{})
	if err != nil {
		t.Fatalf("ToHTML: %v", err)
	}
	binary, call := f.lastCall()
	if binary != "pandoc" {
		t.Errorf("expected pandoc binary, got %q", binary)
	}
	// Template flag must reference an absolute path under the scratch
	// dir (no /tpl indirection anymore — the wrapper bind-mounts the
	// scratch dir at its own path, so absolute host paths just work).
	scratch := f.scratchDir[len(f.scratchDir)-1]
	if scratch == "" {
		t.Fatalf("ToHTML must pass a scratch dir to the runner")
	}
	wantTpl := "--template=" + scratch + "/report.html"
	if !contains(call, wantTpl) {
		t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call)
	}
	if !contains(call, "--toc") {
		t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
	}
}

func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
	f := &fakeRunner{resp: []byte("<html/>")}
	InstallRunner(f)
	t.Cleanup(func() { InstallRunner(nil) })

	_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}, TemplateSet{})
	_, call := f.lastCall()
	if contains(call, "--toc") {
		t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
	}
	if !contains(call, "no-toc=true") {
		t.Errorf("no-toc metadata variable missing: %v", call)
	}
}

// recordingRunner records every call and returns canned responses in
// sequence. Lets ToPDF tests assert the two-stage pipeline (pandoc
// then chromium).
type recordingRunner struct {
	mu     sync.Mutex
	calls  []recordedCall
	resp   [][]byte
	err    []error
	cursor int
}

type recordedCall struct {
	binary  string
	cmd     []string
	scratch string
}

func (r *recordingRunner) Run(_ context.Context, binary string, _ []byte, scratch string, cmd []string) ([]byte, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.calls = append(r.calls, recordedCall{
		binary:  binary,
		cmd:     append([]string(nil), cmd...),
		scratch: scratch,
	})
	if r.cursor >= len(r.resp) {
		return nil, nil
	}
	out := r.resp[r.cursor]
	var e error
	if r.cursor < len(r.err) {
		e = r.err[r.cursor]
	}
	r.cursor++
	return out, e
}

func TestScratchDir_UsedByToHTML(t *testing.T) {
	f := &fakeRunner{resp: []byte("<html/>")}
	InstallRunner(f)
	t.Cleanup(func() { InstallRunner(nil); SetScratchDir("") })

	scratchRoot := t.TempDir()
	SetScratchDir(scratchRoot)

	_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
	if err != nil {
		t.Fatalf("ToHTML: %v", err)
	}
	if len(f.scratchDir) == 0 {
		t.Fatalf("expected a scratch dir to be passed to the runner")
	}
	got := f.scratchDir[0]
	if !strings.HasPrefix(got, scratchRoot+"/") {
		t.Errorf("scratch dir not under configured root: %q (root=%q)", got, scratchRoot)
	}
}

func TestToPDF_TwoStagePipeline(t *testing.T) {
	// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
	// the scratch dir and writes out.pdf there. The fake runner can't
	// actually write the PDF, so we expect ToPDF to fail at the
	// read-back step — but we can still assert the two-stage call
	// shape and the right binary per stage.
	r := &recordingRunner{
		resp: [][]byte{
			[]byte("<html><body>fake</body></html>"), // stage 1 stdout
			nil,                                      // stage 2 stdout (chromium writes PDF to scratch)
		},
	}
	InstallRunner(r)
	t.Cleanup(func() { InstallRunner(nil) })
	SetBinaries("pandoc", "chromium-browser")

	_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{})
	// PDF read-back will fail (fake runner didn't write the file) —
	// that's expected for this test which only inspects the call shape.
	if err == nil {
		t.Fatalf("expected error from PDF read-back; got nil")
	}
	if len(r.calls) != 2 {
		t.Fatalf("expected 2 calls (pandoc + chromium); got %d", len(r.calls))
	}
	if r.calls[0].binary != "pandoc" {
		t.Errorf("stage 1 binary: got %q want pandoc", r.calls[0].binary)
	}
	if r.calls[1].binary != "chromium-browser" {
		t.Errorf("stage 2 binary: got %q want chromium-browser", r.calls[1].binary)
	}
	// Stage 2 must include --print-to-pdf pointing at an absolute
	// path under the scratch dir.
	stage2 := r.calls[1]
	if stage2.scratch == "" {
		t.Fatalf("chromium call must have a scratch dir")
	}
	wantPDF := "--print-to-pdf=" + stage2.scratch + "/out.pdf"
	if !contains(stage2.cmd, wantPDF) {
		t.Errorf("chromium call missing --print-to-pdf=%s/out.pdf: %v", stage2.scratch, stage2.cmd)
	}
	if !contains(stage2.cmd, "--no-sandbox") {
		t.Errorf("chromium call missing --no-sandbox: %v", stage2.cmd)
	}
	// Stage 2 chromium reads file://<scratch>/in.html.
	wantHTML := "file://" + stage2.scratch + "/in.html"
	if !contains(stage2.cmd, wantHTML) {
		t.Errorf("chromium call missing file:// URL: %v", stage2.cmd)
	}
}

func TestErrUnavailable_WhenNoRunner(t *testing.T) {
	InstallRunner(nil)
	_, err := ToDocx(context.Background(), []byte("x"), Metadata{})
	if !errors.Is(err, ErrUnavailable) {
		t.Errorf("expected ErrUnavailable, got %v", err)
	}
}

func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
	args := metadataArgs(Metadata{
		Title:          "T",
		Project:        "P",
		GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC),
	})
	want := []string{
		"-V", "title=T",
		"-V", "project=P",
	}
	for i, w := range want {
		if i >= len(args) || args[i] != w {
			t.Fatalf("args[%d]: got %v want prefix %v", i, args, want)
		}
	}
	joined := strings.Join(args, "|")
	if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") {
		t.Errorf("generation_time missing or malformed: %v", args)
	}
	if strings.Contains(joined, "client=") {
		t.Errorf("empty client should not be passed: %v", args)
	}
}

func TestSingleflight_Collapses(t *testing.T) {
	var g singleflightGroup
	const N = 50
	var wg sync.WaitGroup
	var hits int32
	var mu sync.Mutex

	wg.Add(N)
	for i := 0; i < N; i++ {
		go func() {
			defer wg.Done()
			_, _ = g.Do("k", func() (any, error) {
				mu.Lock()
				hits++
				mu.Unlock()
				time.Sleep(20 * time.Millisecond)
				return "v", nil
			})
		}()
	}
	wg.Wait()
	if hits != 1 {
		t.Errorf("singleflight collapse: got %d invocations, want 1", hits)
	}
}

// contains reports whether haystack has needle as any of its elements.
func contains(haystack []string, needle string) bool {
	for _, s := range haystack {
		if s == needle {
			return true
		}
	}
	return false
}
