package logscanner

import (
	"bufio"
	"bytes"
	"context"
	"sync"
	"sync/atomic"
	"testing"

	"github.com/alexei/tinyforge/internal/store"
)

// recordingEmitter captures hits from a tail without touching the
// real store or event bus. Concurrent-safe so we can let the tail
// goroutine push hits while the test asserts.
type recordingEmitter struct {
	mu   sync.Mutex
	hits []Hit
}

func (r *recordingEmitter) EmitHit(_ context.Context, hit Hit) {
	r.mu.Lock()
	r.hits = append(r.hits, hit)
	r.mu.Unlock()
}

func (r *recordingEmitter) Hits() []Hit {
	r.mu.Lock()
	defer r.mu.Unlock()
	out := make([]Hit, len(r.hits))
	copy(out, r.hits)
	return out
}

// snapshotForRule wraps a single rule into an atomic Snapshot
// pointer so tests can drive processLine without rebuilding from
// store rows.
func snapshotForRule(t *testing.T, pattern string) *atomic.Pointer[Snapshot] {
	t.Helper()
	rows := []store.LogScanRule{
		{ID: 1, Name: "t", Pattern: pattern, Severity: "warn", Streams: "all", Enabled: true},
	}
	snap, errs := BuildSnapshot(rows)
	if len(errs) != 0 {
		t.Fatalf("BuildSnapshot: %v", errs)
	}
	p := &atomic.Pointer[Snapshot]{}
	p.Store(snap)
	return p
}

func TestProcessLine_StripsRFC3339Prefix(t *testing.T) {
	emit := &recordingEmitter{}
	snap := snapshotForRule(t, `panic`)
	tl := &tail{
		containerID: "c1",
		workloadID:  "w1",
		engine:      NewEngine(),
		emitter:     emit,
		snapshot:    snap,
	}
	tl.processLine(context.Background(), "stderr", "2026-05-11T12:34:56.123456789Z fatal panic in worker")
	hits := emit.Hits()
	if len(hits) != 1 {
		t.Fatalf("want 1 hit, got %d", len(hits))
	}
	if hits[0].Line != "fatal panic in worker" {
		t.Errorf("timestamp not stripped: %q", hits[0].Line)
	}
}

func TestProcessLine_LeavesNonTimestampedLineAlone(t *testing.T) {
	// The previous heuristic stripped the first word of any line
	// whose first space landed past byte 20. A long-hash line with
	// no embedded timestamp must now survive intact.
	emit := &recordingEmitter{}
	snap := snapshotForRule(t, `(?i)hash`)
	tl := &tail{
		containerID: "c1",
		workloadID:  "w1",
		engine:      NewEngine(),
		emitter:     emit,
		snapshot:    snap,
	}
	long := "aaaaaaaaaaaaaaaaaaaaaaaa hash payload"
	tl.processLine(context.Background(), "stdout", long)
	hits := emit.Hits()
	if len(hits) != 1 {
		t.Fatalf("want 1 hit, got %d", len(hits))
	}
	if hits[0].Line != long {
		t.Errorf("non-timestamp prefix incorrectly stripped: %q", hits[0].Line)
	}
}

func TestProcessLine_NoSnapshotIsSafe(t *testing.T) {
	// Tail constructed before the manager loads its first snapshot
	// (or after a transient nil) must not crash — processLine
	// returns silently when snapshot.Load() is nil.
	tl := &tail{
		containerID: "c1",
		workloadID:  "w1",
		engine:      NewEngine(),
		emitter:     &recordingEmitter{},
		snapshot:    &atomic.Pointer[Snapshot]{}, // empty pointer
	}
	tl.processLine(context.Background(), "stdout", "anything")
}

func TestReadLineFromBuffer(t *testing.T) {
	buf := &bytes.Buffer{}
	buf.WriteString("line one\nline two\npartial")

	got, ok := readLineFromBuffer(buf)
	if !ok || got != "line one" {
		t.Fatalf("first read: ok=%v got=%q", ok, got)
	}
	got, ok = readLineFromBuffer(buf)
	if !ok || got != "line two" {
		t.Fatalf("second read: ok=%v got=%q", ok, got)
	}
	// Trailing partial line stays in buffer until more data arrives.
	if _, ok := readLineFromBuffer(buf); ok {
		t.Errorf("partial line should NOT yield until newline arrives")
	}
	if buf.String() != "partial" {
		t.Errorf("remainder=%q want %q", buf.String(), "partial")
	}
}

func TestIsMultiplexedStream(t *testing.T) {
	// Valid docker frame header: type=2 (stderr), 3 nulls, then 4-byte length.
	demuxed := []byte{2, 0, 0, 0, 0, 0, 0, 12, 'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd', '!'}
	if !isMultiplexedStream(newReader(demuxed)) {
		t.Error("valid demuxed frame should be detected")
	}

	// Plain text: first byte is a printable letter, header check fails.
	plain := []byte("plain log line without framing\n")
	if isMultiplexedStream(newReader(plain)) {
		t.Error("plain text should not be detected as multiplexed")
	}

	// Header with type=3 is invalid (docker uses 0,1,2 only).
	bad := []byte{3, 0, 0, 0, 0, 0, 0, 1}
	if isMultiplexedStream(newReader(bad)) {
		t.Error("type=3 header is not a valid docker frame")
	}
}

// newReader returns a *bufio.Reader sized large enough to satisfy
// the Peek(8) the demuxer detection requires.
func newReader(b []byte) *bufio.Reader {
	return bufio.NewReaderSize(bytes.NewReader(b), 32)
}
