// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.

package database

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/harness/gitness/internal/paths"
	"github.com/harness/gitness/internal/store"
	"github.com/harness/gitness/internal/store/database/dbtx"
	"github.com/harness/gitness/types"
	"github.com/harness/gitness/types/enum"

	"github.com/Masterminds/squirrel"
	"github.com/guregu/null"
	"github.com/jmoiron/sqlx"
	"github.com/pkg/errors"
)

var _ store.PathStore = (*PathStore)(nil)

// NewSpaceStore returns a new PathStore.
func NewPathStore(db *sqlx.DB, pathTransformation store.PathTransformation) *PathStore {
	return &PathStore{
		db:                 db,
		pathTransformation: pathTransformation,
	}
}

// PathStore implements a store.PathStore backed by a relational database.
type PathStore struct {
	db                 *sqlx.DB
	pathTransformation store.PathTransformation
}

// path is an internal representation used to store path data in DB.
type path struct {
	ID      int64 `db:"path_id"`
	Version int64 `db:"path_version"`
	// Value is the original path that was provided
	Value string `db:"path_value"`
	// ValueUnique is a transformed version of Value which is used to ensure uniqueness guarantees
	ValueUnique string `db:"path_value_unique"`
	// IsPrimary indicates whether the path is the primary path of the repo/space
	// IMPORTANT: to allow DB enforcement of at most one primary path per repo/space
	// we have a unique index on repoID|spaceID + IsPrimary and set IsPrimary to true
	// for primary paths and to nil for non-primary paths.
	IsPrimary null.Bool `db:"path_is_primary"`
	SpaceID   null.Int  `db:"path_space_id"`
	RepoID    null.Int  `db:"path_repo_id"`
	CreatedBy int64     `db:"path_created_by"`
	Created   int64     `db:"path_created"`
	Updated   int64     `db:"path_updated"`
}

const (
	pathColumns = `
		path_id
		,path_version
		,path_value
		,path_value_unique
		,path_is_primary
		,path_space_id
		,path_repo_id
		,path_created_by
		,path_created
		,path_updated`

	pathSelectBase = `
		SELECT` + pathColumns + `
		FROM paths`
)

// Create creates a new path.
func (s *PathStore) Create(ctx context.Context, path *types.Path) error {
	const sqlQuery = `
		INSERT INTO paths (
			path_version
			,path_value
			,path_value_unique
			,path_is_primary
			,path_space_id
			,path_repo_id
			,path_created_by
			,path_created
			,path_updated
		) values (
			:path_version
			,:path_value
			,:path_value_unique
			,:path_is_primary
			,:path_space_id
			,:path_repo_id
			,:path_created_by
			,:path_created
			,:path_updated
		) RETURNING path_id`

	// map to internal path
	dbPath, err := s.mapToInternalPath(path)
	if err != nil {
		return fmt.Errorf("failed to map path: %w", err)
	}

	db := dbtx.GetAccessor(ctx, s.db)

	query, arg, err := db.BindNamed(sqlQuery, dbPath)
	if err != nil {
		return processSQLErrorf(err, "Failed to bind path object")
	}

	if err = db.QueryRowContext(ctx, query, arg...).Scan(&path.ID); err != nil {
		return processSQLErrorf(err, "Insert query failed")
	}

	return nil
}

// findInner finds the path for the given id and locks the record if requested.
func (s *PathStore) findInner(ctx context.Context, id int64, lock bool) (*types.Path, error) {
	sqlQuery := pathSelectBase + `
		WHERE path_id = $1`

	if lock && !strings.HasPrefix(s.db.DriverName(), "sqlite") {
		sqlQuery += "\n" + sqlForUpdate
	}

	db := dbtx.GetAccessor(ctx, s.db)

	dst := new(path)
	err := db.GetContext(ctx, dst, sqlQuery, id)
	if err != nil {
		return nil, processSQLErrorf(err, "Failed to find path")
	}

	res, err := mapToPath(dst)
	if err != nil {
		return nil, fmt.Errorf("failed to map path to external type: %w", err)
	}

	return res, nil
}

// Find finds the path for the given id.
func (s *PathStore) Find(ctx context.Context, id int64) (*types.Path, error) {
	return s.findInner(ctx, id, false)
}

// FindWithLock finds the path for the given id and locks the entry.
func (s *PathStore) FindWithLock(ctx context.Context, id int64) (*types.Path, error) {
	return s.findInner(ctx, id, true)
}

// FindValue finds the path for the given value.
func (s *PathStore) FindValue(ctx context.Context, value string) (*types.Path, error) {
	const sqlQuery = pathSelectBase + `
		WHERE path_value_unique = $1`

	// map the Value to unique Value before searching!
	valueUnique, err := s.pathTransformation(value)
	if err != nil {
		return nil, fmt.Errorf("failed to transform path '%s': %w", value, err)
	}

	db := dbtx.GetAccessor(ctx, s.db)

	dst := new(path)
	err = db.GetContext(ctx, dst, sqlQuery, valueUnique)
	if err != nil {
		return nil, processSQLErrorf(err, "Failed to find path")
	}

	res, err := mapToPath(dst)
	if err != nil {
		return nil, fmt.Errorf("failed to map path to external type: %w", err)
	}

	return res, nil
}

// findPrimaryInternal finds the  primary path for the given target and locks the record if requested.
func (s *PathStore) findPrimaryInternal(ctx context.Context,
	targetType enum.PathTargetType, targetID int64, lock bool) (*types.Path, error) {
	stmt := builder.
		Select(pathColumns).
		From("paths").
		Where("path_is_primary = ?", true)

	stmt, err := wherePathTarget(stmt, targetType, targetID)
	if err != nil {
		return nil, err
	}

	if lock && !strings.HasPrefix(s.db.DriverName(), "sqlite") {
		stmt.Suffix(sqlForUpdate)
	}

	sqlQuery, args, err := stmt.ToSql()
	if err != nil {
		return nil, fmt.Errorf("failed to convert query to sql: %w", err)
	}

	db := dbtx.GetAccessor(ctx, s.db)

	// NOTE: there is at most one primary path (so no list needed)
	dst := new(path)
	err = db.GetContext(ctx, dst, sqlQuery, args...)
	if err != nil {
		return nil, processSQLErrorf(err, "Failed to find path")
	}

	res, err := mapToPath(dst)
	if err != nil {
		return nil, fmt.Errorf("failed to map path to external type: %w", err)
	}

	return res, nil
}

// FindPrimary finds the primary path for a target.
func (s *PathStore) FindPrimary(ctx context.Context,
	targetType enum.PathTargetType, targetID int64) (*types.Path, error) {
	return s.findPrimaryInternal(ctx, targetType, targetID, false)
}

// FindPrimaryWithLock finds the primary path for a target and locks the db entry.
func (s *PathStore) FindPrimaryWithLock(ctx context.Context,
	targetType enum.PathTargetType, targetID int64) (*types.Path, error) {
	return s.findPrimaryInternal(ctx, targetType, targetID, true)
}

// Update updates an existing path.
func (s *PathStore) Update(ctx context.Context, path *types.Path) error {
	const sqlQuery = `
		UPDATE paths
		SET
			path_version 		= :path_version
			,path_updated 		= :path_updated
			,path_value 		= :path_value
			,path_value_unique 	= :path_value_unique
			,path_is_primary 	= :path_is_primary
		WHERE path_id = :path_id and path_version = :path_version - 1`

	dbPath, err := s.mapToInternalPath(path)
	if err != nil {
		return fmt.Errorf("failed to map path: %w", err)
	}

	dbPath.Version++
	dbPath.Updated = time.Now().UnixMilli()

	db := dbtx.GetAccessor(ctx, s.db)

	query, args, err := db.BindNamed(sqlQuery, dbPath)
	if err != nil {
		return processSQLErrorf(err, "Failed to bind path object")
	}

	result, err := db.ExecContext(ctx, query, args...)
	if err != nil {
		return processSQLErrorf(err, "failed to update path")
	}

	count, err := result.RowsAffected()
	if err != nil {
		return processSQLErrorf(err, "Failed to get number of updated rows")
	}

	if count == 0 {
		return store.ErrVersionConflict
	}

	path.Version = dbPath.Version
	path.Updated = dbPath.Updated

	return nil
}

// Delete deletes a specific path.
func (s *PathStore) Delete(ctx context.Context, id int64) error {
	const sqlQuery = `
		DELETE FROM paths
		WHERE path_id = $1`

	db := dbtx.GetAccessor(ctx, s.db)

	if _, err := db.ExecContext(ctx, sqlQuery, id); err != nil {
		return processSQLErrorf(err, "Delete query failed")
	}

	return nil
}

// Count returns the count of paths for a target.
func (s *PathStore) Count(ctx context.Context, targetType enum.PathTargetType, targetID int64,
	opts *types.PathFilter) (int64, error) {
	stmt := builder.
		Select("count(*)").
		From("paths")

	stmt, err := wherePathTarget(stmt, targetType, targetID)
	if err != nil {
		return 0, err
	}

	sqlQuery, args, err := stmt.ToSql()
	if err != nil {
		return 0, fmt.Errorf("failed to convert query to sql: %w", err)
	}

	db := dbtx.GetAccessor(ctx, s.db)

	var count int64
	err = db.QueryRowContext(ctx, sqlQuery, args...).Scan(&count)
	if err != nil {
		return 0, processSQLErrorf(err, "Failed executing count query")
	}
	return count, nil
}

// List lists all paths for a target.
func (s *PathStore) List(ctx context.Context, targetType enum.PathTargetType, targetID int64,
	opts *types.PathFilter) ([]*types.Path, error) {
	// else we construct the sql statement.
	stmt := builder.
		Select("*").
		From("paths")

	stmt, err := wherePathTarget(stmt, targetType, targetID)
	if err != nil {
		return nil, err
	}

	stmt = stmt.Limit(uint64(limit(opts.Size)))
	stmt = stmt.Offset(uint64(offset(opts.Page, opts.Size)))

	switch opts.Sort {
	case enum.PathAttrID, enum.PathAttrNone:
		// NOTE: string concatenation is safe because the
		// order attribute is an enum and is not user-defined,
		// and is therefore not subject to injection attacks.
		stmt = stmt.OrderBy("path_id " + opts.Order.String())
	case enum.PathAttrValue:
		stmt = stmt.OrderBy("path_value " + opts.Order.String())
	case enum.PathAttrCreated:
		stmt = stmt.OrderBy("path_created " + opts.Order.String())
	case enum.PathAttrUpdated:
		stmt = stmt.OrderBy("path_updated " + opts.Order.String())
	}

	sqlQuery, args, err := stmt.ToSql()
	if err != nil {
		return nil, errors.Wrap(err, "Failed to convert query to sql")
	}

	db := dbtx.GetAccessor(ctx, s.db)

	dst := []*path{}
	if err = db.SelectContext(ctx, &dst, sqlQuery, args...); err != nil {
		return nil, processSQLErrorf(err, "Path select query failed")
	}

	res, err := mapToPaths(dst)
	if err != nil {
		return nil, fmt.Errorf("failed to map paths to external type: %w", err)
	}

	return res, nil
}

// ListPrimaryDescendantsWithLock lists all primary paths that are descendants of the given path and locks them.
func (s *PathStore) ListPrimaryDescendantsWithLock(ctx context.Context, value string) ([]*types.Path, error) {
	var sqlQuery = pathSelectBase + `
		WHERE path_value_unique LIKE $1 AND path_is_primary = true`

	if !strings.HasPrefix(s.db.DriverName(), "sqlite") {
		sqlQuery += "\n" + sqlForUpdate
	}

	// map the Value to unique Value before searching!
	valueUnique, err := s.pathTransformation(value)
	if err != nil {
		return nil, fmt.Errorf("failed to transform path '%s': %w", value, err)
	}
	// prepare input for LIKE query (space1/space2 -> space1/space2/%)
	valueUniquePattern := paths.Concatinate(valueUnique, "%")

	db := dbtx.GetAccessor(ctx, s.db)

	dst := []*path{}
	if err = db.SelectContext(ctx, &dst, sqlQuery, valueUniquePattern); err != nil {
		return nil, processSQLErrorf(err, "Failed to list paths")
	}

	res, err := mapToPaths(dst)
	if err != nil {
		return nil, fmt.Errorf("failed to map paths to external type: %w", err)
	}

	return res, nil
}

// wherePathTarget adds a where statement for a path select statement filtering for the provided target.
func wherePathTarget(stmt squirrel.SelectBuilder,
	targetType enum.PathTargetType, targetID int64) (squirrel.SelectBuilder, error) {
	switch targetType {
	case enum.PathTargetTypeRepo:
		return stmt.Where("path_repo_id = ?", targetID), nil
	case enum.PathTargetTypeSpace:
		return stmt.Where("path_space_id = ?", targetID), nil
	default:
		return squirrel.SelectBuilder{}, fmt.Errorf("path target type '%s' is not supported", targetType)
	}
}

func mapToPath(p *path) (*types.Path, error) {
	res := &types.Path{
		ID:        p.ID,
		Version:   p.Version,
		Value:     p.Value,
		Created:   p.Created,
		CreatedBy: p.CreatedBy,
		Updated:   p.Updated,
	}

	if p.IsPrimary.Valid {
		res.IsPrimary = p.IsPrimary.Bool
	}

	switch {
	case p.RepoID.Valid && p.SpaceID.Valid:
		return nil, fmt.Errorf("both repoID and spaceID are set for path %d", p.ID)
	case p.RepoID.Valid:
		res.TargetType = enum.PathTargetTypeRepo
		res.TargetID = p.RepoID.Int64
	case p.SpaceID.Valid:
		res.TargetType = enum.PathTargetTypeSpace
		res.TargetID = p.SpaceID.Int64
	default:
		return nil, fmt.Errorf("neither repoID nor spaceID are set for path %d", p.ID)
	}

	return res, nil
}

func mapToPaths(paths []*path) ([]*types.Path, error) {
	var err error
	res := make([]*types.Path, len(paths))
	for i := range paths {
		res[i], err = mapToPath(paths[i])
		if err != nil {
			return nil, err
		}
	}
	return res, nil
}

func (s *PathStore) mapToInternalPath(p *types.Path) (*path, error) {
	// path comes from outside.
	if p == nil {
		return nil, fmt.Errorf("path is nil")
	}

	res := &path{
		ID:        p.ID,
		Version:   p.Version,
		Value:     p.Value,
		Created:   p.Created,
		CreatedBy: p.CreatedBy,
		Updated:   p.Updated,
	}

	var err error
	if res.ValueUnique, err = s.pathTransformation(p.Value); err != nil {
		return nil, fmt.Errorf("failed to transform path: %w", err)
	}

	// only set IsPrimary to a value if it's true (Unique Index doesn't allow multiple false, hence keep it nil)
	if p.IsPrimary {
		res.IsPrimary = null.BoolFrom(true)
	}

	switch p.TargetType {
	case enum.PathTargetTypeRepo:
		res.RepoID = null.IntFrom(p.TargetID)
	case enum.PathTargetTypeSpace:
		res.SpaceID = null.IntFrom(p.TargetID)
	default:
		return nil, fmt.Errorf("path target type '%s' is not supported", p.TargetType)
	}

	return res, nil
}
