package okf

import (
	"encoding/json"
	"os"
	"testing"
)

// corpusEntry mirrors the single-sourced parity fixture emitted by the REAL
// Lezer parser in web/src/lib/cm/hashtag.parity.test.ts. spans are BYTE offsets.
type corpusEntry struct {
	Name  string `json:"name"`
	Body  string `json:"body"`
	Spans []struct {
		From int    `json:"from"`
		To   int    `json:"to"`
		Name string `json:"name"`
	} `json:"spans"`
}

func loadCorpus(t *testing.T) []corpusEntry {
	t.Helper()
	raw, err := os.ReadFile("testdata/hashtag_corpus.json")
	if err != nil {
		t.Fatalf("read corpus: %v (regenerate with UPDATE_CORPUS=1 vitest)", err)
	}
	var corpus []corpusEntry
	if err := json.Unmarshal(raw, &corpus); err != nil {
		t.Fatalf("parse corpus: %v", err)
	}
	if len(corpus) == 0 {
		t.Fatal("corpus is empty")
	}
	return corpus
}

// TestRewriteHashtags_LezerParity is the TR-02 anti-drift gate: the Go
// FindHashtags scanner MUST recognize the SAME #tag spans — at the byte-offset
// level — as the canonical TS Lezer grammar over every whole-document fixture.
// The expected spans are single-sourced from hashtag_corpus.json (generated by
// the real Lezer parser). A scanner that finds a different span, misses one, or
// splices the wrong bytes fails here — offset parity, not name-set parity.
func TestRewriteHashtags_LezerParity(t *testing.T) {
	corpus := loadCorpus(t)
	totalSpans := 0
	for _, e := range corpus {
		got := FindHashtags([]byte(e.Body))
		if len(got) != len(e.Spans) {
			t.Errorf("%s: FindHashtags(%q) found %d spans, want %d\n got=%v\nwant=%v",
				e.Name, e.Body, len(got), len(e.Spans), got, e.Spans)
			continue
		}
		for i, want := range e.Spans {
			totalSpans += 1
			g := got[i]
			if g.Start != want.From || g.End != want.To {
				t.Errorf("%s: span %d = [%d,%d), want [%d,%d) (body=%q)",
					e.Name, i, g.Start, g.End, want.From, want.To, e.Body)
			}
			if g.Name != want.Name {
				t.Errorf("%s: span %d name = %q, want %q", e.Name, i, g.Name, want.Name)
			}
			// Prove the offsets splice the exact bytes the Lezer node covers.
			if g.Start >= 0 && g.End <= len(e.Body) && e.Body[g.Start] != '#' {
				t.Errorf("%s: span %d does not begin at '#' (byte %q)", e.Name, i, e.Body[g.Start])
			}
		}
	}
	if totalSpans == 0 {
		t.Fatal("corpus asserted zero spans — parity test is vacuous")
	}
}

// TestRewriteHashtags_Rename splices #new for #old case-insensitively, byte-
// stable elsewhere.
func TestRewriteHashtags_Rename(t *testing.T) {
	cases := []struct {
		name, body, old, new, want string
		changed                    bool
	}{
		{"basic", "see #ops here", "ops", "devops", "see #devops here", true},
		{"case-insensitive-match", "see #Ops here", "ops", "devops", "see #devops here", true},
		{"multiple", "#ops and #ops", "ops", "devops", "#devops and #devops", true},
		{"nested-target", "#a here", "a", "a/b", "#a/b here", true},
		{"no-match-byte-identical", "no tags here #other", "ops", "devops", "no tags here #other", false},
		{"skips-fenced", "```\n#ops\n```\nlive #ops", "ops", "devops", "```\n#ops\n```\nlive #devops", true},
		{"skips-inline-code", "`#ops` and #ops", "ops", "devops", "`#ops` and #devops", true},
		{"skips-escape", "\\#ops and #ops", "ops", "devops", "\\#ops and #devops", true},
		{"in-heading", "## Release #ops\n\nbody", "ops", "devops", "## Release #devops\n\nbody", true},
	}
	for _, tc := range cases {
		got, changed := RewriteHashtags([]byte(tc.body), tc.old, tc.new)
		if string(got) != tc.want {
			t.Errorf("%s: RewriteHashtags(%q,%q→%q) = %q, want %q", tc.name, tc.body, tc.old, tc.new, got, tc.want)
		}
		if changed != tc.changed {
			t.Errorf("%s: changed = %v, want %v", tc.name, changed, tc.changed)
		}
	}
}

// TestRewriteHashtags_NoMatchSameSlice: byte-identical AND the SAME backing slice
// on no-match (no allocation, true round-trip).
func TestRewriteHashtags_NoMatchSameSlice(t *testing.T) {
	body := []byte("plain body with #other tag")
	got, changed := RewriteHashtags(body, "ops", "devops")
	if changed {
		t.Fatal("changed = true on no-match")
	}
	if &got[0] != &body[0] {
		t.Error("no-match returned a copy, not the original slice")
	}
}

// TestRewriteHashtags_Delete exercises the D-03a whitespace rule.
func TestRewriteHashtags_Delete(t *testing.T) {
	cases := []struct {
		name, body, want string
		changed          bool
	}{
		// Space-bounded both sides → collapse exactly ONE space.
		{"space-bounded", "see #ops here", "see here", true},
		// Trailing punctuation (not a space) → leave whitespace untouched.
		{"punct-after", "foo #ops.", "foo .", true},
		// A tag-only line → the '#ops' removed, the leading space? none here.
		{"line-only", "#ops", "", true},
		// Not space-bounded on the left (start of line) → no left collapse; right is EOL.
		{"start-of-line", "#ops trailing", " trailing", true},
		// Never merge across a newline: the newline is not a collapsible space.
		{"no-newline-merge", "a #ops\nb", "a \nb", true},
		{"multiple", "#ops mid #ops end", " mid end", true},
		{"no-match", "nothing here", "nothing here", false},
	}
	for _, tc := range cases {
		got, changed := RewriteHashtags([]byte(tc.body), "ops", "")
		if string(got) != tc.want {
			t.Errorf("%s: delete(%q) = %q, want %q", tc.name, tc.body, got, tc.want)
		}
		if changed != tc.changed {
			t.Errorf("%s: changed = %v, want %v", tc.name, changed, tc.changed)
		}
	}
}

// TestRewriteHashtags_MergeAdjacentDup is the D-03b rule: merging #a→#b next to
// an existing #b leaves `#b #b` (no dedupe; the frontmatter reconcile dedupes).
func TestRewriteHashtags_MergeAdjacentDup(t *testing.T) {
	got, changed := RewriteHashtags([]byte("#a #b"), "a", "b")
	if string(got) != "#b #b" {
		t.Errorf("merge adjacent-dup = %q, want %q", got, "#b #b")
	}
	if !changed {
		t.Error("changed = false, want true")
	}
}

// TestRewriteHashtags_ByteStability: only the matched #tag bytes change; a
// no-op field (other tags, prose, code) is preserved verbatim.
func TestRewriteHashtags_ByteStability(t *testing.T) {
	body := "# Title\n\nProse with #keep and #ops.\n\n```\n#ops in code\n```\n\nEnd."
	want := "# Title\n\nProse with #keep and #devops.\n\n```\n#ops in code\n```\n\nEnd."
	got, changed := RewriteHashtags([]byte(body), "ops", "devops")
	if !changed {
		t.Fatal("changed = false")
	}
	if string(got) != want {
		t.Errorf("byte-stability:\n got=%q\nwant=%q", got, want)
	}
}
