package api

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
	"github.com/gin-gonic/gin"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

func registerWorkflowRoutes(g *gin.RouterGroup) {
	g.GET("/steps", listSteps)
	g.POST("/steps", createStep)
	g.PUT("/steps/:id", updateStep)
	g.DELETE("/steps/:id", deleteStep)
	g.GET("/steps/:id/export", exportStep)
	g.POST("/steps/import", importStep)
	g.POST("/steps/seed-defaults", seedDefaults)
	g.GET("/steps/usage", stepUsage)
	g.POST("/steps/parse", parseStep)

	g.GET("/workflows", listWorkflows)
	g.POST("/workflows", createWorkflow)
	g.GET("/workflows/:id", getWorkflow)
	g.PUT("/workflows/:id", updateWorkflow)
	g.DELETE("/workflows/:id", deleteWorkflow)
	g.POST("/workflows/:id/run", runWorkflow)
	g.GET("/workflows/:id/runs", listWorkflowRuns)
	g.PUT("/workflows/:id/schedule", putWorkflowSchedule)
	g.GET("/workflows/:id/schedule/preview", previewWorkflowSchedule)

	g.GET("/runs/:runId", getRun)
	g.POST("/runs/:runId/cancel", cancelRun)
	g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
	g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
}

var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)

func getServerRunLog(c *gin.Context) {
	runID, serverID := c.Param("runId"), c.Param("serverId")
	if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return
	}
	if !services.HasServerRunLog(runID, serverID) {
		c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
		return
	}

	c.Header("Content-Type", "text/plain; charset=utf-8")
	c.Status(http.StatusOK)

	// Streamed in pages rather than read whole. A log capped at 200k lines is
	// tens of megabytes, and holding that in memory per concurrent download is
	// how one curious user takes a pod down.
	var after int64
	for {
		lines, last, err := services.ReadServerRunLog(runID, serverID, after, logPageSize)
		if err != nil || len(lines) == 0 {
			return
		}
		for _, l := range lines {
			_, _ = c.Writer.WriteString(l)
			_, _ = c.Writer.WriteString("\n")
		}
		c.Writer.Flush()
		after = last
	}
}

// logPageSize bounds one read of the log store. Large enough that a normal log
// is one or two queries, small enough that no single response buffers much.
const logPageSize = 2000

func streamServerRunLog(c *gin.Context) {
	runID, serverID := c.Param("runId"), c.Param("serverId")
	if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
		return
	}
	c.Writer.Header().Set("Content-Type", "text/event-stream")
	c.Writer.Header().Set("Cache-Control", "no-cache")
	c.Writer.Header().Set("Connection", "keep-alive")
	c.Writer.Header().Set("X-Accel-Buffering", "no")

	flusher, ok := c.Writer.(http.Flusher)
	if !ok {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "stream unsupported"})
		return
	}

	// The cursor is a sequence number now, not a byte offset: lines come from
	// the log store, which any pod can read, rather than from a file only this
	// one has.
	var after int64
	sendNew := func() {
		for {
			lines, last, err := services.ReadServerRunLog(runID, serverID, after, logPageSize)
			if err != nil || len(lines) == 0 {
				return
			}
			after = last
			for _, line := range lines {
				for _, part := range splitSSE([]byte(line)) {
					_, _ = c.Writer.WriteString("data: " + part + "\n")
				}
				_, _ = c.Writer.WriteString("\n")
			}
			flusher.Flush()
			if len(lines) < logPageSize {
				return
			}
		}
	}

	ctx := c.Request.Context()
	ticker := time.NewTicker(500 * time.Millisecond)
	defer ticker.Stop()
	instanceID := auth.InstanceID(c)
	for {
		sendNew()
		if serverRunTerminal(instanceID, runID, serverID) {
			sendNew()
			_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
			flusher.Flush()
			return
		}
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
		}
	}
}

func serverRunTerminal(instanceID, runID, serverID string) bool {
	r, err := services.GetRun(instanceID, runID)
	if err != nil {
		return true
	}
	for _, sr := range r.ServerRuns {
		if sr.ServerID == serverID {
			switch sr.Status {
			case "success", "failed", "skipped", "cancelled":
				return true
			}
			return false
		}
	}
	return true
}

func splitSSE(b []byte) []string {
	s := strings.ReplaceAll(string(b), "\r", "")
	return strings.Split(s, "\n")
}

func listSteps(c *gin.Context) {
	steps, err := services.ListSteps(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, steps)
}

func stepUsage(c *gin.Context) {
	counts, err := services.StepUsageCounts(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, counts)
}

func createStep(c *gin.Context) {
	var s models.WorkflowStep
	if err := c.ShouldBindJSON(&s); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	out, err := services.CreateStep(auth.InstanceID(c), s)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
	c.JSON(http.StatusCreated, out)
}

func updateStep(c *gin.Context) {
	var s models.WorkflowStep
	if err := c.ShouldBindJSON(&s); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil {
		if errors.Is(err, services.ErrDefaultStep) {
			c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
	c.JSON(http.StatusOK, gin.H{"updated": true})
}

func deleteStep(c *gin.Context) {
	if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
		if errors.Is(err, services.ErrDefaultStep) {
			c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
	c.JSON(http.StatusOK, gin.H{"deleted": true})
}

func exportStep(c *gin.Context) {
	b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}
	c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=step-%s.json", c.Param("id")))
	c.Data(http.StatusOK, "application/json", b)
}

func seedDefaults(c *gin.Context) {
	created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
	c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
}

const maxStepBodyBytes = 1 << 20

func importStep(c *gin.Context) {
	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
	body, err := io.ReadAll(c.Request.Body)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	out, err := services.ImportStepToLibrary(auth.InstanceID(c), body)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
	c.JSON(http.StatusCreated, out)
}

func parseStep(c *gin.Context) {
	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
	body, err := io.ReadAll(c.Request.Body)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	s, err := services.ParseStepDoc(body)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, s)
}

func listWorkflows(c *gin.Context) {
	wfs, err := services.ListWorkflows(auth.InstanceID(c))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, wfs)
}

func createWorkflow(c *gin.Context) {
	var w models.Workflow
	if err := c.ShouldBindJSON(&w); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	out, err := services.CreateWorkflow(auth.InstanceID(c), w)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
	c.JSON(http.StatusCreated, out)
}

func getWorkflow(c *gin.Context) {
	w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, w)
}

func updateWorkflow(c *gin.Context) {
	var w models.Workflow
	if err := c.ShouldBindJSON(&w); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
	updated, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, updated)
}

func deleteWorkflow(c *gin.Context) {
	if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
	c.JSON(http.StatusOK, gin.H{"deleted": true})
}

func runWorkflow(c *gin.Context) {
	runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
	if err != nil {
		if errors.Is(err, services.ErrNoTargets) {
			c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
			return
		}
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
	c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
}

func listWorkflowRuns(c *gin.Context) {
	limit := int64(50)
	if l := c.Query("limit"); l != "" {
		if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
			limit = n
		}
	}
	runs, err := services.ListRuns(auth.InstanceID(c), c.Param("id"), limit)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, runs)
}

func getRun(c *gin.Context) {
	r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
	if err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, r)
}

func cancelRun(c *gin.Context) {
	if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
	c.JSON(http.StatusOK, gin.H{"cancelled": true})
}

func putWorkflowSchedule(c *gin.Context) {
	var body models.Schedule
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
		return
	}

	instanceID := auth.InstanceID(c)
	next, err := services.SetSchedule(instanceID, c.Param("id"), &body)
	if err != nil {
		if errors.Is(err, workflowsched.ErrBadSchedule) {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		if errors.Is(err, mongo.ErrNoDocuments) {
			c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	services.LogEvent(instanceID, "workflow.schedule_updated", actorFromCtx(c), "", c.Param("id"),
		fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled))
	c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next})
}

// previewWorkflowSchedule exists so the browser and the scheduler agree on
// what a cron string means. A client-side cron parser that disagrees with the
// server by one field is a bug found in production, at night.
func previewWorkflowSchedule(c *gin.Context) {
	expr := c.Query("cron")
	tz := c.Query("tz")

	occurrences := make([]time.Time, 0, 3)
	from := time.Now()
	for i := 0; i < 3; i++ {
		next, err := workflowsched.NextOccurrence(expr, tz, from)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		occurrences = append(occurrences, next)
		from = next
	}
	c.JSON(http.StatusOK, gin.H{"occurrences": occurrences})
}
