package pipelines

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/config"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/middleware"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/oauth"
	"tangled.org/core/appview/pages"
	"tangled.org/core/appview/reporesolver"
	"tangled.org/core/eventconsumer"
	"tangled.org/core/idresolver"
	"tangled.org/core/orm"
	"tangled.org/core/rbac"
	spindlemodel "tangled.org/core/spindle/models"

	"github.com/go-chi/chi/v5"
	"github.com/gorilla/websocket"
)

type Pipelines struct {
	repoResolver  *reporesolver.RepoResolver
	idResolver    *idresolver.Resolver
	config        *config.Config
	oauth         *oauth.OAuth
	pages         *pages.Pages
	spindlestream *eventconsumer.Consumer
	db            *db.DB
	enforcer      *rbac.Enforcer
	logger        *slog.Logger
}

func (p *Pipelines) Router(mw *middleware.Middleware) http.Handler {
	r := chi.NewRouter()
	r.Get("/", p.Index)
	r.Get("/{pipeline}/workflow/{workflow}", p.Workflow)
	r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs)
	r.
		With(mw.RepoPermissionMiddleware("repo:owner")).
		Post("/{pipeline}/workflow/{workflow}/cancel", p.Cancel)

	return r
}

func New(
	oauth *oauth.OAuth,
	repoResolver *reporesolver.RepoResolver,
	pages *pages.Pages,
	spindlestream *eventconsumer.Consumer,
	idResolver *idresolver.Resolver,
	db *db.DB,
	config *config.Config,
	enforcer *rbac.Enforcer,
	logger *slog.Logger,
) *Pipelines {
	return &Pipelines{
		oauth:         oauth,
		repoResolver:  repoResolver,
		pages:         pages,
		idResolver:    idResolver,
		config:        config,
		spindlestream: spindlestream,
		db:            db,
		enforcer:      enforcer,
		logger:        logger,
	}
}

func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) {
	user := p.oauth.GetMultiAccountUser(r)
	l := p.logger.With("handler", "Index")

	f, err := p.repoResolver.Resolve(r)
	if err != nil {
		l.Error("failed to get repo and knot", "err", err)
		return
	}

	filterKind := r.URL.Query().Get("trigger")
	filters := []orm.Filter{
		orm.FilterEq("p.repo_did", f.RepoDid),
	}
	switch filterKind {
	case "push":
		filters = append(filters, orm.FilterEq("t.kind", "push"))
	case "pull_request":
		filters = append(filters, orm.FilterEq("t.kind", "pull_request"))
	default:
		// no filters otherwise, default to "all"
		filterKind = "all"
	}

	ps, err := db.GetPipelineStatuses(
		p.db,
		30,
		filters...,
	)
	if err != nil {
		l.Error("failed to query db", "err", err)
		return
	}

	total, err := db.GetTotalPipelineStatuses(p.db, filters...)
	if err != nil {
		l.Error("failed to query db", "err", err)
		return
	}

	p.pages.Pipelines(w, pages.PipelinesParams{
		LoggedInUser: user,
		RepoInfo:     p.repoResolver.GetRepoInfo(r, user),
		Pipelines:    ps,
		FilterKind:   filterKind,
		Total:        total,
	})
}

func (p *Pipelines) Workflow(w http.ResponseWriter, r *http.Request) {
	user := p.oauth.GetMultiAccountUser(r)
	l := p.logger.With("handler", "Workflow")

	f, err := p.repoResolver.Resolve(r)
	if err != nil {
		l.Error("failed to get repo and knot", "err", err)
		return
	}

	pipelineId := chi.URLParam(r, "pipeline")
	if pipelineId == "" {
		l.Error("empty pipeline ID")
		return
	}

	workflow := chi.URLParam(r, "workflow")
	if workflow == "" {
		l.Error("empty workflow name")
		return
	}

	ps, err := db.GetPipelineStatuses(
		p.db,
		1,
		orm.FilterEq("p.repo_did", f.RepoDid),
		orm.FilterEq("p.id", pipelineId),
	)
	if err != nil {
		l.Error("failed to query db", "err", err)
		return
	}

	if len(ps) != 1 {
		l.Error("invalid number of pipelines", "len", len(ps))
		return
	}

	singlePipeline := ps[0]

	p.pages.Workflow(w, pages.WorkflowParams{
		LoggedInUser: user,
		RepoInfo:     p.repoResolver.GetRepoInfo(r, user),
		Pipeline:     singlePipeline,
		Workflow:     workflow,
	})
}

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
}

func (p *Pipelines) Logs(w http.ResponseWriter, r *http.Request) {
	l := p.logger.With("handler", "logs")

	clientConn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		l.Error("websocket upgrade failed", "err", err)
		return
	}
	defer func() {
		_ = clientConn.WriteControl(
			websocket.CloseMessage,
			websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"),
			time.Now().Add(time.Second),
		)
		clientConn.Close()
	}()

	ctx, cancel := context.WithCancel(r.Context())
	defer cancel()

	f, err := p.repoResolver.Resolve(r)
	if err != nil {
		l.Error("failed to get repo and knot", "err", err)
		http.Error(w, "bad repo/knot", http.StatusBadRequest)
		return
	}

	pipelineId := chi.URLParam(r, "pipeline")
	workflow := chi.URLParam(r, "workflow")
	if pipelineId == "" || workflow == "" {
		http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest)
		return
	}

	ps, err := db.GetPipelineStatuses(
		p.db,
		1,
		orm.FilterEq("p.repo_did", f.RepoDid),
		orm.FilterEq("p.id", pipelineId),
	)
	if err != nil || len(ps) != 1 {
		l.Error("pipeline query failed", "err", err, "count", len(ps))
		http.Error(w, "pipeline not found", http.StatusNotFound)
		return
	}

	singlePipeline := ps[0]
	spindle := f.Spindle
	knot := f.Knot
	rkey := singlePipeline.Rkey

	if spindle == "" || knot == "" || rkey == "" {
		http.Error(w, "invalid repo info", http.StatusBadRequest)
		return
	}

	scheme := "wss"
	if p.config.Core.Dev {
		scheme = "ws"
	}

	url := scheme + "://" + strings.Join([]string{spindle, "logs", knot, rkey, workflow}, "/")
	l = l.With("url", url)
	l.Info("logs endpoint hit")

	spindleConn, _, err := websocket.DefaultDialer.Dial(url, nil)
	if err != nil {
		l.Error("websocket dial failed", "err", err)
		http.Error(w, "failed to connect to log stream", http.StatusBadGateway)
		return
	}
	defer spindleConn.Close()

	// create a channel for incoming messages
	evChan := make(chan logEvent, 100)
	// start a goroutine to read from spindle
	go readLogs(spindleConn, evChan)

	stepStartTimes := make(map[int]time.Time)
	var fragment bytes.Buffer
	for {
		select {
		case <-ctx.Done():
			l.Info("client disconnected")
			return

		case ev, ok := <-evChan:
			if !ok {
				continue
			}

			if ev.err != nil && ev.isCloseError() {
				l.Debug("graceful shutdown, tail complete", "err", err)
				return
			}
			if ev.err != nil {
				l.Error("error reading from spindle", "err", err)
				return
			}

			var logLine spindlemodel.LogLine
			if err = json.Unmarshal(ev.msg, &logLine); err != nil {
				l.Error("failed to parse logline", "err", err)
				continue
			}

			fragment.Reset()

			switch logLine.Kind {
			case spindlemodel.LogKindControl:
				switch logLine.StepStatus {
				case spindlemodel.StepStatusStart:
					stepStartTimes[logLine.StepId] = logLine.Time
					collapsed := false
					if logLine.StepKind == spindlemodel.StepKindSystem {
						collapsed = true
					}
					err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
						Id:        logLine.StepId,
						Name:      logLine.Content,
						Command:   logLine.StepCommand,
						Collapsed: collapsed,
						StartTime: logLine.Time,
					})
				case spindlemodel.StepStatusEnd:
					startTime := stepStartTimes[logLine.StepId]
					endTime := logLine.Time
					err = p.pages.LogBlockEnd(&fragment, pages.LogBlockEndParams{
						Id:        logLine.StepId,
						StartTime: startTime,
						EndTime:   endTime,
					})
				}

			case spindlemodel.LogKindData:
				// data messages simply insert new log lines into current step
				err = p.pages.LogLine(&fragment, pages.LogLineParams{
					Id:      logLine.StepId,
					Content: logLine.Content,
				})
			}
			if err != nil {
				l.Error("failed to render log line", "err", err)
				return
			}

			if err = clientConn.WriteMessage(websocket.TextMessage, fragment.Bytes()); err != nil {
				l.Error("error writing to client", "err", err)
				return
			}

		case <-time.After(30 * time.Second):
			l.Debug("sent keepalive")
			if err = clientConn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
				l.Error("failed to write control", "err", err)
				return
			}
		}
	}
}

func (p *Pipelines) Cancel(w http.ResponseWriter, r *http.Request) {
	l := p.logger.With("handler", "Cancel")

	var (
		pipelineId = chi.URLParam(r, "pipeline")
		workflow   = chi.URLParam(r, "workflow")
	)
	if pipelineId == "" || workflow == "" {
		http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest)
		return
	}

	f, err := p.repoResolver.Resolve(r)
	if err != nil {
		l.Error("failed to get repo and knot", "err", err)
		http.Error(w, "bad repo/knot", http.StatusBadRequest)
		return
	}

	pipeline, err := func() (models.Pipeline, error) {
		ps, err := db.GetPipelineStatuses(
			p.db,
			1,
			orm.FilterEq("p.repo_did", f.RepoDid),
			orm.FilterEq("p.id", pipelineId),
		)
		if err != nil {
			return models.Pipeline{}, err
		}
		if len(ps) != 1 {
			return models.Pipeline{}, fmt.Errorf("wrong pipeline count %d", len(ps))
		}
		return ps[0], nil
	}()
	if err != nil {
		l.Error("pipeline query failed", "err", err)
		http.Error(w, "pipeline not found", http.StatusNotFound)
	}
	var (
		spindle = f.Spindle
		knot    = f.Knot
		rkey    = pipeline.Rkey
	)

	if spindle == "" || knot == "" || rkey == "" {
		http.Error(w, "invalid repo info", http.StatusBadRequest)
		return
	}

	spindleClient, err := p.oauth.ServiceClient(
		r,
		oauth.WithService(f.Spindle),
		oauth.WithLxm(tangled.PipelineCancelPipelineNSID),
		oauth.WithDev(p.config.Core.Dev),
		oauth.WithTimeout(time.Second*30), // workflow cleanup usually takes time
	)

	err = tangled.PipelineCancelPipeline(
		r.Context(),
		spindleClient,
		&tangled.PipelineCancelPipeline_Input{
			Repo:     string(f.RepoAt()),
			Pipeline: pipeline.AtUri().String(),
			Workflow: workflow,
		},
	)
	errorId := "workflow-error"
	if err != nil {
		l.Error("failed to cancel workflow", "err", err)
		p.pages.Notice(w, errorId, "Failed to cancel workflow")
		return
	}
	l.Debug("canceled pipeline", "uri", pipeline.AtUri())
}

// either a message or an error
type logEvent struct {
	msg []byte
	err error
}

func (ev *logEvent) isCloseError() bool {
	return websocket.IsCloseError(
		ev.err,
		websocket.CloseNormalClosure,
		websocket.CloseGoingAway,
		websocket.CloseAbnormalClosure,
	)
}

// read logs from spindle and pass through to chan
func readLogs(conn *websocket.Conn, ch chan logEvent) {
	defer close(ch)

	for {
		if conn == nil {
			return
		}

		_, msg, err := conn.ReadMessage()
		if err != nil {
			ch <- logEvent{err: err}
			return
		}
		ch <- logEvent{msg: msg}
	}
}
