// Package log builds the agent's slog logger. Kept tiny on purpose; the agent is
// a host service, so logs go to stderr (journald-friendly). Secrets must never be
// passed to the logger — config is logged only via Config.Redacted (see config).
//
// Observability pass (v0.83.0): New now fans out to TWO handlers — stderr at the
// configured level (journald behavior unchanged) and an in-memory debug Ring fixed
// at LevelDebug. The ring is the remote-diagnostics capture layer: DEBUG detail
// exists for GET /debug/logs and the heartbeat log-pull WITHOUT a config flip.
package log

import (
	"context"
	"io"
	"log/slog"
	"os"
	"strings"
	"sync"
	"time"
)

// DefaultRingSize is the debug ring's entry capacity (~1000 lines ≈ a few hours of
// normal operation; the heartbeat tail is additionally byte-capped by its caller).
const DefaultRingSize = 1000

// New returns a text slog.Logger at the given level ("debug"|"info"|"warn"|
// "error"; unknown falls back to info) writing to stderr, plus the debug Ring
// that captures EVERY record at LevelDebug regardless of the stderr level.
func New(level string) (*slog.Logger, *Ring) {
	return NewWithWriter(os.Stderr, level, DefaultRingSize)
}

// NewWithWriter is the injectable constructor (tests capture the stderr stream).
func NewWithWriter(w io.Writer, level string, ringSize int) (*slog.Logger, *Ring) {
	lvl := ParseLevel(level)
	ring := NewRing(ringSize)
	stderrH := slog.NewTextHandler(w, &slog.HandlerOptions{Level: lvl})
	// The ring handler is FIXED at LevelDebug — the capture layer must hold flow
	// detail even when the emit level is info (the motivating incident: an info box
	// showed nothing about a refused NAS verify because the detail never existed).
	ringH := slog.NewTextHandler(ring, &slog.HandlerOptions{Level: slog.LevelDebug})
	return slog.New(fanout{stderrH, ringH}), ring
}

// ParseLevel maps a config level string to a slog.Level (unknown → info).
func ParseLevel(level string) slog.Level {
	switch strings.ToLower(level) {
	case "debug":
		return slog.LevelDebug
	case "warn", "warning":
		return slog.LevelWarn
	case "error":
		return slog.LevelError
	default:
		return slog.LevelInfo
	}
}

// fanout dispatches one record to every handler whose level admits it. Each
// handler keeps its own level, so the ring can capture DEBUG while stderr stays
// at the configured emit level.
type fanout []slog.Handler

func (f fanout) Enabled(ctx context.Context, lvl slog.Level) bool {
	for _, h := range f {
		if h.Enabled(ctx, lvl) {
			return true
		}
	}
	return false
}

func (f fanout) Handle(ctx context.Context, r slog.Record) error {
	var firstErr error
	for _, h := range f {
		if !h.Enabled(ctx, r.Level) {
			continue
		}
		if err := h.Handle(ctx, r.Clone()); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	return firstErr
}

func (f fanout) WithAttrs(attrs []slog.Attr) slog.Handler {
	out := make(fanout, len(f))
	for i, h := range f {
		out[i] = h.WithAttrs(attrs)
	}
	return out
}

func (f fanout) WithGroup(name string) slog.Handler {
	out := make(fanout, len(f))
	for i, h := range f {
		out[i] = h.WithGroup(name)
	}
	return out
}

// Entry is one captured log record, parsed from the slog text line (the port of
// the controller's web.LogBuffer entry, adapted to slog's text format).
type Entry struct {
	Time    time.Time `json:"timestamp"`
	Level   string    `json:"level"`   // DEBUG | INFO | WARN | ERROR
	Message string    `json:"message"` // msg=… plus attrs, verbatim from the text handler
}

// Ring is a thread-safe fixed-size ring of Entries. It implements io.Writer so a
// slog.TextHandler can feed it (one Write per record — TextHandler writes each
// record as a single line).
type Ring struct {
	mu      sync.RWMutex
	entries []Entry
	pos     int
	full    bool
}

// NewRing creates a ring keeping the last size entries (size ≤ 0 → DefaultRingSize).
func NewRing(size int) *Ring {
	if size <= 0 {
		size = DefaultRingSize
	}
	return &Ring{entries: make([]Entry, size)}
}

// Write parses one slog text line ("time=… level=… msg=… k=v …") into an Entry.
func (r *Ring) Write(p []byte) (int, error) {
	line := strings.TrimRight(string(p), "\r\n")
	if line == "" {
		return len(p), nil
	}
	e := parseSlogLine(line)
	r.mu.Lock()
	r.entries[r.pos] = e
	r.pos = (r.pos + 1) % len(r.entries)
	if r.pos == 0 && !r.full {
		r.full = true
	}
	r.mu.Unlock()
	return len(p), nil
}

// Entries returns up to limit entries in chronological order (newest kept when
// truncating; limit ≤ 0 or > cap → everything held) plus the total held count.
func (r *Ring) Entries(limit int) ([]Entry, int) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	total := r.pos
	start := 0
	if r.full {
		total = len(r.entries)
		start = r.pos
	}
	out := make([]Entry, 0, total)
	for i := 0; i < total; i++ {
		out = append(out, r.entries[(start+i)%len(r.entries)])
	}
	if limit > 0 && len(out) > limit {
		out = out[len(out)-limit:]
	}
	return out, total
}

// Lines renders the held entries as plain text lines (chronological), dropping
// from the HEAD (oldest) to honor maxBytes so the newest lines survive — the
// heartbeat log-tail budget (maxBytes ≤ 0 → no byte cap).
func (r *Ring) Lines(maxBytes int) []string {
	entries, _ := r.Entries(0)
	lines := make([]string, len(entries))
	for i, e := range entries {
		lines[i] = e.Time.Format(time.RFC3339) + " [" + e.Level + "] " + e.Message
	}
	if maxBytes <= 0 {
		return lines
	}
	total := 0
	start := len(lines)
	for i := len(lines) - 1; i >= 0; i-- {
		total += len(lines[i]) + 1 // +1 for the newline it represents
		if total > maxBytes {
			break
		}
		start = i
	}
	return lines[start:]
}

// parseSlogLine splits a TextHandler line into time / level / the rest. Attrs and
// the quoted msg stay verbatim in Message — honest, and immune to quoting edge
// cases the viewer doesn't need parsed.
func parseSlogLine(line string) Entry {
	e := Entry{Level: "INFO", Message: line, Time: time.Now()}
	rest := line
	if v, r2, ok := cutField(rest, "time="); ok {
		if t, err := time.Parse(time.RFC3339Nano, v); err == nil {
			e.Time = t
		}
		rest = r2
	}
	if v, r2, ok := cutField(rest, "level="); ok {
		switch v {
		case "DEBUG", "INFO", "WARN", "ERROR":
			e.Level = v
		}
		rest = r2
	}
	e.Message = rest
	return e
}

// cutField extracts a leading `key=value ` field (unquoted — slog never quotes
// its own time/level values) and returns the value + the remainder.
func cutField(s, key string) (val, rest string, ok bool) {
	if !strings.HasPrefix(s, key) {
		return "", s, false
	}
	s = s[len(key):]
	if i := strings.IndexByte(s, ' '); i >= 0 {
		return s[:i], s[i+1:], true
	}
	return s, "", true
}
