package validator

import (
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"sync"
	"time"
)

// SessionRecordSchema versions the on-wire JSON shape. Bump when the
// schema changes incompatibly. Consumers (e.g. duckdb queries over
// coordinator_sessions.jsonl, scrum review tooling) check this field
// to decide whether they understand the row.
const SessionRecordSchema = "session.iterate.v1"

// SessionRecord is one row in coordinator_sessions.jsonl. Captures the
// full retry chain of a single /v1/iterate session for offline
// forensics: "show me all sessions where the validator caught a
// phantom worker" / "show me all sessions where retrieval missed."
//
// The Langfuse trace tree (see X-Lakehouse-Trace-Id propagation
// 2026-05-02) is the live view; this JSONL is the longitudinal view
// for ad-hoc DuckDB queries over thousands of sessions.
type SessionRecord struct {
	Schema           string                 `json:"schema"`
	SessionID        string                 `json:"session_id"` // = Langfuse trace_id
	Timestamp        string                 `json:"timestamp"`  // ISO 8601
	Daemon           string                 `json:"daemon"`
	Kind             string                 `json:"kind"`     // fill | email | playbook
	Model            string                 `json:"model"`
	Provider         string                 `json:"provider"`
	Prompt           string                 `json:"prompt"` // truncated to 4000
	Iterations       int                    `json:"iterations"`
	MaxIterations    int                    `json:"max_iterations"`
	FinalVerdict     string                 `json:"final_verdict"` // accepted | max_iter_exhausted | infra_error
	Attempts         []SessionAttemptRecord `json:"attempts"`
	Artifact         map[string]any         `json:"artifact,omitempty"` // present on success
	GroundedInRoster *bool                  `json:"grounded_in_roster,omitempty"`
	DurationMs       int64                  `json:"duration_ms"`
}

// SessionAttemptRecord mirrors IterateAttempt but stores only the
// stable signals (iteration, verdict, error, span id). The raw model
// output is intentionally NOT captured here — it lives in the
// Langfuse span (queryable by span_id) and the iterate response. Two
// copies would let the JSONL grow unbounded on long sessions.
type SessionAttemptRecord struct {
	Iteration   int    `json:"iteration"`
	VerdictKind string `json:"verdict_kind"` // no_json | validation_failed | accepted
	Error       string `json:"error,omitempty"`
	SpanID      string `json:"span_id,omitempty"`
}

// SessionLogger appends SessionRecord rows to a JSONL file. Best-effort:
// errors are logged via slog and never returned to the caller (per the
// rest of the observability stack — never block a request because the
// session log is unhappy).
//
// nil is a valid value: NewSessionLogger("") returns nil; Append on
// a nil receiver is a no-op. Lets validatord skip the wiring entirely
// when no session log is configured.
type SessionLogger struct {
	path string
	mu   sync.Mutex
}

// NewSessionLogger constructs a logger writing to `path`. Empty path
// disables logging. Creates parent dirs lazily on first write so the
// constructor doesn't panic when the path is on a not-yet-mounted
// volume (e.g. systemd unit ordering).
func NewSessionLogger(path string) *SessionLogger {
	if path == "" {
		return nil
	}
	return &SessionLogger{path: path}
}

// Append writes one JSONL row. Returns nil on success or after a
// best-effort failure. The caller doesn't need to handle errors;
// logging is an observability witness, not a correctness gate.
func (l *SessionLogger) Append(rec SessionRecord) {
	if l == nil {
		return
	}
	if rec.Schema == "" {
		rec.Schema = SessionRecordSchema
	}
	if rec.Timestamp == "" {
		rec.Timestamp = time.Now().UTC().Format(time.RFC3339Nano)
	}
	if rec.Daemon == "" {
		rec.Daemon = "validatord"
	}
	body, err := json.Marshal(rec)
	if err != nil {
		slog.Warn("session_log: marshal", "err", err, "session_id", rec.SessionID)
		return
	}
	body = append(body, '\n')

	l.mu.Lock()
	defer l.mu.Unlock()

	if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
		slog.Warn("session_log: mkdir", "err", err, "path", l.path)
		return
	}
	f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		slog.Warn("session_log: open", "err", err, "path", l.path)
		return
	}
	defer f.Close()
	if _, err := f.Write(body); err != nil {
		slog.Warn("session_log: write", "err", err, "session_id", rec.SessionID)
	}
}

// BuildSessionRecord assembles a SessionRecord from an iterate
// response/failure pair. Exactly one of resp/fail must be non-nil
// (or both nil for an infrastructure failure). Centralized here so
// validatord and any future iterate-using daemon emit the same shape.
//
// rosterCheck is called per accepted artifact to populate
// GroundedInRoster — set to nil to skip the check (e.g. for
// non-fill kinds that don't have worker IDs). Returns the shaped
// record; pass to logger.Append on the caller side so failed
// daemons don't block on their own observability layer.
func BuildSessionRecord(
	req IterateRequest,
	resp *IterateResponse,
	fail *IterateFailure,
	infraErr error,
	rosterCheck func(map[string]any) *bool,
	durationMs int64,
) SessionRecord {
	rec := SessionRecord{
		SessionID:     req.TraceID,
		Kind:          req.Kind,
		Model:         req.Model,
		Provider:      req.Provider,
		Prompt:        trim(req.Prompt, 4000),
		MaxIterations: req.MaxIterations,
		DurationMs:    durationMs,
	}

	switch {
	case resp != nil:
		rec.Iterations = resp.Iterations
		rec.FinalVerdict = "accepted"
		rec.Attempts = sessionAttemptsFromHistory(resp.History)
		rec.Artifact = resp.Artifact
		if rosterCheck != nil {
			rec.GroundedInRoster = rosterCheck(resp.Artifact)
		}
		// Keep the trace id authoritative even if the request didn't
		// supply one — the iterate response carries the resolved id.
		if rec.SessionID == "" {
			rec.SessionID = resp.TraceID
		}
	case fail != nil:
		rec.Iterations = fail.Iterations
		rec.FinalVerdict = "max_iter_exhausted"
		rec.Attempts = sessionAttemptsFromHistory(fail.History)
		if rec.SessionID == "" {
			rec.SessionID = fail.TraceID
		}
	default:
		// Infrastructure failure (chat hop crashed mid-loop, etc.).
		// We still emit a row so the failure is forensically visible —
		// otherwise long debugging sessions get harder.
		rec.FinalVerdict = "infra_error"
		if infraErr != nil {
			rec.Attempts = []SessionAttemptRecord{{
				Iteration:   0,
				VerdictKind: "infra_error",
				Error:       trim(fmt.Sprintf("%v", infraErr), 800),
			}}
		}
	}

	return rec
}

func sessionAttemptsFromHistory(h []IterateAttempt) []SessionAttemptRecord {
	out := make([]SessionAttemptRecord, len(h))
	for i, a := range h {
		out[i] = SessionAttemptRecord{
			Iteration:   a.Iteration,
			VerdictKind: a.Status.Kind,
			Error:       a.Status.Error,
			SpanID:      a.SpanID,
		}
	}
	return out
}
