package repository

import (
	"database/sql"
	"errors"
	"fmt"
	"time"

	domain "github.com/haydenhargreaves/Potion/internal/domain/engagement"
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
)

type EngagementRepository struct {
	db *sqlx.DB
}

// Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository
var _ domain.EngagementRepository = (*EngagementRepository)(nil)

// NewUserRepository creates a user repository object which is used by the user service to access
// the database. Any user related database operations will take place in this repository.
func NewEngagementRepository(db *sqlx.DB) domain.EngagementRepository {
	return &EngagementRepository{db: db}
}

// AddUserEngagement creates an engagement record in the database with the user ID provided. This
// function does not accept an entity ID as it should be used when there is no need to reference
// an entity. The message should be provided, but a blank string ("") is acceptable. The engagement
// type parameter determines the labeling of the engagement in the database. Any errors will be
// bubbled to the caller.
func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
	tx, err := r.db.Begin()
	if err != nil {
		return domain.Engagement{}, err
	}

	query := `
		INSERT INTO Engagements (
			type, message, entity, userid, created
		) VALUES  (
			$1, $2, NULL, $3, $4
		) RETURNING *;
	`

	var engagement domain.Engagement
	var engUserId sql.NullInt32
	if err := tx.QueryRow(query, engagementType, message, userId, time.Now()).Scan(
		&engagement.Id,
		&engagement.Type,
		&engagement.Message,
		&engagement.Entity,
		&engUserId,
		&engagement.Created,
	); err != nil {
		tx.Rollback()
		return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
	}

	if err := tx.Commit(); err != nil {
		return domain.Engagement{}, err
	}

	// Is user is valid
	if engUserId.Valid {
		engagement.UserId = int(engUserId.Int32)
	}

	return engagement, nil
}

// AddUserEntityEngagement creates an engagement record in the database with the user ID provided. This
// function requires an entity ID as it should be used when there is a reference to external an
// entity. The message should be provided, but a blank string ("") is acceptable. The engagement
// type parameter determines the labeling of the engagement in the database. Any errors will be
// bubbled to the caller.
//
// TODO: Disallow users to "make" the same recipe more than once a day
func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
	tx, err := r.db.Begin()
	if err != nil {
		return domain.Engagement{}, err
	}

	query := `
		INSERT INTO Engagements (
			type, message, entity, userid, created
		) VALUES  (
			$1, $2, $3, $4, $5
		) RETURNING *;
	`

	var engagement domain.Engagement
	var engUserId sql.NullInt32
	if err := tx.QueryRow(query, engagementType, message, entityId, userId, time.Now()).Scan(
		&engagement.Id,
		&engagement.Type,
		&engagement.Message,
		&engagement.Entity,
		&engUserId,
		&engagement.Created,
	); err != nil {
		tx.Rollback()
		return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
	}

	if err := tx.Commit(); err != nil {
		return domain.Engagement{}, err
	}

	// Is user is valid
	if engUserId.Valid {
		engagement.UserId = int(engUserId.Int32)
	}

	return engagement, nil
}

// AddEngagement creates an engagement record in the database without any user. This function does
// not accept an entity ID as it should be used when there is no need to reference an entity or user.
// The message should be provided, but a blank string ("") is acceptable. The engagement type
// parameter determines the labeling of the engagement in the database. Any errors will be bubbled
// to the caller.
//
// List of allowed engagements: viewed, shared
func (r *EngagementRepository) AddEngagement(message string, engagementType domain.EngagementType) (domain.Engagement, error) {
	// Prevent invalid engagement types
	switch engagementType {
	case domain.EngagementViewed:
	case domain.EngagementShared:
		break
	case domain.EngagementMade:
	case domain.EngagementLiked:
	case domain.EngagementReviewed:
	case domain.EngagementRated:
		return domain.Engagement{}, fmt.Errorf("Attempting to use disallowed anonymous engagement type.")
	}

	tx, err := r.db.Begin()
	if err != nil {
		return domain.Engagement{}, err
	}

	query := `
		INSERT INTO Engagements (
			type, message, entity, userid, created
		) VALUES  (
			$1, $2, NULL, NULL, $3
		) RETURNING *;
	`

	var engagement domain.Engagement
	var userId sql.NullInt32
	if err := tx.QueryRow(query, engagementType, message, time.Now()).Scan(
		&engagement.Id,
		&engagement.Type,
		&engagement.Message,
		&engagement.Entity,
		&userId,
		&engagement.Created,
	); err != nil {
		tx.Rollback()
		return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
	}

	if err := tx.Commit(); err != nil {
		return domain.Engagement{}, err
	}

	// Is user is valid
	if userId.Valid {
		engagement.UserId = int(userId.Int32)
	}

	return engagement, nil
}

// AddEntityEngagement creates an engagement record in the database without any user. This function
// requires an entity ID as it should be used when there is a reference to external an entity.
// The message should be provided, but a blank string ("") is acceptable. The engagement type
// parameter determines the labeling of the engagement in the database. Any errors will be
// bubbled to the caller.
//
// List of allowed engagements: viewed, shared
func (r *EngagementRepository) AddEntityEngagement(entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
	// Prevent invalid engagement types
	switch engagementType {
	case domain.EngagementViewed:
	case domain.EngagementShared:
		break
	case domain.EngagementMade:
	case domain.EngagementLiked:
	case domain.EngagementReviewed:
	case domain.EngagementRated:
		return domain.Engagement{}, fmt.Errorf("Attempting to use disallowed anonymous engagement type.")
	}

	tx, err := r.db.Begin()
	if err != nil {
		return domain.Engagement{}, err
	}

	query := `
		INSERT INTO Engagements (
			type, message, entity, userid, created
		) VALUES  (
			$1, $2, $3, NULL, $4
		) RETURNING *;
	`

	var engagement domain.Engagement
	var userId sql.NullInt32
	if err := tx.QueryRow(query, engagementType, message, entityId, time.Now()).Scan(
		&engagement.Id,
		&engagement.Type,
		&engagement.Message,
		&engagement.Entity,
		&userId,
		&engagement.Created,
	); err != nil {
		tx.Rollback()
		return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
	}

	if err := tx.Commit(); err != nil {
		return domain.Engagement{}, err
	}

	// Is user is valid
	if userId.Valid {
		engagement.UserId = int(userId.Int32)
	}

	return engagement, nil
}

// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
func (r *EngagementRepository) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {
	query := `
		SELECT * FROM Engagements
		WHERE Userid = $1
		ORDER BY created DESC LIMIT $2;
	`

	rows, err := r.db.Query(query, userId, limit)
	if err != nil {
		return []domain.Engagement{}, fmt.Errorf("Failed to get user engagements. %s", err.Error())
	}
	defer rows.Close()

	var engagements []domain.Engagement
	for rows.Next() {
		var engagement domain.Engagement
		var engUserId sql.NullInt32
		if err := rows.Scan(
			&engagement.Id,
			&engagement.Type,
			&engagement.Message,
			&engagement.Entity,
			&engUserId,
			&engagement.Created,
		); err != nil {
			return []domain.Engagement{}, fmt.Errorf("Failed to scan user engagement. %s", err.Error())
		}

		// Add user if valid
		if engUserId.Valid {
			engagement.UserId = int(engUserId.Int32)
		}

		engagements = append(engagements, engagement)
	}

	return engagements, err
}

// GetUserEngagementFiltered returns a list of the users most recent engagement entries of a provided
// type. The number of records is determined by the limit passed into this function. The results are
// sorted, newest-to-oldest. Only results of the provided engagementType will be returned.
func (r *EngagementRepository) GetUserEngagementFiltered(userId, limit int, engagementType domain.EngagementType) ([]domain.Engagement, error) {
	query := `
	SELECT id, type, message, entity, userid, created
	FROM (
		SELECT 
			*,
			ROW_NUMBER() OVER (PARTITION BY entity ORDER BY created DESC) as rn
		FROM Engagements
		WHERE Userid = $1 AND type = $2
	) AS subquery
	WHERE rn = 1
	ORDER BY created DESC
	LIMIT $3;
	`

	rows, err := r.db.Query(query, userId, engagementType, limit)
	if err != nil {
		return []domain.Engagement{}, fmt.Errorf("Failed to get user engagements. %s", err.Error())
	}
	defer rows.Close()

	var engagements []domain.Engagement
	for rows.Next() {
		var engagement domain.Engagement
		var engUserId sql.NullInt32
		if err := rows.Scan(
			&engagement.Id,
			&engagement.Type,
			&engagement.Message,
			&engagement.Entity,
			&engUserId,
			&engagement.Created,
		); err != nil {
			return []domain.Engagement{}, fmt.Errorf("Failed to scan user engagement. %s", err.Error())
		}

		// Add user if valid
		if engUserId.Valid {
			engagement.UserId = int(engUserId.Int32)
		}

		engagements = append(engagements, engagement)
	}

	return engagements, err
}

// UserFavoriteRecipeToggle toggles the status of a users favorite of a recipe. If the user has already
// favorited the provided recipe, the database entry will be delete, hence removing the favorite. Otherwise,
// an entry will be created. The NEW status of the users favorite will be returned as the boolean. Any
// errors will be bubbled to the caller.
func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) {
	tx, err := r.db.Begin()
	if err != nil {
		return false, err
	}

	query := `
		SELECT id 
		FROM favorites
		WHERE userid = $1 AND recipeid = $2
	`

	var id int

	err = tx.QueryRow(query, userId, recipeId).Scan(&id)
	if err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			tx.Rollback()
			return false, fmt.Errorf("Failed to get recipe favorite. %s", err.Error())
		}
	}

	// Means we should create
	var success bool
	if id == 0 {
		createQuery := "INSERT INTO favorites (userid, recipeid, created) VALUES ($1, $2, $3);"

		if result, err := tx.Exec(createQuery, userId, recipeId, time.Now()); err != nil {
			tx.Rollback()
			return false, fmt.Errorf("Failed to create recipe favorite. %s", err.Error())
		} else {
			rows, _ := result.RowsAffected()
			success = rows == 1
		}
	} else {
		deleteQuery := "DELETE FROM favorites WHERE id = $1;"

		if result, err := tx.Exec(deleteQuery, id); err != nil {
			tx.Rollback()
			return false, fmt.Errorf("Failed to remove recipe favorite. %s", err.Error())
		} else {
			rows, _ := result.RowsAffected()
			success = rows == 1
		}
	}

	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return false, err
	}

	return success, nil
}
