package services

import (
	"bytes"
	"context"
	"log"
	"strings"
	"sync"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// Workflow run logs live in MongoDB, not on disk.
//
// They used to be a file per (run, server) under a volume. That is the cheapest
// possible writer, and it is wrong the moment there is more than one server
// process: step output arrives on whichever pod holds the agent's stream, the
// run's markers are written by whichever pod started the run, and the browser
// asks for the log through whichever pod the load balancer picked. Three pods,
// one file, one local disk — two of them see an empty log.
//
// Mongo makes every pod an equal reader and writer, which is the property that
// matters. It costs writes on the hot path, so the writer batches (see
// stepLogWriter) and both caps below exist to keep a runaway step from turning
// a workflow into a database incident.
const (
	// A single line longer than this is truncated. Base64 blobs and minified
	// output are the usual cause; nobody reads column 9000 of a log line.
	maxLogLineBytes = 8 * 1024

	// A single (run, server) log stops accepting lines here, with one final
	// marker saying so. 200k lines is far past what anyone reads and still
	// only a few tens of MB. Without a cap, `yes` in a step fills the database.
	maxLogLines = 200_000

	// The writer flushes on whichever comes first. Batching is what keeps a
	// chatty step to a handful of writes a second instead of one per line.
	logFlushLines    = 128
	logFlushInterval = 250 * time.Millisecond
)

const logLinesCol = "workflow_log_lines"
const logSeqCol = "workflow_log_seq"

func logCtx() (context.Context, context.CancelFunc) {
	return context.WithTimeout(context.Background(), 10*time.Second)
}

func logTS() string {
	return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
}

func seqID(runID, serverID string) string { return runID + "/" + serverID }

// reserveSeq atomically claims n consecutive sequence numbers for a
// (run, server) log and returns the first.
//
// The counter is a document rather than a per-process integer because two pods
// write the same log concurrently: the run's pod emits markers while the
// agent's pod emits step output. Ordering between them is only meaningful if
// they draw from the same counter.
func reserveSeq(ctx context.Context, runID, serverID string, n int) (int64, error) {
	var doc struct {
		Seq int64 `bson:"seq"`
	}
	err := db.Col(logSeqCol).FindOneAndUpdate(ctx,
		bson.M{"_id": seqID(runID, serverID)},
		bson.M{"$inc": bson.M{"seq": int64(n)}},
		options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
	).Decode(&doc)
	if err != nil {
		return 0, err
	}
	return doc.Seq - int64(n), nil
}

type logLine struct {
	RunID    string    `bson:"run_id"`
	ServerID string    `bson:"server_id"`
	Seq      int64     `bson:"seq"`
	At       time.Time `bson:"at"`
	Line     string    `bson:"line"`
}

// writeLines reserves a block of sequence numbers and inserts the batch.
func writeLines(ctx context.Context, runID, serverID string, lines []string) (int64, error) {
	if len(lines) == 0 {
		return 0, nil
	}
	start, err := reserveSeq(ctx, runID, serverID, len(lines))
	if err != nil {
		return 0, err
	}
	now := time.Now().UTC()
	docs := make([]any, 0, len(lines))
	for i, l := range lines {
		docs = append(docs, logLine{
			RunID:    runID,
			ServerID: serverID,
			Seq:      start + int64(i),
			At:       now,
			Line:     l,
		})
	}
	// Unordered: one rejected document must not discard the rest of the batch.
	_, err = db.Col(logLinesCol).InsertMany(ctx, docs, options.InsertMany().SetOrdered(false))
	return start, err
}

// AppendMarker writes one control line (step banners, retries, run outcome) and
// returns its sequence number, which is what a StepRun's log_offset records so
// the UI can scroll to where a step began.
func AppendMarker(runID, serverID, text string) (int64, error) {
	ctx, cancel := logCtx()
	defer cancel()
	return writeLines(ctx, runID, serverID, []string{"[" + logTS() + "] " + text})
}

// stepLogWriter accumulates one command's output, splits it into lines, masks
// secrets and flushes batches to Mongo.
type stepLogWriter struct {
	mu       sync.Mutex
	runID    string
	serverID string
	secrets  []string

	carry   []byte
	pending []string
	written int
	capped  bool

	stop chan struct{}
	done chan struct{}
}

type stepLogRegistry struct {
	mu      sync.Mutex
	writers map[string]*stepLogWriter
}

// The registry stays process-local, and correctly so: a step's output arrives
// on the pod holding that agent's stream, and that is the same pod the
// dispatch envelope asked to open the writer. Nothing here crosses pods —
// only the lines it produces do, by virtue of landing in Mongo.
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}

func (r *stepLogRegistry) Open(commandID, runID, serverID string, secrets []string) error {
	w := &stepLogWriter{
		runID:    runID,
		serverID: serverID,
		secrets:  secrets,
		stop:     make(chan struct{}),
		done:     make(chan struct{}),
	}
	go w.flushLoop()

	r.mu.Lock()
	if old := r.writers[commandID]; old != nil {
		// A retry reuses the command ID. Close the previous attempt's writer
		// rather than leaking its flush goroutine.
		r.mu.Unlock()
		old.close()
		r.mu.Lock()
	}
	r.writers[commandID] = w
	r.mu.Unlock()
	return nil
}

func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.writers[commandID]
}

func (r *stepLogRegistry) Append(commandID string, data []byte) {
	w := r.get(commandID)
	if w == nil {
		return
	}
	w.mu.Lock()
	buf := append(w.carry, data...)
	for {
		i := bytes.IndexByte(buf, '\n')
		if i < 0 {
			break
		}
		w.queueLine(buf[:i])
		buf = buf[i+1:]
	}
	w.carry = append([]byte{}, buf...)
	full := len(w.pending) >= logFlushLines
	w.mu.Unlock()

	if full {
		w.flush()
	}
}

func (r *stepLogRegistry) Close(commandID string) {
	r.mu.Lock()
	w := r.writers[commandID]
	delete(r.writers, commandID)
	r.mu.Unlock()
	if w == nil {
		return
	}
	w.close()
}

// queueLine masks, truncates and enqueues a line. Caller holds w.mu.
func (w *stepLogWriter) queueLine(line []byte) {
	if w.capped {
		return
	}
	if w.written+len(w.pending) >= maxLogLines {
		w.capped = true
		w.pending = append(w.pending, "["+logTS()+"] [vantage] log truncated: this step exceeded the per-server line limit")
		return
	}
	masked := maskBytes(line, w.secrets)
	if len(masked) > maxLogLineBytes {
		masked = append(masked[:maxLogLineBytes], []byte(" [truncated]")...)
	}
	w.pending = append(w.pending, "["+logTS()+"] "+string(masked))
}

func (w *stepLogWriter) flush() {
	w.mu.Lock()
	if len(w.pending) == 0 {
		w.mu.Unlock()
		return
	}
	batch := w.pending
	w.pending = nil
	w.written += len(batch)
	runID, serverID := w.runID, w.serverID
	w.mu.Unlock()

	ctx, cancel := logCtx()
	defer cancel()
	if _, err := writeLines(ctx, runID, serverID, batch); err != nil {
		// Dropped rather than retried. A log line is not worth stalling the
		// step it describes, and a Mongo that cannot take writes has larger
		// problems than a missing line.
		log.Printf("step log: write %d line(s) for run %s: %v", len(batch), runID, err)
	}
}

func (w *stepLogWriter) flushLoop() {
	defer close(w.done)
	t := time.NewTicker(logFlushInterval)
	defer t.Stop()
	for {
		select {
		case <-w.stop:
			return
		case <-t.C:
			w.flush()
		}
	}
}

func (w *stepLogWriter) close() {
	close(w.stop)
	<-w.done
	w.mu.Lock()
	if len(w.carry) > 0 {
		w.queueLine(w.carry)
		w.carry = nil
	}
	w.mu.Unlock()
	w.flush()
}

func maskBytes(b []byte, secrets []string) []byte {
	s := string(b)
	for _, v := range secrets {
		if v == "" {
			continue
		}
		s = strings.ReplaceAll(s, v, "***")
	}
	return []byte(s)
}

// ReadServerRunLog returns the log for one server in a run, from sequence
// number after onwards, along with the highest sequence returned.
//
// Callers page with it rather than fetching everything: the live stream asks
// repeatedly for what is new, and the plain-text endpoint walks the whole log
// in chunks so a very large one is never held in memory whole.
func ReadServerRunLog(runID, serverID string, after int64, limit int) ([]string, int64, error) {
	ctx, cancel := logCtx()
	defer cancel()

	cur, err := db.Col(logLinesCol).Find(ctx,
		bson.M{"run_id": runID, "server_id": serverID, "seq": bson.M{"$gt": after}},
		options.Find().SetSort(bson.D{{Key: "seq", Value: 1}}).SetLimit(int64(limit)),
	)
	if err != nil {
		return nil, after, err
	}
	defer cur.Close(ctx)

	lines := make([]string, 0, 64)
	last := after
	for cur.Next(ctx) {
		var l logLine
		if err := cur.Decode(&l); err != nil {
			return lines, last, err
		}
		lines = append(lines, l.Line)
		last = l.Seq
	}
	return lines, last, cur.Err()
}

// HasServerRunLog reports whether any line exists, so a log read can answer 404
// rather than 200 with an empty body.
func HasServerRunLog(runID, serverID string) bool {
	ctx, cancel := logCtx()
	defer cancel()
	err := db.Col(logLinesCol).FindOne(ctx, bson.M{"run_id": runID, "server_id": serverID}).Err()
	return err == nil
}

func StartLogSweeper(ctx context.Context) {
	go func() {
		sweepLogs()
		t := time.NewTicker(time.Hour)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				sweepLogs()
			}
		}
	}()
}

// sweepLogs deletes the lines of finished runs past their instance's retention.
//
// It walks workflow_runs rather than the log collection: retention is a
// property of the run (its instance, its finish time), and a run row is the
// only place both are recorded.
func sweepLogs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	cur, err := db.Col("workflow_runs").Find(ctx,
		bson.M{"finished_at": bson.M{"$ne": nil}},
		options.Find().SetProjection(bson.M{"run_id": 1, "instance_id": 1, "finished_at": 1}),
	)
	if err != nil {
		log.Printf("log sweep: list runs: %v", err)
		return
	}
	defer cur.Close(ctx)

	cache := map[string]int{}
	now := time.Now()

	for cur.Next(ctx) {
		var run struct {
			RunID      string     `bson:"run_id"`
			InstanceID string     `bson:"instance_id"`
			FinishedAt *time.Time `bson:"finished_at"`
		}
		if err := cur.Decode(&run); err != nil || run.FinishedAt == nil {
			continue
		}

		days, ok := cache[run.InstanceID]
		if !ok {
			days = defaultRetentionDays
			if run.InstanceID != "" {
				if v, err := GetWorkflowLogRetentionDays(run.InstanceID); err == nil {
					days = v
				}
			}
			cache[run.InstanceID] = days
		}
		if days <= 0 {
			continue
		}
		if !run.FinishedAt.Before(now.AddDate(0, 0, -days)) {
			continue
		}

		if _, err := db.Col(logLinesCol).DeleteMany(ctx, bson.M{"run_id": run.RunID}); err != nil {
			log.Printf("log sweep: delete lines for run %s: %v", run.RunID, err)
			continue
		}
		_, _ = db.Col(logSeqCol).DeleteMany(ctx, bson.M{"_id": bson.M{"$regex": "^" + run.RunID + "/"}})
	}
}

const defaultRetentionDays = 30

// EnsureLogIndexes builds the indexes the log store depends on. The compound
// index is not an optimisation: every read is a range scan over it, and without
// it a log read collection-scans every line in the database.
func EnsureLogIndexes() error {
	ctx, cancel := logCtx()
	defer cancel()
	if _, err := db.Col(logLinesCol).Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys: bson.D{{Key: "run_id", Value: 1}, {Key: "server_id", Value: 1}, {Key: "seq", Value: 1}},
	}); err != nil {
		return err
	}
	return nil
}
