// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
// Licensed under the GNU Affero General Public License (AGPL).
// See License.AGPL.txt in the project root for license information.

package db

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"time"

	"github.com/gitpod-io/gitpod/common-go/log"

	"github.com/google/uuid"
	"gorm.io/datatypes"
	"gorm.io/gorm"
)

type WorkspaceInstance struct {
	ID                 uuid.UUID      `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`
	WorkspaceID        string         `gorm:"column:workspaceId;type:char;size:36;" json:"workspaceId"`
	Configuration      datatypes.JSON `gorm:"column:configuration;type:text;size:65535;" json:"configuration"`
	Region             string         `gorm:"column:region;type:varchar;size:255;" json:"region"`
	ImageBuildInfo     sql.NullString `gorm:"column:imageBuildInfo;type:text;size:65535;" json:"imageBuildInfo"`
	IdeURL             string         `gorm:"column:ideUrl;type:varchar;size:255;" json:"ideUrl"`
	WorkspaceBaseImage string         `gorm:"column:workspaceBaseImage;type:varchar;size:255;" json:"workspaceBaseImage"`
	WorkspaceImage     string         `gorm:"column:workspaceImage;type:varchar;size:255;" json:"workspaceImage"`
	UsageAttributionID AttributionID  `gorm:"column:usageAttributionId;type:varchar;size:60;" json:"usageAttributionId"`
	WorkspaceClass     string         `gorm:"column:workspaceClass;type:varchar;size:255;" json:"workspaceClass"`

	CreationTime VarcharTime `gorm:"column:creationTime;type:varchar;size:255;" json:"creationTime"`
	StartedTime  VarcharTime `gorm:"column:startedTime;type:varchar;size:255;" json:"startedTime"`
	DeployedTime VarcharTime `gorm:"column:deployedTime;type:varchar;size:255;" json:"deployedTime"`
	StoppedTime  VarcharTime `gorm:"column:stoppedTime;type:varchar;size:255;" json:"stoppedTime"`
	LastModified time.Time   `gorm:"column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`
	StoppingTime VarcharTime `gorm:"column:stoppingTime;type:varchar;size:255;" json:"stoppingTime"`

	LastHeartbeat string         `gorm:"column:lastHeartbeat;type:varchar;size:255;" json:"lastHeartbeat"`
	StatusOld     sql.NullString `gorm:"column:status_old;type:varchar;size:255;" json:"status_old"`
	Status        datatypes.JSON `gorm:"column:status;type:json;" json:"status"`
	// Phase is derived from Status by extracting JSON from it. Read-only (-> property).
	Phase          sql.NullString `gorm:"->:column:phase;type:char;size:32;" json:"phase"`
	PhasePersisted string         `gorm:"column:phasePersisted;type:char;size:32;" json:"phasePersisted"`

	// deleted is restricted for use by db-sync
	_ bool `gorm:"column:deleted;type:tinyint;default:0;" json:"deleted"`
}

// TableName sets the insert table name for this struct type
func (i *WorkspaceInstance) TableName() string {
	return "d_b_workspace_instance"
}

// FindStoppedWorkspaceInstancesInRange finds WorkspaceInstanceForUsage that have been stopped between from (inclusive) and to (exclusive).
func FindStoppedWorkspaceInstancesInRange(ctx context.Context, conn *gorm.DB, from, to time.Time) ([]WorkspaceInstanceForUsage, error) {
	var instances []WorkspaceInstanceForUsage
	var instancesInBatch []WorkspaceInstanceForUsage

	tx := queryWorkspaceInstanceForUsage(ctx, conn).
		Where("wsi.stoppingTime >= ?", TimeToISO8601(from)).
		Where("wsi.stoppingTime < ?", TimeToISO8601(to)).
		Where("wsi.stoppingTime != ?", "").
		Where("wsi.usageAttributionId != ?", "").
		FindInBatches(&instancesInBatch, 1000, func(_ *gorm.DB, _ int) error {
			instances = append(instances, instancesInBatch...)
			return nil
		})
	if tx.Error != nil {
		return nil, fmt.Errorf("failed to find workspace instances: %w", tx.Error)
	}

	return instances, nil
}

// FindRunningWorkspaceInstances finds WorkspaceInstanceForUsage that are running at the point in time the querty is executed.
func FindRunningWorkspaceInstances(ctx context.Context, conn *gorm.DB) ([]WorkspaceInstanceForUsage, error) {
	var instances []WorkspaceInstanceForUsage
	var instancesInBatch []WorkspaceInstanceForUsage

	tx := queryWorkspaceInstanceForUsage(ctx, conn).
		Where("wsi.stoppingTime = ?", "").
		Where("wsi.usageAttributionId != ?", "").
		// We cannot guarantee data quality before this date
		Where("wsi.startedTime > ?", TimeToISO8601(time.Date(2022, 8, 1, 0, 0, 0, 0, time.UTC))).
		FindInBatches(&instancesInBatch, 1000, func(_ *gorm.DB, _ int) error {
			instances = append(instances, instancesInBatch...)
			return nil
		})
	if tx.Error != nil {
		return nil, fmt.Errorf("failed to find running workspace instances: %w", tx.Error)
	}

	return instances, nil
}

// FindWorkspaceInstancesByIds finds WorkspaceInstanceForUsage by Id.
func FindWorkspaceInstancesByIds(ctx context.Context, conn *gorm.DB, workspaceInstanceIds []uuid.UUID) ([]WorkspaceInstanceForUsage, error) {
	var instances []WorkspaceInstanceForUsage
	var instancesInBatch []WorkspaceInstanceForUsage
	var idChunks [][]uuid.UUID
	chunkSize, totalSize := 1000, len(workspaceInstanceIds)
	// explicit batching to reduce the lengths of the 'in'-part in the SELECT statement below
	for i := 0; i < totalSize; i += chunkSize {
		end := i + chunkSize
		if end > totalSize {
			end = totalSize
		}
		idChunks = append(idChunks, workspaceInstanceIds[i:end])
	}

	for _, idChunk := range idChunks {
		err := queryWorkspaceInstanceForUsage(ctx, conn).
			Where("wsi.id in ?", idChunk).
			Where("wsi.usageAttributionId != ?", "").
			Find(&instancesInBatch).Error
		if err != nil {
			return nil, fmt.Errorf("failed to find workspace instances by id: %w", err)
		}
		instances = append(instances, instancesInBatch...)
	}

	return instances, nil
}

func queryWorkspaceInstanceForUsage(ctx context.Context, conn *gorm.DB) *gorm.DB {
	return conn.WithContext(ctx).
		Table(fmt.Sprintf("%s as wsi", (&WorkspaceInstance{}).TableName())).
		Select("wsi.id as id, "+
			"ws.projectId as projectId, "+
			"ws.contextUrl as contextUrl, "+
			"ws.type as workspaceType, "+
			"wsi.workspaceClass as workspaceClass, "+
			"wsi.usageAttributionId as usageAttributionId, "+
			"wsi.startedTime as startedTime, "+
			"wsi.stoppingTime as stoppingTime, "+
			"wsi.stoppedTime as stoppedTime, "+
			"ws.ownerId as ownerId, "+
			"wsi.workspaceId as workspaceId, "+
			"ws.ownerId as userId, "+
			"u.name as userName, "+
			"u.avatarURL as userAvatarURL ",
		).
		Joins(fmt.Sprintf("LEFT JOIN %s AS ws ON wsi.workspaceId = ws.id", (&Workspace{}).TableName())).
		Joins(fmt.Sprintf("LEFT JOIN %s AS u ON ws.ownerId = u.id", "d_b_user")).
		// Instances without a StartedTime never actually started, we're not interested in these.
		Where("wsi.startedTime != ?", "")
}

const (
	AttributionEntity_User = "user"
	AttributionEntity_Team = "team"
)

func newAttributionID(entity, identifier string) AttributionID {
	return AttributionID(fmt.Sprintf("%s:%s", entity, identifier))
}

func NewUserAttributionID(userID string) AttributionID {
	return newAttributionID(AttributionEntity_User, userID)
}

func NewTeamAttributionID(teamID string) AttributionID {
	return newAttributionID(AttributionEntity_Team, teamID)
}

// AttributionID consists of an entity, and an identifier in the form:
// <entity>:<identifier>, e.g. team:a7dcf253-f05e-4dcf-9a47-cf8fccc74717
type AttributionID string

func (a AttributionID) Values() (entity string, identifier string) {
	tokens := strings.Split(string(a), ":")
	if len(tokens) != 2 {
		return "", ""
	}

	return tokens[0], tokens[1]
}

func (a AttributionID) IsEntity(entity string) bool {
	e, _ := a.Values()
	return e == entity
}

func ParseAttributionID(s string) (AttributionID, error) {
	tokens := strings.Split(s, ":")
	if len(tokens) != 2 {
		return "", fmt.Errorf("attribution ID (%s) does not have two parts", s)
	}
	_, err := uuid.Parse(tokens[1])
	if err != nil {
		return "", fmt.Errorf("The uuid part of attribution ID (%s) is not a valid UUID. %w", tokens[1], err)
	}

	switch tokens[0] {
	case AttributionEntity_Team:
		return NewTeamAttributionID(tokens[1]), nil
	case AttributionEntity_User:
		return NewUserAttributionID(tokens[1]), nil
	default:
		return "", fmt.Errorf("unknown attribution ID type: %s", s)
	}
}

const (
	WorkspaceClass_Default = "default"
)

type WorkspaceInstanceForUsage struct {
	ID                 uuid.UUID      `gorm:"column:id;type:char;size:36;" json:"id"`
	WorkspaceID        string         `gorm:"column:workspaceId;type:char;size:36;" json:"workspaceId"`
	OwnerID            uuid.UUID      `gorm:"column:ownerId;type:char;size:36;" json:"ownerId"`
	ProjectID          sql.NullString `gorm:"column:projectId;type:char;size:36;" json:"projectId"`
	WorkspaceClass     string         `gorm:"column:workspaceClass;type:varchar;size:255;" json:"workspaceClass"`
	Type               WorkspaceType  `gorm:"column:workspaceType;type:char;size:16;default:regular;" json:"workspaceType"`
	UsageAttributionID AttributionID  `gorm:"column:usageAttributionId;type:varchar;size:60;" json:"usageAttributionId"`
	ContextURL         string         `gorm:"column:contextUrl;type:varchar;size:255;" json:"contextUrl"`
	UserID             uuid.UUID      `gorm:"column:userId;type:varchar;size:255;" json:"userId"`
	UserName           string         `gorm:"column:userName;type:varchar;size:255;" json:"userName"`
	UserAvatarURL      string         `gorm:"column:userAvatarURL;type:varchar;size:255;" json:"userAvatarURL"`

	StartedTime  VarcharTime `gorm:"column:startedTime;type:varchar;size:255;" json:"startedTime"`
	StoppingTime VarcharTime `gorm:"column:stoppingTime;type:varchar;size:255;" json:"stoppingTime"`
	StoppedTime  VarcharTime `gorm:"column:stoppedTime;type:varchar;size:255;" json:"stoppedTime"`
}

// WorkspaceRuntimeSeconds computes how long this WorkspaceInstance has been running.
// If the instance is still running (no stopping time set), maxStopTime is used to to compute the duration - this is an upper bound on stop
func (i *WorkspaceInstanceForUsage) WorkspaceRuntimeSeconds(stopTimeIfInstanceIsStillRunning time.Time) int64 {
	start := i.StartedTime.Time()
	stop := stopTimeIfInstanceIsStillRunning

	if i.StoppingTime.IsSet() {
		stop = i.StoppingTime.Time()
	} else if i.StoppedTime.IsSet() {
		stop = i.StoppedTime.Time()
	}

	if stop.Before(start) {
		log.
			WithField("instance_id", i.ID).
			WithField("workspace_id", i.WorkspaceID).
			WithField("started_time", TimeToISO8601(i.StartedTime.Time())).
			WithField("started_time_set", i.StartedTime.IsSet()).
			WithField("stopping_time_set", i.StoppingTime.IsSet()).
			WithField("stopping_time", TimeToISO8601(i.StoppingTime.Time())).
			WithField("stopped_time_set", i.StoppedTime.IsSet()).
			WithField("stopped_time", TimeToISO8601(i.StoppedTime.Time())).
			WithField("stop_time_if_instance_still_running", stopTimeIfInstanceIsStillRunning).
			Errorf("Instance %s had stop time before start time. Using startedTime as stop time.", i.ID)

		stop = start
	}

	return int64(stop.Sub(start).Round(time.Second).Seconds())
}

func ListWorkspaceInstanceIDsWithPhaseStoppedButNoStoppingTime(ctx context.Context, conn *gorm.DB) ([]uuid.UUID, error) {
	var ids []uuid.UUID
	//var chunk []uuid.UUID

	tx := conn.WithContext(ctx).
		Table(fmt.Sprintf("%s as wsi", (&WorkspaceInstance{}).TableName())).
		Joins(fmt.Sprintf("LEFT JOIN %s AS u ON wsi.id = u.id", (&Usage{}).TableName())).
		Where("wsi.phasePersisted = ?", "stopped").
		Where("wsi.stoppingTime = ''"). // empty
		Pluck("wsi.id", &ids)
	if tx.Error != nil {
		return nil, fmt.Errorf("failed to list workspace instances with phase stopped but no stopping time: %w", tx.Error)
	}
	return ids, nil
}
