package logging

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

// levelWidth and messageWidth keep the fields of consecutive lines in the same columns,
// which is what makes a scrolling log readable at a glance. Both are minimums: a longer
// message pushes its fields right rather than being truncated.
const (
	levelWidth   = 5
	messageWidth = 30
)

// consoleHandler writes one aligned line per event — timestamp, level, message, fields.
//
// The timestamp leads the line rather than arriving as a `time=` field. Everything that
// reads these lines (docker compose logs, the admin page, a NAS console) already treats
// the start of a line as when it happened, so a labelled key there is noise sitting in
// front of the message.
type consoleHandler struct {
	// mu is shared by every derived handler: WithAttrs clones this struct, and two
	// clones writing to the same file must still produce whole lines.
	mu     *sync.Mutex
	w      io.Writer
	level  slog.Leveler
	attrs  []field
	groups []string
}

type field struct {
	key   string
	value string
}

func newConsoleHandler(w io.Writer, level slog.Leveler) *consoleHandler {
	return &consoleHandler{mu: &sync.Mutex{}, w: w, level: level}
}

func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
	return level >= h.level.Level()
}

func (h *consoleHandler) Handle(_ context.Context, record slog.Record) error {
	fields := slices.Clone(h.attrs)
	record.Attrs(func(attr slog.Attr) bool {
		fields = appendField(fields, h.groups, attr)
		return true
	})
	orderFields(fields)

	var line strings.Builder
	line.WriteString(record.Time.UTC().Format(time.DateTime))
	line.WriteByte(' ')
	line.WriteString(pad(record.Level.String(), levelWidth))
	line.WriteByte(' ')
	line.WriteString(pad(record.Message, messageWidth))
	for _, f := range fields {
		line.WriteByte(' ')
		line.WriteString(f.key)
		line.WriteByte('=')
		line.WriteString(quote(f.value))
	}
	line.WriteByte('\n')

	h.mu.Lock()
	defer h.mu.Unlock()
	_, err := io.WriteString(h.w, line.String())
	return err
}

func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	clone := *h
	clone.attrs = slices.Clone(h.attrs)
	for _, attr := range attrs {
		clone.attrs = appendField(clone.attrs, h.groups, attr)
	}
	return &clone
}

func (h *consoleHandler) WithGroup(name string) slog.Handler {
	clone := *h
	clone.groups = append(slices.Clone(h.groups), name)
	return &clone
}

func appendField(target []field, groups []string, attr slog.Attr) []field {
	attr.Value = attr.Value.Resolve()
	if attr.Equal(slog.Attr{}) {
		return target
	}
	if attr.Value.Kind() == slog.KindGroup {
		for _, child := range attr.Value.Group() {
			target = appendField(target, append(groups, attr.Key), child)
		}
		return target
	}
	key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
	return append(target, field{key: key, value: attributeValue(attr.Value)})
}

// fieldRank puts the fields that identify *who and where* first, in the same order on
// every line, so scanning a column answers "which TV, which user" without reading.
// Anything unranked keeps the order the caller wrote it in, which is usually the order
// that reads best for that particular event.
var fieldRank = map[string]int{
	"component": 1,
	"user":      2,
	"device":    3,
	"client":    4,
	"protocol":  5,
	"method":    6,
	"path":      7,
	"status":    8,
	"duration":  9,
	// Constant per process, so it belongs at the end of the line rather than in front
	// of the fields that differ between events.
	"version": 900,
	// An error is the reason for the line; keeping it last means it is never buried
	// between routine fields.
	"error": 1000,
}

func orderFields(fields []field) {
	rank := func(f field) int {
		if r, ok := fieldRank[f.key]; ok {
			return r
		}
		return 100
	}
	slices.SortStableFunc(fields, func(a, b field) int { return rank(a) - rank(b) })
}

func pad(value string, width int) string {
	if len(value) >= width {
		return value
	}
	return value + strings.Repeat(" ", width-len(value))
}

// quote only intervenes when a bare value would be ambiguous. Quoting everything would
// double the punctuation on a line whose whole purpose is to be read.
func quote(value string) string {
	if value == "" {
		return `""`
	}
	if strings.ContainsAny(value, " \t\"=\n") {
		return strconv.Quote(value)
	}
	return value
}

func attributeValue(value slog.Value) string {
	switch value.Kind() {
	case slog.KindDuration:
		return value.Duration().String()
	case slog.KindTime:
		return value.Time().UTC().Format(time.RFC3339)
	case slog.KindInt64:
		return strconv.FormatInt(value.Int64(), 10)
	default:
		return value.String()
	}
}
