package tools

import (
	"path/filepath"
	"testing"

	"skraak/utils"
)

// helpers

func seg(start, end float64, labels ...*utils.Label) *utils.Segment {
	return &utils.Segment{
		StartTime: start,
		EndTime:   end,
		FreqLow:   100,
		FreqHigh:  8000,
		Labels:    labels,
	}
}

func lbl(filter, species, calltype string, certainty int) *utils.Label {
	return &utils.Label{
		Filter:    filter,
		Species:   species,
		CallType:  calltype,
		Certainty: certainty,
	}
}

func writeFile(t *testing.T, segs ...*utils.Segment) string {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "test.data")
	df := &utils.DataFile{
		Meta:     &utils.DataMeta{Operator: "ML", Reviewer: "David", Duration: 3600},
		Segments: segs,
	}
	if err := df.Write(path); err != nil {
		t.Fatalf("write fixture: %v", err)
	}
	return path
}

func readFile(t *testing.T, path string) *utils.DataFile {
	t.Helper()
	df, err := utils.ParseDataFile(path)
	if err != nil {
		t.Fatalf("parse %s: %v", path, err)
	}
	return df
}

// findLabel returns the label with matching filter and time on the parsed file, or nil.
func findLabel(df *utils.DataFile, filter string, start, end float64) *utils.Label {
	for _, s := range df.Segments {
		if s.StartTime != start || s.EndTime != end {
			continue
		}
		for _, l := range s.Labels {
			if l.Filter == filter {
				return l
			}
		}
	}
	return nil
}

const (
	fFrom = "opensoundscape-kiwi-1.2"
	fTo   = "opensoundscape-kiwi-1.5"
)

func TestPropagate_HappyPathSingle(t *testing.T) {
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v (%s)", err, out.Error)
	}
	if out.Propagated != 1 || out.TargetsExamined != 1 || out.SkippedConflict != 0 || out.SkippedNoOverlap != 0 {
		t.Fatalf("counts wrong: %+v", out)
	}

	df := readFile(t, path)
	target := findLabel(df, fTo, 100, 125)
	if target == nil {
		t.Fatal("target label missing")
	}
	if target.Species != "Kiwi" || target.CallType != "Male" || target.Certainty != 90 {
		t.Errorf("target not updated correctly: species=%q calltype=%q cert=%d", target.Species, target.CallType, target.Certainty)
	}
	if df.Meta.Reviewer != "Skraak" {
		t.Errorf("reviewer = %q, want Skraak", df.Meta.Reviewer)
	}
}

func TestPropagate_NoOverlap(t *testing.T) {
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(500, 525, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 0 || out.TargetsExamined != 1 || out.SkippedNoOverlap != 1 {
		t.Fatalf("counts wrong: %+v", out)
	}
	df := readFile(t, path)
	target := findLabel(df, fTo, 500, 525)
	if target.Certainty != 70 {
		t.Errorf("target should not be modified, cert=%d", target.Certainty)
	}
	if df.Meta.Reviewer != "David" {
		t.Errorf("reviewer should stay David (no write), got %q", df.Meta.Reviewer)
	}
}

func TestPropagate_SourceWrongSpecies_Ignored(t *testing.T) {
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Weka", "", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 0 || out.SkippedNoOverlap != 1 {
		t.Fatalf("counts wrong: %+v", out)
	}
}

func TestPropagate_SourceWrongCertainty_Ignored(t *testing.T) {
	// cert=70 and cert=0 source labels must NOT count as sources.
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 70)),
		seg(200, 225, lbl(fFrom, "Don't Know", "", 0)),
		seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
		seg(200, 225, lbl(fTo, "Kiwi", "Male", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 0 || out.SkippedNoOverlap != 2 {
		t.Fatalf("counts wrong: %+v", out)
	}
}

func TestPropagate_SourceWrongFilter_Ignored(t *testing.T) {
	path := writeFile(t,
		seg(100, 125, lbl("some-other-filter", "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !out.FiltersMissing || out.Propagated != 0 || out.TargetsExamined != 0 {
		t.Fatalf("expected FiltersMissing=true with zero counts, got: %+v", out)
	}
}

func TestPropagate_TargetCert100_NotTouched(t *testing.T) {
	// Target with cert=100 is human-verified — must NOT be overwritten.
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Male", 100)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.TargetsExamined != 0 || out.Propagated != 0 {
		t.Fatalf("cert=100 target must not be examined: %+v", out)
	}
	df := readFile(t, path)
	if df.Meta.Reviewer != "David" {
		t.Errorf("reviewer should stay David (no write), got %q", df.Meta.Reviewer)
	}
}

func TestPropagate_TargetCert90_NotTouched(t *testing.T) {
	// Target with cert=90 (already propagated earlier) must NOT be re-propagated.
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Female", 90)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.TargetsExamined != 0 || out.Propagated != 0 {
		t.Fatalf("cert=90 target must not be examined: %+v", out)
	}
	df := readFile(t, path)
	target := findLabel(df, fTo, 100, 125)
	if target.Certainty != 90 || target.CallType != "Female" {
		t.Errorf("cert=90 target was modified: %+v", target)
	}
}

func TestPropagate_TargetCert0_Propagated(t *testing.T) {
	// Target at cert=0 ("Don't Know" / "Noise") SHOULD be propagated when an
	// overlapping cert=100 source exists — rescues labels from the noise bucket
	// so they surface for review even if occasionally wrong.
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Don't Know", "", 0)),
		seg(200, 225, lbl(fFrom, "Kiwi", "Female", 100)),
		seg(200, 225, lbl(fTo, "Noise", "", 0)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.TargetsExamined != 2 || out.Propagated != 2 {
		t.Fatalf("cert=0 targets must be propagated: %+v", out)
	}
	df := readFile(t, path)
	for _, c := range []struct {
		start, end float64
		calltype   string
	}{{100, 125, "Male"}, {200, 225, "Female"}} {
		l := findLabel(df, fTo, c.start, c.end)
		if l == nil || l.Species != "Kiwi" || l.CallType != c.calltype || l.Certainty != 90 {
			t.Errorf("at %v-%v got %+v, want Kiwi+%s cert=90", c.start, c.end, l, c.calltype)
		}
	}
}

func TestPropagate_MultipleSourcesAgree(t *testing.T) {
	// Two overlapping sources with same calltype → propagate.
	path := writeFile(t,
		seg(100, 110, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(105, 120, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 1 || out.SkippedConflict != 0 {
		t.Fatalf("counts wrong: %+v", out)
	}
	df := readFile(t, path)
	target := findLabel(df, fTo, 100, 125)
	if target.CallType != "Male" {
		t.Errorf("calltype should be Male, got %q", target.CallType)
	}
}

func TestPropagate_MultipleSourcesConflict(t *testing.T) {
	// Two overlapping sources with different calltypes → conflict, skip, report.
	path := writeFile(t,
		seg(100, 110, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(115, 120, lbl(fFrom, "Kiwi", "Female", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 0 || out.SkippedConflict != 1 {
		t.Fatalf("expected 1 conflict skip: %+v", out)
	}
	if len(out.Conflicts) != 1 {
		t.Fatalf("expected 1 conflict report, got %d", len(out.Conflicts))
	}
	if out.Conflicts[0].TargetStart != 100 || out.Conflicts[0].TargetEnd != 125 {
		t.Errorf("conflict target wrong: %+v", out.Conflicts[0])
	}
	if len(out.Conflicts[0].SourceChoices) != 2 {
		t.Errorf("expected 2 source choices, got %d", len(out.Conflicts[0].SourceChoices))
	}
	// Target must NOT be modified.
	df := readFile(t, path)
	target := findLabel(df, fTo, 100, 125)
	if target.CallType != "Duet" || target.Certainty != 70 {
		t.Errorf("conflicted target was modified: %+v", target)
	}
	if df.Meta.Reviewer != "David" {
		t.Errorf("reviewer should stay David (no write), got %q", df.Meta.Reviewer)
	}
}

func TestPropagate_EmptyCallTypePropagates(t *testing.T) {
	// Source with empty calltype → target gets empty calltype.
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Male", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 1 {
		t.Fatalf("expected propagated=1: %+v", out)
	}
	df := readFile(t, path)
	target := findLabel(df, fTo, 100, 125)
	if target.CallType != "" {
		t.Errorf("calltype should be cleared, got %q", target.CallType)
	}
	if target.Species != "Kiwi" || target.Certainty != 90 {
		t.Errorf("target fields wrong: %+v", target)
	}
}

func TestPropagate_SpeciesOverride(t *testing.T) {
	// Target species was different from --species; must be overwritten.
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Don't Know", "", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 1 {
		t.Fatalf("expected propagated=1: %+v", out)
	}
	df := readFile(t, path)
	target := findLabel(df, fTo, 100, 125)
	if target.Species != "Kiwi" || target.CallType != "Male" || target.Certainty != 90 {
		t.Errorf("target not overwritten correctly: %+v", target)
	}
}

func TestPropagate_OverlapBoundaryExclusive(t *testing.T) {
	// Segments touching at a point (src ends exactly where tgt starts) do NOT overlap.
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(125, 150, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 0 || out.SkippedNoOverlap != 1 {
		t.Fatalf("touching boundary must not count as overlap: %+v", out)
	}
}

func TestPropagate_OverlapPartial(t *testing.T) {
	// 1-second overlap is enough.
	path := writeFile(t,
		seg(100, 126, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(125, 150, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 1 {
		t.Fatalf("expected propagated=1: %+v", out)
	}
}

func TestPropagate_SupersetEitherDirection(t *testing.T) {
	// Source engulfs target.
	path1 := writeFile(t,
		seg(100, 200, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(110, 150, lbl(fTo, "Kiwi", "Duet", 70)),
	)
	if out, _ := CallsPropagate(CallsPropagateInput{File: path1, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"}); out.Propagated != 1 {
		t.Errorf("source-engulfs-target: %+v", out)
	}

	// Target engulfs source.
	path2 := writeFile(t,
		seg(110, 150, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 200, lbl(fTo, "Kiwi", "Duet", 70)),
	)
	if out, _ := CallsPropagate(CallsPropagateInput{File: path2, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"}); out.Propagated != 1 {
		t.Errorf("target-engulfs-source: %+v", out)
	}
}

func TestPropagate_MissingFlags(t *testing.T) {
	cases := []struct {
		name string
		in   CallsPropagateInput
	}{
		{"no file", CallsPropagateInput{FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"}},
		{"no from", CallsPropagateInput{File: "x", ToFilter: fTo, Species: "Kiwi"}},
		{"no to", CallsPropagateInput{File: "x", FromFilter: fFrom, Species: "Kiwi"}},
		{"no species", CallsPropagateInput{File: "x", FromFilter: fFrom, ToFilter: fTo}},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, err := CallsPropagate(c.in)
			if err == nil {
				t.Errorf("expected error")
			}
		})
	}
}

func TestPropagate_SameFromAndTo(t *testing.T) {
	_, err := CallsPropagate(CallsPropagateInput{
		File: "x", FromFilter: fFrom, ToFilter: fFrom, Species: "Kiwi",
	})
	if err == nil {
		t.Error("expected error when --from == --to")
	}
}

func TestPropagate_NonexistentFile(t *testing.T) {
	_, err := CallsPropagate(CallsPropagateInput{
		File: "/nonexistent/path.data", FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err == nil {
		t.Error("expected error for nonexistent file")
	}
}

func TestPropagate_RealisticMixed(t *testing.T) {
	// Mimics the 20260228_211500.WAV.data case: cert=0 "Don't Know" and cert=100 Kiwi sources
	// coexist; only cert=100 Kiwi gets propagated.
	path := writeFile(t,
		// Sources (kiwi-1.2)
		seg(45, 52.5, lbl(fFrom, "Don't Know", "", 0)),
		seg(142.5, 177.5, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(195, 217.5, lbl(fFrom, "Don't Know", "", 0)),
		seg(647.5, 682.5, lbl(fFrom, "Kiwi", "Female", 100)),
		seg(815, 855, lbl(fFrom, "Kiwi", "Duet", 100)),
		// Targets (kiwi-1.5)
		seg(147.5, 167.5, lbl(fTo, "Kiwi", "Male", 70)),
		seg(647.5, 672.5, lbl(fTo, "Kiwi", "Female", 70)),
		seg(815, 852.5, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.TargetsExamined != 3 || out.Propagated != 3 || out.SkippedConflict != 0 {
		t.Fatalf("counts wrong: %+v", out)
	}
	df := readFile(t, path)
	expect := []struct {
		start, end float64
		calltype   string
	}{
		{147.5, 167.5, "Male"},
		{647.5, 672.5, "Female"},
		{815, 852.5, "Duet"},
	}
	for _, e := range expect {
		l := findLabel(df, fTo, e.start, e.end)
		if l == nil || l.Certainty != 90 || l.CallType != e.calltype || l.Species != "Kiwi" {
			t.Errorf("at %v-%v got %+v, want Kiwi+%s cert=90", e.start, e.end, l, e.calltype)
		}
	}
}

func TestPropagate_NoWriteIfNothingChanged(t *testing.T) {
	// File with only non-target segments should not be rewritten (reviewer unchanged).
	path := writeFile(t,
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
	)

	out, err := CallsPropagate(CallsPropagateInput{
		File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.Propagated != 0 || out.TargetsExamined != 0 {
		t.Fatalf("expected no activity: %+v", out)
	}
	df := readFile(t, path)
	if df.Meta.Reviewer != "David" {
		t.Errorf("reviewer should not be touched, got %q", df.Meta.Reviewer)
	}
}

// writeFileAt is like writeFile but puts the file inside an existing dir
// with a caller-provided basename (must end in .data).
func writeFileAt(t *testing.T, dir, base string, segs ...*utils.Segment) string {
	t.Helper()
	path := filepath.Join(dir, base)
	df := &utils.DataFile{
		Meta:     &utils.DataMeta{Operator: "ML", Reviewer: "David", Duration: 3600},
		Segments: segs,
	}
	if err := df.Write(path); err != nil {
		t.Fatalf("write fixture: %v", err)
	}
	return path
}

// assertPropagateStats checks output stats against expected values.
func assertPropagateStats(t *testing.T, got, want CallsPropagateFolderOutput) {
	t.Helper()
	checks := []struct {
		name string
		got  int
		want int
	}{
		{"FilesTotal", got.FilesTotal, want.FilesTotal},
		{"FilesWithBothFilters", got.FilesWithBothFilters, want.FilesWithBothFilters},
		{"FilesSkippedNoFilter", got.FilesSkippedNoFilter, want.FilesSkippedNoFilter},
		{"FilesChanged", got.FilesChanged, want.FilesChanged},
		{"FilesErrored", got.FilesErrored, want.FilesErrored},
		{"TargetsExamined", got.TargetsExamined, want.TargetsExamined},
		{"Propagated", got.Propagated, want.Propagated},
		{"SkippedNoOverlap", got.SkippedNoOverlap, want.SkippedNoOverlap},
	}
	for _, c := range checks {
		if c.got != c.want {
			t.Errorf("%s: got %d, want %d", c.name, c.got, c.want)
		}
	}
}

func TestPropagateFolder_AggregatesAndSkipsMissing(t *testing.T) {
	dir := t.TempDir()

	// File A: both filters present, one clean propagation.
	aPath := writeFileAt(t, dir, "a.wav.data",
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
	)
	// File B: only target filter — missing source, must be skipped silently.
	bPath := writeFileAt(t, dir, "b.wav.data",
		seg(200, 225, lbl(fTo, "Kiwi", "Duet", 70)),
	)
	// File C: only source filter — missing target, must be skipped silently.
	writeFileAt(t, dir, "c.wav.data",
		seg(300, 325, lbl(fFrom, "Kiwi", "Male", 100)),
	)
	// File D: both filters, but no overlap → targets examined, none propagated.
	dPath := writeFileAt(t, dir, "d.wav.data",
		seg(400, 425, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(500, 525, lbl(fTo, "Kiwi", "Duet", 70)),
	)

	out, err := CallsPropagateFolder(CallsPropagateFolderInput{
		Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	assertPropagateStats(t, out, CallsPropagateFolderOutput{
		FilesTotal:           4,
		FilesWithBothFilters: 2,
		FilesSkippedNoFilter: 2,
		FilesChanged:         1,
		FilesErrored:         0,
		TargetsExamined:      2,
		Propagated:           1,
		SkippedNoOverlap:     1,
	})

	t.Run("file_a_propagated", func(t *testing.T) {
		aDf := readFile(t, aPath)
		if aDf.Meta.Reviewer != "Skraak" {
			t.Errorf("reviewer: got %q, want Skraak", aDf.Meta.Reviewer)
		}
		if l := findLabel(aDf, fTo, 100, 125); l == nil || l.Certainty != 90 || l.CallType != "Male" {
			t.Errorf("target label: got %+v, want cert=90 calltype=Male", l)
		}
	})

	t.Run("file_b_skipped", func(t *testing.T) {
		bDf := readFile(t, bPath)
		if bDf.Meta.Reviewer != "David" {
			t.Errorf("reviewer should not be touched, got %q", bDf.Meta.Reviewer)
		}
	})

	t.Run("file_d_no_overlap", func(t *testing.T) {
		dDf := readFile(t, dPath)
		if dDf.Meta.Reviewer != "David" {
			t.Errorf("reviewer should not be touched, got %q", dDf.Meta.Reviewer)
		}
		if l := findLabel(dDf, fTo, 500, 525); l == nil || l.Certainty != 70 {
			t.Errorf("target label should be unchanged cert=70, got %+v", l)
		}
	})
}

func TestPropagateFolder_EmptyFolder(t *testing.T) {
	dir := t.TempDir()
	out, err := CallsPropagateFolder(CallsPropagateFolderInput{
		Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.FilesTotal != 0 || out.Propagated != 0 {
		t.Errorf("expected empty result, got %+v", out)
	}
}

func TestPropagateFolder_MissingRequiredFlags(t *testing.T) {
	dir := t.TempDir()
	cases := []CallsPropagateFolderInput{
		{Folder: "", FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"},
		{Folder: dir, FromFilter: "", ToFilter: fTo, Species: "Kiwi"},
		{Folder: dir, FromFilter: fFrom, ToFilter: "", Species: "Kiwi"},
		{Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: ""},
		{Folder: dir, FromFilter: fFrom, ToFilter: fFrom, Species: "Kiwi"},
	}
	for i, in := range cases {
		if _, err := CallsPropagateFolder(in); err == nil {
			t.Errorf("case %d: expected error for input %+v", i, in)
		}
	}
}

func TestPropagateFolder_NonexistentFolder(t *testing.T) {
	_, err := CallsPropagateFolder(CallsPropagateFolderInput{
		Folder: "/nonexistent/path/xyz", FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err == nil {
		t.Fatal("expected error for nonexistent folder")
	}
}

func TestPropagateFolder_ConflictsTaggedWithFile(t *testing.T) {
	dir := t.TempDir()
	// Two sources with different calltypes both overlapping one target.
	writeFileAt(t, dir, "conflict.wav.data",
		seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
		seg(110, 130, lbl(fFrom, "Kiwi", "Female", 100)),
		seg(100, 130, lbl(fTo, "Kiwi", "", 70)),
	)

	out, err := CallsPropagateFolder(CallsPropagateFolderInput{
		Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if out.SkippedConflict != 1 || len(out.Conflicts) != 1 {
		t.Fatalf("expected one conflict, got %+v", out)
	}
	if out.Conflicts[0].File == "" {
		t.Errorf("conflict should be tagged with file path, got %+v", out.Conflicts[0])
	}
}