package dbManager

import (
	"encoding/json"
	"errors"
	"math"
	"reflect"
	"strings"
	"time"

	"github.com/go-openapi/strfmt"
	"github.com/prometheus/client_golang/prometheus"
	cdrModels "github.com/Cyclops-Labs/cyclops-4-hpc.git/services/cdr/models"
	"github.com/Cyclops-Labs/cyclops-4-hpc.git/services/credit-system/models"
	"github.com/Cyclops-Labs/cyclops-4-hpc.git/services/credit-system/server/cacheManager"
	cusModels "github.com/Cyclops-Labs/cyclops-4-hpc.git/services/customerdb/models"
	pmModels "github.com/Cyclops-Labs/cyclops-4-hpc.git/services/plan-manager/models"
	l "gitlab.com/cyclops-utilities/logging"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

const (
	scaler           = 1e7
	statusDuplicated = iota
	statusFail
	statusMissing
	statusOK
	AC_CREDIT  = "CREDIT"
	AC_CASH    = "CASH"
	AC_BOTH    = "BOTH"
	AC_NONE    = "NONE"
	MED_CASH   = "CASH"
	MED_CREDIT = "CREDIT"
)

var (
	//states = []string{"active", "error", "inactive", "suspended", "terminated"}
	states     = []string{"active", "error", "inactive", "used"}
	totalTime  float64
	totalCount int64
)

// DbParameter is the struct defined to group and contain all the methods
// that interact with the database.
// On it there is the following parameters:
// - Cache: CacheManager pointer for the cache mechanism.
// - connStr: strings with the connection information to the database
// - Db: a gorm.DB pointer to the db to invoke all the db methods
type DbParameter struct {
	Cache   *cacheManager.CacheManager
	connStr string
	Db      *gorm.DB
	Metrics map[string]*prometheus.GaugeVec
}

// New is the function to create the struct DbParameter.
// Parameters:
// - dbConn: strings with the connection information to the database
// - tables: array of interfaces that will contains the models to migrate
// to the database on initialization
// Returns:
// - DbParameter: struct to interact with dbManager functionalities¬
func New(dbConn string, tables ...interface{}) *DbParameter {

	l.Trace.Printf("[DB] Gerenating new DBParameter.\n")

	var (
		dp  DbParameter
		err error
	)

	dp.connStr = dbConn

	dp.Db, err = gorm.Open(postgres.Open(dbConn), &gorm.Config{})

	if err != nil {

		l.Error.Printf("[DB] Error opening connection. Error: %v\n", err)

	}

	l.Trace.Printf("[DB] Migrating tables.\n")

	//Database migration, it handles everything
	dp.Db.AutoMigrate(tables...)

	//l.Trace.Printf("[DB] Generating hypertables.\n")

	// Hypertables creation for timescaledb in case of needed
	//dp.Db.Exec("SELECT create_hypertable('" + dp.Db.NewScope(&models.TABLE).TableName() + "', 'TIMESCALE-ROW-INDEX');")

	return &dp

}

// AddConsumption job is to decrease the credit in the system linked to the
// provided account by a certain amount of credit.
// This function is the one that produces a "cosumed" decrease of credit without
// human intervention.
// Parameters:
// - id: string containing the id of the account requested.
// - amount: float containing the amount to be -- in the system
// Returns:
// - reference to creditStatus containing the credit balance after the operation.
// - error raised in case of problems.
func (d *DbParameter) AddConsumption(id string, amount float64, medium string) (*models.CreditStatus, error) {

	l.Trace.Printf("[DB] Attempting to add a comsuption of %v credit, in the account with id: %v", amount, id)

	var c, c0, cs models.CreditStatus
	var ce models.CreditEvents
	var e error

	if r := d.Db.Where(&models.CreditStatus{AccountID: id}).First(&c).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		l.Trace.Printf("[DB] Account with id: %v doesn't exist in the system, check with administrator.", id)

		e = errors.New("account doesn't exist in the system")

	} else {

		med := strings.ToUpper(medium)

		if med == models.CreditEventsMediumCREDIT {

			c0.AvailableCredit = c.AvailableCredit - amount

		}

		if med == models.CreditEventsMediumCASH {

			c0.AvailableCash = c.AvailableCash - amount

		}

		c0.LastUpdate = strfmt.DateTime(time.Now())

		ce.AccountID = id
		ce.Delta = -amount
		ce.EventType = func(s string) *string { return &s }(models.EventEventTypeConsumption)
		ce.Timestamp = strfmt.DateTime(time.Now())
		ce.Medium = &med

		if e = d.Db.Model(&c).Updates(c0).Error; e == nil {

			if e = d.Db.Create(&ce).Error; e == nil {

				d.Db.Where(&models.CreditStatus{AccountID: id}).First(&cs)

			} else {

				l.Trace.Printf("[DB] Attempting to add the comsuption of credit event for account in the system failed.")

			}

		} else {

			l.Trace.Printf("[DB] Attempting to update the account in the system failed.")

		}

	}

	return &cs, e

}

// CreateAccount job is to register a new account in the system with the provided
// ID.
// Parameters:
// - id: string containing the id of the account to be created.
// Returns:
// - reference to AccountStatus containing the state of the account after the
// operation.
// - error raised in case of problems.
func (d *DbParameter) CreateAccount(id string) (*models.AccountStatus, error) {

	l.Trace.Printf("[DB] Attempting to create a new account with id: %v", id)

	var a, a0, as models.AccountStatus
	var c models.CreditStatus
	var e error

	if r := d.Db.Where(&models.AccountStatus{AccountID: id}).First(&a).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		a0.AccountID = id
		a0.CreatedAt = strfmt.DateTime(time.Now())

		c.AccountID = id
		c.LastUpdate = strfmt.DateTime(time.Now())

		if e = d.Db.Create(&a0).Error; e == nil {

			if e = d.Db.Create(&c).Error; e == nil {

				d.Db.Where(&models.AccountStatus{AccountID: id}).First(&as)

				d.Metrics["count"].With(prometheus.Labels{"type": "Accounts created"}).Inc()

			} else {

				l.Trace.Printf("[DB] Attempting to create the credit account in the system failed.")

			}

		} else {

			l.Trace.Printf("[DB] Attempting to create the account in the system failed.")

		}

	} else {

		l.Trace.Printf("[DB] Account with id: %v already in the system, check with administrator.", id)

		e = errors.New("account already exist in the system")

	}

	return &as, e

}

// DecreaseCredit job is to decrease the credit in the system linked to the
// provided account by a certain amount of credit.
// Parameters:
// - id: string containing the id of the account requested.
// - amount: float containing the amount to be decreased in the system.
// Returns:
// - reference to CreditStatus containing the credit balance after the operation.
// - error raised in case of problems.
func (d *DbParameter) DecreaseCredit(id string, amount float64, medium string) (*models.CreditStatus, error) {

	l.Trace.Printf("[DB] Attempting to decrease credit by: %v, in the account with id: %v", amount, id)

	var c, c0, cs models.CreditStatus
	var ce models.CreditEvents
	var e error

	if r := d.Db.Where(&models.CreditStatus{AccountID: id}).First(&c).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		l.Trace.Printf("[DB] Account with id: %v doesn't exist in the system, check with administrator.", id)

		e = errors.New("account doesn't exist in the system")

	} else {

		med := strings.ToUpper(medium)

		if med == models.CreditEventsMediumCREDIT {

			c0.AvailableCredit = c.AvailableCredit - amount

		}

		if med == models.CreditEventsMediumCASH {

			c0.AvailableCash = c.AvailableCash - amount

		}

		c0.LastUpdate = strfmt.DateTime(time.Now())

		ce.AccountID = id
		ce.Delta = -amount
		ce.EventType = func(s string) *string { return &s }(models.EventEventTypeAuthorizedDecrease)
		ce.Timestamp = strfmt.DateTime(time.Now())
		ce.Medium = &med

		if e = d.Db.Model(&c).Updates(c0).Error; e == nil {

			if e = d.Db.Create(&ce).Error; e == nil {

				d.Db.Where(&models.CreditStatus{AccountID: id}).First(&cs)

			} else {

				l.Trace.Printf("[DB] Attempting to add the decrease of credit event for account in the system failed.")

			}

		} else {

			l.Trace.Printf("[DB] Attempting to update the account in the system failed.")

		}

	}

	return &cs, e

}

// DisableAccount job is to mark as disabled in the system the account provided.
// Parameters:
// - id: string containing the id of the account to be disabled.
// Returns:
// - reference to AccountStatus containing the state of the account after the
// operation.
// - error raised in case of problems.
func (d *DbParameter) DisableAccount(id string) (*models.AccountStatus, error) {

	l.Trace.Printf("[DB] Attempting to disable account with id: %v", id)

	var a, as models.AccountStatus
	var e error

	if r := d.Db.Where(&models.AccountStatus{AccountID: id}).First(&a).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		l.Trace.Printf("[DB] Account with id: %v doesn't exist in the system, check with administrator.", id)

		e = errors.New("account doesn't exist in the system")

	} else {

		enabled := false

		if e = d.Db.Model(&a).Updates(&models.AccountStatus{Enabled: enabled}).Error; e == nil {

			d.Db.Where(&models.AccountStatus{AccountID: id}).First(&as)

		} else {

			l.Trace.Printf("[DB] Attempting to update the account in the system failed.")

		}

	}

	return &as, e

}

// EnableAccount job is to mark as enabled in the system the account provided.
// Parameters:
// - id: string containing the id of the account to be enabled.
// Returns:
// - reference to AccountStatus containing the state of the account after the
// operation.
// - error raised in case of problems.
func (d *DbParameter) EnableAccount(id string) (*models.AccountStatus, error) {

	l.Trace.Printf("[DB] Attempting to enable account with id: %v", id)

	var a, as models.AccountStatus
	var e error

	if r := d.Db.Where(&models.AccountStatus{AccountID: id}).First(&a).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		l.Trace.Printf("[DB] Account with id: %v doesn't exist in the system, check with administrator.", id)

		e = errors.New("account doesn't exist in the system")

	} else {

		if e = d.Db.Model(&a).Update("Enabled", true).Error; e == nil {

			d.Db.Where(&models.AccountStatus{AccountID: id}).First(&as)

		} else {

			l.Trace.Printf("[DB] Attempting to update the account in the system failed.")

		}

	}

	return &as, e

}

// GetAccountStatus job is to retrieve the actual state of the provided account.
// Parameters:
// - id: string containing the id of the account to be retrieved.
// Returns:
// - reference to AccountStatus containing the state of the account.
// - error raised in case of problems.
func (d *DbParameter) GetAccountStatus(id string) (*models.AccountStatus, error) {

	l.Trace.Printf("[DB] Attempting to get the status of the account with id: %v", id)

	var as models.AccountStatus
	var e error

	if r := d.Db.Where(&models.AccountStatus{AccountID: id}).First(&as).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		l.Trace.Printf("[DB] Account with id: %v doesn't exist in the system, check with administrator.", id)

		e = errors.New("account doesn't exist in the system")

	}

	return &as, e

}

// GetCredit job is to retrieve the actual state of the credit balance of the
// provided account.
// Parameters:
// - id: string containing the id of the account requested.
// Returns:
// - reference to CreditStatus containing the credit balance.
// - error raised in case of problems.
func (d *DbParameter) GetCredit(id string) (*models.CreditStatus, error) {

	l.Trace.Printf("[DB] Attempting to get the credit balance in the account with id: %v", id)

	var cs models.CreditStatus
	var e error

	if r := d.Db.Where(&models.CreditStatus{AccountID: id}).First(&cs).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		l.Trace.Printf("[DB] Account with id: %v doesn't exist in the system, check with administrator.", id)

		e = errors.New("account doesn't exist in the system")

	}

	return &cs, e

}

// GetHistory job is to retrieve the history of the credit balance of the account
// provided with the posibility of filter the actions.
// Parameters:
// - id: string containing the id of the account requested.
// - filtered: string specifying the criteria for filtering.
// Returns:
// - reference to CreditHistory containing the credit balance history contained
// in the system.
// - error raised in case of problems.
func (d *DbParameter) GetHistory(id string, filtered bool, medium string) (*models.CreditHistory, error) {

	l.Trace.Printf("[DB] Attempting to get the credit history in the account with id: %v", id)

	var ch models.CreditHistory
	var ce []*models.CreditEvents
	var e0 []*models.Event
	var e error

	ch.AccountID = id

	med := strings.ToUpper(medium)

	if filtered {

		evType := models.EventEventTypeConsumption

		e = d.Db.Where(&models.CreditEvents{AccountID: id, Medium: &med}).Not(&models.CreditEvents{EventType: &evType}).Find(&ce).Error

	} else {

		e = d.Db.Where(&models.CreditEvents{AccountID: id, Medium: &med}).Find(&ce).Error

	}

	if e != nil {

		l.Trace.Printf("[DB] Account with id: %v doesn't have events in the system, check with administrator.", id)

	}

	for _, event := range ce {

		var ev models.Event
		ev.Delta = event.Delta
		ev.EventType = event.EventType
		ev.Timestamp = event.Timestamp
		ev.AuthorizedBy = event.AuthorizedBy
		e0 = append(e0, &ev)

	}

	l.Trace.Printf("[DB] Amount of events for the account with id: %v, is: %v.", id, len(e0))

	ch.Events = e0

	return &ch, e

}

// IncreaseCredit job is to increase the credit in the system linked to the
// provided account by a certain amount of credit.
// Parameters:
// - id: string containing the id of the account requested.
// - amount: float containing the amount to be -- in the system
// Returns:
// - reference to CreditStatus containing the credit balance after the operation.
// - error raised in case of problems.
func (d *DbParameter) IncreaseCredit(id string, amount float64, medium string) (*models.CreditStatus, error) {

	l.Trace.Printf("[DB] Attempting to increase credit by: %v, in the account with id: %v", amount, id)

	var c, c0, cs models.CreditStatus
	var ce models.CreditEvents
	var e error

	if r := d.Db.Where(&models.CreditStatus{AccountID: id}).First(&c).Error; errors.Is(r, gorm.ErrRecordNotFound) {

		l.Trace.Printf("[DB] Account with id: %v doesn't exist in the system, check with administrator.", id)

		e = errors.New("account doesn't exist in the system")

	} else {

		med := strings.ToUpper(medium)

		if med == models.CreditEventsMediumCREDIT {

			c0.AvailableCredit = c.AvailableCredit + amount

		}

		if med == models.CreditEventsMediumCASH {

			c0.AvailableCash = c.AvailableCash + amount

		}

		c0.LastUpdate = strfmt.DateTime(time.Now())

		ce.AccountID = id
		ce.Delta = amount
		ce.EventType = func(s string) *string { return &s }(models.EventEventTypeAuthorizedIncrease)
		ce.Timestamp = strfmt.DateTime(time.Now())
		ce.Medium = &med

		if e = d.Db.Model(&c).Updates(c0).Error; e == nil {

			if e = d.Db.Create(&ce).Error; e == nil {

				d.Db.Where(&models.CreditStatus{AccountID: id}).First(&cs)

			} else {

				l.Trace.Printf("[DB] Attempting to add the increase of credit event for account in the system failed.")

			}

		} else {

			l.Trace.Printf("[DB] Attempting to update the account in the system failed.")

		}

	}

	return &cs, e

}

// ListAccounts job is to retrieve the list of all the accounts safe in the system.
// Returns:
// - slice of references to AccountStatus containing the status of every account
// in the system
func (d *DbParameter) ListAccounts() ([]*models.AccountStatus, error) {

	l.Trace.Printf("[DB] Attempting to list accounts in the system.")

	var as []*models.AccountStatus
	var e error

	if e = d.Db.Find(&as).Error; e != nil {

		l.Trace.Printf("[DB] There is not accounts in the system")

	}

	return as, e

}

// ProcessCDR job is to check every CDR report arriving to the system to add the
// consumptions of each account to the corresponding
// Parameters:
// - report: CDR Report model reference to be processed.
// - usage: a boolean discriminant to set the accounting to usage instead of cost
// Returns:
// - e: error in case of problem while doing a conversion, a cache.get, or adding the comsumption.
func (d *DbParameter) ProcessCDR(report cdrModels.CReport, token string) error {

	l.Trace.Printf("[DB] Starting the processing of the a CDR.\n")

	var planID string

	now := time.Now().UnixNano()

	listAccounts, e := d.ListAccounts()

	if e != nil {

		l.Warning.Printf("[DB] There's was an error retrieving the accounts in the system. Error: %v\n", e)

		return e

	}

	if len(listAccounts) < 1 {

		l.Trace.Printf("[DB] There's no accounts in the system right now, skipping the processing...\n")

		return nil

	}

	p, e := d.Cache.Get(report.AccountID, "product", token)

	if e != nil {

		l.Warning.Printf("[DB] Something went wrong while retrieving the product info for id [ %v ]. Error: %v\n", report.AccountID, e)

		return e

	}

	product := p.(cusModels.Product)

	c, e := d.Cache.Get(product.CustomerID, "customer", token)

	if e != nil {

		l.Warning.Printf("[DB] Something went wrong while retrieving the customer info for id [ %v ]. Error: %v\n", product.CustomerID, e)

		return e

	}

	customer := c.(cusModels.Customer)

	account, e := d.GetAccountStatus(customer.CustomerID)

	if e != nil {

		l.Trace.Printf("[DB] There's no account associated in the customer level ID: [ %v ], moving to the next level...\n", customer.CustomerID)

		acc, e := d.GetAccountStatus(customer.ResellerID)

		if e != nil {

			l.Trace.Printf("[DB] There's no account associated in the reseller level. ID: [ %v ], skipping this product [ %v ].\n", customer.ResellerID, product.ProductID)

			return nil

		}

		account = acc

	}

	if !(account.Enabled) {

		l.Trace.Printf("[DB] Account associated [ %v ] is disabled! Skipping...\n", account.AccountID)

		return nil

	}

	if product.PlanID != "" {

		planID = product.PlanID

	} else {

		if customer.PlanID != "" {

			planID = customer.PlanID

		} else {

			r, e := d.Cache.Get(customer.ResellerID, "reseller", token)

			if e != nil {

				l.Warning.Printf("[DB] Something went wrong while retrieving the reseller info for id [ %v ]. Error: %v\n", customer.ResellerID, e)

				return e

			}

			reseller := r.(cusModels.Reseller)

			planID = reseller.PlanID

		}

	}

PlanDefault:
	p, e = d.Cache.Get(planID, "plan", token)

	if e != nil {

		if planID == "DEFAULT" {

			l.Warning.Printf("[DB] Something went wrong while retrieving the default plan id [ %v ]. Error: %v\n", planID, e)

			return e

		}

		l.Warning.Printf("[DB] Something went wrong while retrieving the plan id [ %v ]. Re-trying with default plan. Error: %v\n", planID, e)

		planID = "DEFAULT"

		goto PlanDefault

	}

	plan := p.(pmModels.Plan)

	// In case the plan is not valid we return to the deault plan (id 0) which is valid ad vitam
	if (time.Now()).After((time.Time)(*plan.OfferedEndDate)) || (time.Now()).Before((time.Time)(*plan.OfferedStartDate)) {

		l.Warning.Printf("[DB] The plan [ %v ] is only valid between [ %v ] and [ %v ]. Falling back to default plan.\n", plan.ID, *plan.OfferedStartDate, *plan.OfferedEndDate)

		planID = "DEFAULT"

		goto PlanDefault

	}

	skus := make(map[string]*pmModels.SkuPrice)

	for _, k := range plan.SkuPrices {

		skus[k.SkuName] = k

	}

	var cashDelta, creditDelta float64

	for i := range report.Usage {

		var value interface{}

		mode := *skus[report.Usage[i].ResourceType].AccountingMode

		if mode == AC_CREDIT || mode == AC_BOTH {

			for _, j := range states {

				value = report.Usage[i].UsageBreakup[j]

				if value == nil {

					l.Debug.Printf("[DB] The state [ %v ] seem to not be part of the usage UsageBreakup [ %+v ], skipping...", j, report.Usage[i].UsageBreakup)

					continue

				}

				if k := reflect.ValueOf(value); k.Kind() == reflect.Float64 {

					creditDelta += value.(float64) * skus[report.Usage[i].ResourceType].UnitCreditPrice

					continue

				}

				v, e := value.(json.Number).Float64()

				if e != nil {

					l.Warning.Printf("[DB] There was a problem retrieving the float value from the usagebreakup interface. Error: %v.\n", e)

					return e

				}

				creditDelta += v * skus[report.Usage[i].ResourceType].UnitCreditPrice

			}

		}

		if mode == AC_CASH || mode == AC_BOTH {

			value = report.Usage[i].Cost["totalFromSku"]

			if k := reflect.ValueOf(value); k.Kind() == reflect.Float64 {

				cashDelta += value.(float64)

				continue

			}

			v, e := value.(json.Number).Float64()

			if e != nil {

				l.Warning.Printf("[DB] There was a problem retrieving the float value from the cost interface. Error: %v.\n", e)

				return e

			}

			cashDelta += v

		}

	}

	state, e := d.AddConsumption(account.AccountID, d.getNiceFloat(creditDelta), MED_CREDIT)

	if e != nil {

		l.Warning.Printf("[DB] There was a problem while adding the consumption to the account [ %v ]. Error: %v.\n", account.AccountID, e)

		return e
	}

	if state.AvailableCredit < float64(0) {

		l.Warning.Printf("[DB] Account [ %v ] credit is not positive. Credit: [ %v ].\n", account.AccountID, state.AvailableCredit)

	}

	l.Trace.Printf("[DB] Account [ %v ] has been updated with a [ %v ] consumption.\n", account.AccountID, creditDelta)

	state, e = d.AddConsumption(account.AccountID, d.getNiceFloat(cashDelta), MED_CASH)

	if e != nil {

		l.Warning.Printf("[DB] There was a problem while adding the consumption to the account [ %v ]. Error: %v.\n", account.AccountID, e)

		return e
	}

	if state.AvailableCash < float64(0) {

		l.Warning.Printf("[DB] Account [ %v ] cash is not positive. Cash: [ %v ].\n", account.AccountID, state.AvailableCash)

	}

	l.Trace.Printf("[DB] Account [ %v ] has been updated with a [ %v ] consumption.\n", account.AccountID, cashDelta)

	totalTime += float64(time.Now().UnixNano()-now) / float64(time.Millisecond)
	totalCount++

	d.Metrics["count"].With(prometheus.Labels{"type": "CDRs procesed"}).Inc()

	d.Metrics["time"].With(prometheus.Labels{"type": "CDRs average processing time"}).Set(totalTime / float64(totalCount))

	return nil

}

func (d *DbParameter) getNiceFloat(i float64) (o float64) {

	return float64(math.Round(i*scaler) / scaler)

}
