package projectmetadata

import (
	"context"
	"strconv"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/client"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
	"github.com/golang/protobuf/proto"
	xnetcontext "golang.org/x/net/context"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"

	"code.justin.tv/common/config"
	"code.justin.tv/dta/rockpaperscissors/internal/sandstorm"
	pb "code.justin.tv/dta/rockpaperscissors/proto"
)

var (
	errf = grpc.Errorf

	// ErrProjectIDNotSet is returned when project_id isn't given when
	// requesting project metadata.
	ErrProjectIDNotSet = errf(codes.InvalidArgument, "No project_id was given.")
	// ErrProjectMetadataNotSet is returned when project_metadata isn't set when
	// trying to update.
	ErrProjectMetadataNotSet = errf(
		codes.InvalidArgument, "Required field project_metadata wasn't set.")

	// ErrProjectNotFound if returned when the given project ID isn't in the datastore.
	ErrProjectNotFound = errf(codes.NotFound, "Project not found.")

	// dependency injection used for tests.
	timeNow = func() time.Time { return time.Now() }
)

type gitHubFileGetterIface interface {
	GetFileFromGitHub(context.Context, string, string, string) ([]byte, error)
}

// Server is a gRPC service server implementing ProjectMetadataServiceServer.
type Server struct {
	DynamoDBSvc      dynamodbiface.DynamoDBAPI
	DynamoDBTable    string
	GitHubFileGetter gitHubFileGetterIface
}

type projectRecord struct {
	ProjectID       string `dynamodbav:"project_id,omitempty"`
	ProjectName     string `dynamodbav:"project_name,omitempty"`
	ProjectMetadata []byte `dynamodbav:"project_metadata,omitempty"`
	TeamName        string `dynamodbav:"team_name,omitempty"`
	OrgName         string `dynamodbav:"org_name,omitempty"`
	Archived        bool   `dynamodbav:"archived,omitempty"`
}

func init() {
	config.Register(map[string]string{
		"enable-projects-datastore-writes": "true",
	})
}

// NewServer constructs a projectmetadata gRPC Server.
func NewServer(sess client.ConfigProvider, table string) *Server {
	return &Server{
		DynamoDBSvc:   dynamodb.New(sess),
		DynamoDBTable: table,
		GitHubFileGetter: &gitHubFileGetter{
			SandstormClient: sandstorm.New(sess),
		},
	}
}

func (s *Server) listProjectRecords(includeArchived bool, teamName *string, orgName *string) ([]*projectRecord, error) {
	params := &dynamodb.ScanInput{
		TableName:            aws.String(s.DynamoDBTable),
		ProjectionExpression: aws.String("project_id, project_name, team_name, org_name"),
	}

	filterExpressions := make([]string, 0, 2)
	expressionAttributeValues := make(map[string]*dynamodb.AttributeValue)
	if !includeArchived {
		filterExpressions = append(filterExpressions, "archived <> :true")
		av, err := dynamodbattribute.Marshal(true)
		if err != nil {
			return nil, errf(
				codes.Internal, "Error marshaling DynamoDB filter expressions: %v", err)
		}
		expressionAttributeValues[":true"] = av
	}
	if teamName != nil {
		filterExpressions = append(filterExpressions, "team_name = :team_name")
		av, err := dynamodbattribute.Marshal(*teamName)
		if err != nil {
			return nil, errf(
				codes.Internal, "Error marshaling DynamoDB filter expressions: %v", err)
		}
		expressionAttributeValues[":team_name"] = av
	}
	if orgName != nil {
		filterExpressions = append(filterExpressions, "org_name = :org_name")
		av, err := dynamodbattribute.Marshal(*orgName)
		if err != nil {
			return nil, errf(
				codes.Internal, "Error marshaling DynamoDB filter expressions: %v", err)
		}
		expressionAttributeValues[":org_name"] = av
	}
	if len(filterExpressions) > 0 {
		params.FilterExpression = aws.String(
			strings.Join(filterExpressions, " AND "))
	}
	if len(expressionAttributeValues) > 0 {
		params.ExpressionAttributeValues = expressionAttributeValues
	}

	scanResponse, err := s.DynamoDBSvc.Scan(params)
	if err != nil {
		return nil, errf(codes.Internal, "Failed to scan DynamoDB table: %v", err)
	}

	records := make([]*projectRecord, *scanResponse.Count)
	for i, item := range scanResponse.Items {
		record := &projectRecord{}
		err = dynamodbattribute.UnmarshalMap(item, record)
		if err != nil {
			return nil, errf(
				codes.Internal, "Error unmarshaling DynamoDB record: %v", err)
		}
		records[i] = record
	}

	return records, nil
}

// ListProjects returns a list of project ids/names.
func (s *Server) ListProjects(ctx xnetcontext.Context, req *pb.ListProjectsRequest) (*pb.ListProjectsResponse, error) {
	records, err := s.listProjectRecords(req.IncludeArchived, nil, nil)
	if err != nil {
		return nil, err
	}

	listResponse := &pb.ListProjectsResponse{
		Projects: make([]*pb.ProjectMetadata, len(records)),
	}
	for i, record := range records {
		// TODO: check project_id and project_name attributes exist for each item?
		metadata := &pb.ProjectMetadata{
			ProjectId:   proto.String(record.ProjectID),
			ProjectName: proto.String(record.ProjectName),
		}
		if len(record.TeamName) > 0 {
			metadata.TeamName = proto.String(record.TeamName)
		}
		if len(record.OrgName) > 0 {
			metadata.OrgName = proto.String(record.OrgName)
		}
		listResponse.Projects[i] = metadata
	}

	return listResponse, nil
}

func itemKey(projectID string) (map[string]*dynamodb.AttributeValue, error) {
	avMap, err := dynamodbattribute.MarshalMap(&projectRecord{
		ProjectID: projectID,
	})
	if err != nil {
		return nil, errf(
			codes.Internal, "Error marshaling project_id to datastore key: %v", err)
	}
	return avMap, nil
}

// GetMetadata returns ProjectMetadata by uuid or id.
func (s *Server) GetMetadata(ctx xnetcontext.Context, req *pb.GetMetadataRequest) (*pb.GetMetadataResponse, error) {
	if len(req.ProjectId) == 0 {
		return nil, ErrProjectIDNotSet
	}

	var avMap map[string]*dynamodb.AttributeValue
	var err error
	if avMap, err = itemKey(req.GetProjectId()); err != nil {
		return nil, err
	}

	resp, err := s.DynamoDBSvc.GetItem(&dynamodb.GetItemInput{
		TableName:            aws.String(s.DynamoDBTable),
		Key:                  avMap,
		ProjectionExpression: aws.String("project_id, project_metadata"),
	})
	if err != nil {
		return nil, errf(
			codes.Internal, "Failed to GetItem from DynamoDB table: %v", err)
	}

	if len(resp.Item) == 0 {
		return nil, ErrProjectNotFound
	}

	record := &projectRecord{}
	err = dynamodbattribute.UnmarshalMap(resp.Item, record)
	if err != nil {
		return nil, errf(
			codes.Internal, "Error unmarshaling DynamoDB record: %v", err)
	}

	projectMetadata := &pb.ProjectMetadata{}
	err = proto.Unmarshal(record.ProjectMetadata, projectMetadata)
	if err != nil {
		return nil, errf(
			codes.Internal, "Error unmarshaling DynamoDB item: %v", err)
	}

	return &pb.GetMetadataResponse{Project: projectMetadata}, nil
}

// Convert Go Time type into Unix epoch secs + fractional seconds as float64.
func convertTimestamp(timestamp time.Time) float64 {
	return float64(timestamp.UnixNano()) * 1E-9
}

// UpdateMetadata updates ProjectMetadata in the datastore.
func (s *Server) UpdateMetadata(ctx xnetcontext.Context, req *pb.UpdateMetadataRequest) (*pb.UpdateMetadataResponse, error) {
	if req.GetProjectMetadata() == nil {
		return nil, ErrProjectMetadataNotSet
	}

	projectID := req.GetProjectId()
	if projectID == "" {
		projectID = req.GetProjectMetadata().GetProjectId()
	}

	var projectMetadata *pb.ProjectMetadata
	if req.GetMask() != nil && len(req.GetMask().GetPaths()) > 0 {
		response, err := s.GetMetadata(ctx, &pb.GetMetadataRequest{ProjectId: projectID})
		if err != nil {
			return nil, err
		}
		projectMetadata = response.GetProject()

		err = pb.MaskedMerge(projectMetadata, req.GetProjectMetadata(), req.GetMask())
		if err != nil {
			return nil, errf(codes.InvalidArgument, "Error updating with mask: %v", err)
		}
	} else {
		projectMetadata = req.GetProjectMetadata()
	}

	projectMetadata.LastUpdateTimestamp = proto.Float64(
		convertTimestamp(timeNow()))

	data, err := proto.Marshal(projectMetadata)
	if err != nil {
		return nil, errf(codes.Internal, "Error marshaling item: %v", err)
	}

	record := &projectRecord{
		ProjectID:       projectMetadata.GetProjectId(),
		ProjectName:     projectMetadata.GetProjectName(),
		ProjectMetadata: data,
		TeamName:        projectMetadata.GetTeamName(),
		OrgName:         projectMetadata.GetOrgName(),
		Archived:        projectMetadata.GetArchived(),
	}
	avMap, err := dynamodbattribute.MarshalMap(&record)
	if err != nil {
		return nil, errf(
			codes.Internal, "Error marshaling DynamoDB record: %v", err)
	}

	enableProjectsDatastoreWrites, err := strconv.ParseBool(
		config.MustResolve("enable-projects-datastore-writes"))
	if err != nil {
		return nil, errf(codes.Internal,
			"Can't parse enable-projects-datastore-writes config: %v", err)
	}

	if enableProjectsDatastoreWrites {
		_, err = s.DynamoDBSvc.PutItem(&dynamodb.PutItemInput{
			Item:      avMap,
			TableName: aws.String(s.DynamoDBTable),
		})
		if err != nil {
			return nil, errf(codes.Internal, "Failed to put item: %v", err)
		}

		if projectID != projectMetadata.GetProjectId() {
			var avMap map[string]*dynamodb.AttributeValue
			var err error
			if avMap, err = itemKey(projectID); err != nil {
				return nil, err
			}
			_, err = s.DynamoDBSvc.DeleteItem(&dynamodb.DeleteItemInput{
				Key:       avMap,
				TableName: aws.String(s.DynamoDBTable),
			})
			if err != nil {
				return nil, errf(codes.Internal, "Failed to delete item: %v", err)
			}
		}
	}

	return &pb.UpdateMetadataResponse{}, nil
}
