package move

import (
	"encoding/base64"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"time"

	"github.com/cozy/cozy-stack/model/bitwarden/settings"
	"github.com/cozy/cozy-stack/model/instance"
	"github.com/cozy/cozy-stack/model/instance/lifecycle"
	"github.com/cozy/cozy-stack/model/job"
	"github.com/cozy/cozy-stack/model/move"
	"github.com/cozy/cozy-stack/model/oauth"
	"github.com/cozy/cozy-stack/model/permission"
	"github.com/cozy/cozy-stack/model/session"
	csettings "github.com/cozy/cozy-stack/model/settings"
	"github.com/cozy/cozy-stack/pkg/config/config"
	"github.com/cozy/cozy-stack/pkg/consts"
	"github.com/cozy/cozy-stack/pkg/couchdb"
	"github.com/cozy/cozy-stack/pkg/jsonapi"
	"github.com/cozy/cozy-stack/pkg/limits"
	"github.com/cozy/cozy-stack/pkg/mail"
	"github.com/cozy/cozy-stack/pkg/realtime"
	"github.com/cozy/cozy-stack/web/auth"
	"github.com/cozy/cozy-stack/web/middlewares"
	"github.com/gorilla/websocket"
	"github.com/labstack/echo/v4"
)

func createExport(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	err := config.GetRateLimiter().CheckRateLimit(inst, limits.ExportType)
	if limits.IsLimitReachedOrExceeded(err) {
		return echo.NewHTTPError(http.StatusNotFound, "Not found")
	}
	if err := middlewares.AllowWholeType(c, permission.POST, consts.Exports); err != nil {
		return err
	}

	var exportOptions move.ExportOptions
	if _, err := jsonapi.Bind(c.Request().Body, &exportOptions); err != nil {
		return err
	}
	// The contextual domain is used to send a link on the correct domain when
	// the user is accessing their cozy from a backup URL.
	exportOptions.ContextualDomain = inst.ContextualDomain()
	exportOptions.MoveTo = nil
	exportOptions.TokenSource = ""

	msg, err := job.NewMessage(exportOptions)
	if err != nil {
		return err
	}
	_, err = job.System().PushJob(inst, &job.JobRequest{
		WorkerType: "export",
		Message:    msg,
	})
	if err != nil {
		return err
	}
	return c.NoContent(http.StatusCreated)
}

func exportHandler(c echo.Context) error {
	mac, err := base64.URLEncoding.DecodeString(c.Param("export-mac"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err)
	}
	inst := middlewares.GetInstance(c)
	exportDoc, err := move.GetExport(inst, mac)
	if err != nil {
		return err
	}

	return jsonapi.Data(c, http.StatusOK, exportDoc, nil)
}

func exportDataHandler(c echo.Context) error {
	mac, err := base64.URLEncoding.DecodeString(c.Param("export-mac"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err)
	}
	inst := middlewares.GetInstance(c)
	exportDoc, err := move.GetExport(inst, mac)
	if err != nil {
		return err
	}

	cursor, err := move.ParseCursor(exportDoc, c.QueryParam("cursor"))
	if err != nil {
		return err
	}

	if !config.GetConfig().CSPDisabled {
		from := inst.SubDomain(consts.SettingsSlug).String()
		middlewares.AppendCSPRule(c, "frame-ancestors", from)
	}

	w := c.Response()
	w.Header().Set(echo.HeaderContentType, "application/zip")
	filename := "My Cozy.zip"
	if len(exportDoc.PartsCursors) > 0 {
		filename = fmt.Sprintf("My Cozy - part%03d.zip", cursor.Number)
	}
	w.Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
	w.WriteHeader(http.StatusOK)

	archiver := move.SystemArchiver()
	return move.ExportCopyData(w, inst, exportDoc, archiver, cursor)
}

func precheckImport(c echo.Context) error {
	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
		return err
	}

	var options move.ImportOptions
	if _, err := jsonapi.Bind(c.Request().Body, &options); err != nil {
		return err
	}

	inst := middlewares.GetInstance(c)
	if err := move.CheckImport(inst, options.SettingsURL); err != nil {
		return wrapError(err)
	}

	return c.NoContent(http.StatusNoContent)
}

func createImport(c echo.Context) error {
	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
		return err
	}

	var options move.ImportOptions
	if _, err := jsonapi.Bind(c.Request().Body, &options); err != nil {
		return err
	}

	inst := middlewares.GetInstance(c)
	if err := move.ScheduleImport(inst, options); err != nil {
		return c.Render(http.StatusInternalServerError, "error.html", echo.Map{
			"Domain":       inst.ContextualDomain(),
			"ContextName":  inst.ContextName,
			"Locale":       inst.Locale,
			"Title":        inst.TemplateTitle(),
			"Favicon":      middlewares.Favicon(inst),
			"Illustration": "/images/generic-error.svg",
			"Error":        err.Error(),
			"SupportEmail": inst.SupportEmailAddress(),
		})
	}

	to := inst.PageURL("/move/importing", nil)
	return c.Redirect(http.StatusSeeOther, to)
}

func blockForImport(c echo.Context) error {
	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
		return err
	}

	// Force the logout for all sessions before blocking the instance
	inst := middlewares.GetInstance(c)
	_ = session.DeleteOthers(inst, "")
	time.Sleep(100 * time.Millisecond)

	if source := c.QueryParam("source"); source != "" {
		doc, err := inst.SettingsDocument()
		if err != nil {
			return err
		}
		doc.SetID(consts.InstanceSettingsID)
		doc.M["moved_from"] = source
		if err := couchdb.UpdateDoc(inst, doc); err != nil {
			return err
		}
	}

	if err := lifecycle.Block(inst, instance.BlockedMoving.Code); err != nil {
		return err
	}
	return c.NoContent(http.StatusNoContent)
}

func waitImportHasFinished(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	template := "import.html"
	title := "Import Title"
	source := "?"
	if inst.BlockingReason == instance.BlockedMoving.Code {
		template = "move_in_progress.html"
		title = "Move in progress Title"
		doc, err := inst.SettingsDocument()
		if err == nil {
			if from, ok := doc.M["moved_from"].(string); ok {
				source = from
			}
		}
	}
	return c.Render(http.StatusOK, template, echo.Map{
		"Domain":      inst.ContextualDomain(),
		"ContextName": inst.ContextName,
		"Locale":      inst.Locale,
		"Title":       inst.Translate(title),
		"Favicon":     middlewares.Favicon(inst),
		"Source":      source,
	})
}

const (
	// Time allowed to write a message to the peer
	writeWait = 10 * time.Second

	// Time allowed to read the next pong message from the peer
	pongWait = 60 * time.Second

	// Send pings to peer with this period (must be less than pongWait)
	pingPeriod = (pongWait * 9) / 10
)

var upgrader = websocket.Upgrader{
	// Don't check the origin of the connexion
	CheckOrigin:     func(r *http.Request) bool { return true },
	Subprotocols:    []string{"io.cozy.websocket"},
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
}

func wsDone(ws *websocket.Conn, inst *instance.Instance) {
	redirect := inst.PageURL("/auth/login", nil)
	_ = ws.SetWriteDeadline(time.Now().Add(writeWait))
	_ = ws.WriteJSON(echo.Map{"redirect": redirect})
}

func wsImporting(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
	if err != nil {
		return err
	}
	defer ws.Close()

	if move.ImportIsFinished(inst) {
		wsDone(ws, inst)
		return nil
	}

	if err = ws.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
		return err
	}
	ws.SetPongHandler(func(string) error {
		return ws.SetReadDeadline(time.Now().Add(pongWait))
	})

	ticker := time.NewTicker(pingPeriod)
	defer ticker.Stop()
	ds := realtime.GetHub().Subscriber(inst)
	defer ds.Close()
	ds.Subscribe(consts.Jobs)

	for {
		select {
		case e := <-ds.Channel:
			doc, ok := e.Doc.(permission.Fetcher)
			if !ok {
				continue
			}
			worker := doc.Fetch("worker")
			state := doc.Fetch("state")
			if len(worker) != 1 || worker[0] != "import" || len(state) != 1 {
				continue
			}
			if s := job.State(state[0]); s != job.Done && s != job.Errored {
				continue
			}
			wsDone(ws, inst)
			return nil
		case <-ticker.C:
			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
				return err
			}
			if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
				return err
			}
		}
	}
}

func getAuthorizeCode(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	if !middlewares.IsLoggedIn(c) {
		u := inst.PageURL("/auth/login", url.Values{
			"redirect": {inst.FromURL(c.Request().URL)},
		})
		return c.Redirect(http.StatusSeeOther, u)
	}

	err := config.GetRateLimiter().CheckRateLimit(inst, limits.ExportType)
	if limits.IsLimitReachedOrExceeded(err) {
		return echo.NewHTTPError(http.StatusNotFound, "Not found")
	}

	u, err := url.Parse(c.QueryParam("redirect_uri"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "bad url: could not parse")
	}

	if u.Scheme != "http" && u.Scheme != "https" {
		return echo.NewHTTPError(http.StatusBadRequest, "bad url: bad scheme")
	}

	client := &oauth.Client{ClientID: move.SourceClientID}
	access, err := oauth.CreateAccessCode(inst, client, consts.ExportsRequests, "")
	if err != nil {
		return err
	}

	vault := settings.HasVault(inst)
	used, quota, err := auth.DiskInfo(inst.VFS())
	if err != nil {
		return err
	}

	q := u.Query()
	q.Set("state", c.QueryParam("state"))
	q.Set("code", access.Code)
	q.Set("vault", strconv.FormatBool(vault))
	q.Set("used", used)
	if quota != "" {
		q.Set("quota", quota)
	}
	u.RawQuery = q.Encode()
	u.Fragment = ""
	location := u.String() + "#"
	return c.Redirect(http.StatusSeeOther, location)
}

func initializeMove(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	if !middlewares.IsLoggedIn(c) {
		u := inst.PageURL("/auth/login", url.Values{
			"redirect": {inst.SubDomain(consts.SettingsSlug).String()},
		})
		return c.Redirect(http.StatusSeeOther, u)
	}

	err := config.GetRateLimiter().CheckRateLimit(inst, limits.ExportType)
	if limits.IsLimitReachedOrExceeded(err) {
		return echo.NewHTTPError(http.StatusNotFound, "Not found")
	}

	u, err := url.Parse(inst.MoveURL())
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "bad url: could not parse")
	}
	u.Path = "/initialize"

	vault := settings.HasVault(inst)
	used, quota, err := auth.DiskInfo(inst.VFS())
	if err != nil {
		return err
	}

	client, err := move.CreateRequestClient(inst)
	if err != nil {
		return err
	}
	access, err := oauth.CreateAccessCode(inst, client, move.MoveScope, "")
	if err != nil {
		return err
	}

	q := u.Query()
	q.Set("client_id", client.ClientID)
	q.Set("client_secret", client.ClientSecret)
	q.Set("code", access.Code)
	q.Set("vault", strconv.FormatBool(vault))
	q.Set("used", used)
	if quota != "" {
		q.Set("quota", quota)
	}
	q.Set("cozy_url", inst.PageURL("/", nil))
	u.RawQuery = q.Encode()
	return c.Redirect(http.StatusTemporaryRedirect, u.String())
}

func requestMove(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	var request *move.Request
	params, err := c.FormParams()
	if err == nil {
		request, err = move.CreateRequest(inst, params)
	}
	if err != nil {
		return c.Render(http.StatusBadRequest, "error.html", echo.Map{
			"Domain":       inst.ContextualDomain(),
			"ContextName":  inst.ContextName,
			"Locale":       inst.Locale,
			"Title":        inst.TemplateTitle(),
			"Favicon":      middlewares.Favicon(inst),
			"Illustration": "/images/generic-error.svg",
			"Error":        err.Error(),
			"SupportEmail": inst.SupportEmailAddress(),
		})
	}

	publicName, _ := csettings.PublicName(inst)
	mail := mail.Options{
		Mode:         mail.ModeFromStack,
		TemplateName: "move_confirm",
		TemplateValues: map[string]interface{}{
			"ConfirmLink": request.Link,
			"PublicName":  publicName,
			"Source":      inst.ContextualDomain(),
			"Target":      request.TargetHost(),
		},
	}
	msg, err := job.NewMessage(&mail)
	if err != nil {
		return err
	}
	_, err = job.System().PushJob(inst, &job.JobRequest{
		WorkerType: "sendmail",
		Message:    msg,
	})
	if err != nil {
		return err
	}

	email, _ := inst.SettingsEMail()
	return c.Render(http.StatusOK, "move_confirm.html", echo.Map{
		"Domain":      inst.ContextualDomain(),
		"ContextName": inst.ContextName,
		"Locale":      inst.Locale,
		"Title":       inst.Translate("Move Confirm Title"),
		"Favicon":     middlewares.Favicon(inst),
		"Email":       email,
	})
}

func startMove(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	if !middlewares.IsLoggedIn(c) {
		return echo.NewHTTPError(http.StatusUnauthorized, "You must be authenticated")
	}

	request, err := move.StartMove(inst, c.QueryParam("secret"))
	if err != nil {
		return c.Render(http.StatusBadRequest, "error.html", echo.Map{
			"Domain":       inst.ContextualDomain(),
			"ContextName":  inst.ContextName,
			"Locale":       inst.Locale,
			"Title":        inst.TemplateTitle(),
			"ThemeCSS":     middlewares.ThemeCSS(inst),
			"Favicon":      middlewares.Favicon(inst),
			"Illustration": "/images/generic-error.svg",
			"Error":        err.Error(),
			"SupportEmail": inst.SupportEmailAddress(),
		})
	}

	return c.Redirect(http.StatusSeeOther, request.ImportingURL())
}

func finalizeMove(c echo.Context) error {
	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
		return err
	}

	inst := middlewares.GetInstance(c)
	if err := move.Finalize(inst, c.QueryParam("subdomain")); err != nil {
		return err
	}
	return c.NoContent(http.StatusNoContent)
}

func abortMove(c echo.Context) error {
	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
		return err
	}

	inst := middlewares.GetInstance(c)
	if err := lifecycle.Unblock(inst); err != nil {
		return err
	}
	return c.NoContent(http.StatusNoContent)
}

func importVault(c echo.Context) error {
	inst := middlewares.GetInstance(c)
	if !middlewares.IsLoggedIn(c) {
		u := inst.PageURL("/auth/login", url.Values{
			"redirect": {inst.FromURL(c.Request().URL)},
		})
		return c.Redirect(http.StatusSeeOther, u)
	}

	doc, err := inst.SettingsDocument()
	if err != nil {
		return err
	}
	delete(doc.M, "import_vault")
	_ = couchdb.UpdateDoc(inst, doc)

	return c.Render(http.StatusOK, "move_vault.html", echo.Map{
		"Domain":      inst.ContextualDomain(),
		"ContextName": inst.ContextName,
		"Locale":      inst.Locale,
		"Title":       inst.Translate("Move Vault Title"),
		"Favicon":     middlewares.Favicon(inst),
		"Link":        inst.DefaultRedirection(),
	})
}

// Routes defines the routing layout for the /move module.
func Routes(g *echo.Group) {
	g.POST("/exports", createExport)
	g.GET("/exports/:export-mac", exportHandler)
	g.GET("/exports/data/:export-mac", exportDataHandler)

	g.POST("/imports/precheck", precheckImport)
	g.POST("/imports", createImport)

	g.POST("/importing", blockForImport)
	g.GET("/importing", waitImportHasFinished)
	g.GET("/importing/realtime", wsImporting)

	g.GET("/authorize", getAuthorizeCode)
	g.POST("/initialize", initializeMove)

	g.POST("/request", requestMove)
	g.GET("/go", startMove)
	g.POST("/finalize", finalizeMove)
	g.POST("/abort", abortMove)
	g.GET("/vault", importVault)
}

func wrapError(err error) error {
	switch err {
	case move.ErrExportNotFound:
		return jsonapi.PreconditionFailed("url", err)
	case move.ErrNotEnoughSpace:
		return jsonapi.Errorf(http.StatusRequestEntityTooLarge, "%s", err)
	}
	return err
}
