package database

import (
	"database/sql"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"reflect"
	"time"

	"github.com/apex/log"
	"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/enginex"
	"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/utils"
	"github.com/pkg/errors"
	db "upper.io/db.v3"
	"upper.io/db.v3/lib/sqlbuilder"
)

// ListMeasurements given a result ID
func ListMeasurements(sess sqlbuilder.Database, resultID int64) ([]MeasurementURLNetwork, error) {
	measurements := []MeasurementURLNetwork{}
	req := sess.Select(
		db.Raw("networks.*"),
		db.Raw("urls.*"),
		db.Raw("measurements.*"),
		db.Raw("results.*"),
	).From("results").
		Join("measurements").On("results.result_id = measurements.result_id").
		Join("networks").On("results.network_id = networks.network_id").
		LeftJoin("urls").On("urls.url_id = measurements.url_id").
		OrderBy("measurements.measurement_start_time").
		Where("results.result_id = ?", resultID)
	if err := req.All(&measurements); err != nil {
		log.Errorf("failed to run query %s: %v", req.String(), err)
		return measurements, err
	}
	return measurements, nil
}

// GetMeasurementJSON returns a map[string]interface{} given a database and a measurementID
func GetMeasurementJSON(sess sqlbuilder.Database, measurementID int64) (map[string]interface{}, error) {
	var (
		measurement MeasurementURLNetwork
		msmtJSON    map[string]interface{}
	)
	req := sess.Select(
		db.Raw("urls.*"),
		db.Raw("measurements.*"),
	).From("measurements").
		LeftJoin("urls").On("urls.url_id = measurements.url_id").
		Where("measurements.measurement_id= ?", measurementID)
	if err := req.One(&measurement); err != nil {
		log.Errorf("failed to run query %s: %v", req.String(), err)
		return nil, err
	}
	if measurement.Measurement.IsUploaded {
		// TODO(bassosimone): this should be a function exposed by probe-engine
		reportID := measurement.Measurement.ReportID.String
		measurementURL := &url.URL{
			Scheme: "https",
			Host:   "api.ooni.io",
			Path:   "/api/v1/raw_measurement",
		}
		query := url.Values{}
		query.Add("report_id", reportID)
		if measurement.URL.URL.Valid == true {
			query.Add("input", measurement.URL.URL.String)
		}
		measurementURL.RawQuery = query.Encode()
		log.Debugf("using %s", measurementURL.String())
		resp, err := http.Get(measurementURL.String())
		if err != nil {
			log.Errorf("failed to fetch the measurement %s %s", reportID, measurement.URL.URL.String)
			return nil, err
		}
		defer resp.Body.Close()
		if err := json.NewDecoder(resp.Body).Decode(&msmtJSON); err != nil {
			log.Error("failed to unmarshal the measurement_json")
			return nil, err
		}
		return msmtJSON, nil
	}
	// MeasurementFilePath might be NULL because the measurement from a
	// 3.0.0-beta install
	if measurement.Measurement.MeasurementFilePath.Valid == false {
		log.Error("invalid measurement_file_path")
		log.Error("backup your OONI_HOME and run `ooniprobe reset`")
		return nil, errors.New("cannot access measurement file")
	}
	measurementFilePath := measurement.Measurement.MeasurementFilePath.String
	b, err := os.ReadFile(measurementFilePath)
	if err != nil {
		return nil, err
	}
	if err := json.Unmarshal(b, &msmtJSON); err != nil {
		log.Error("failed to unmarshal the measurement_json")
		log.Error("backup your OONI_HOME and run `ooniprobe reset`")
		return nil, err
	}
	return msmtJSON, nil
}

// GetResultTestKeys returns a list of TestKeys for a given result
func GetResultTestKeys(sess sqlbuilder.Database, resultID int64) (string, error) {
	res := sess.Collection("measurements").Find("result_id", resultID)
	defer res.Close()

	var (
		msmt Measurement
		tk   PerformanceTestKeys
	)
	for res.Next(&msmt) {
		// We only really care about performance keys.
		// Note: since even in case of failure we still initialise an empty struct,
		// it could be that these keys come out as initializes with the default
		// values.
		// XXX we may want to change this behaviour by adding `omitempty` to the
		// struct definition.
		if msmt.TestName != "ndt" && msmt.TestName != "dash" {
			return "{}", nil
		}
		if err := json.Unmarshal([]byte(msmt.TestKeys), &tk); err != nil {
			log.WithError(err).Error("failed to parse testKeys")
			return "{}", err
		}
	}
	b, err := json.Marshal(tk)
	if err != nil {
		log.WithError(err).Error("failed to serialize testKeys")
		return "{}", err
	}
	return string(b), nil
}

// GetMeasurementCounts returns the number of anomalous and total measurement for a given result
func GetMeasurementCounts(sess sqlbuilder.Database, resultID int64) (uint64, uint64, error) {
	var (
		totalCount uint64
		anmlyCount uint64
		err        error
	)
	col := sess.Collection("measurements")

	// XXX these two queries can be done with a single query
	totalCount, err = col.Find("result_id", resultID).
		Count()
	if err != nil {
		log.WithError(err).Error("failed to get total count")
		return totalCount, anmlyCount, err
	}

	anmlyCount, err = col.Find("result_id", resultID).
		And(db.Cond{"is_anomaly": true}).Count()
	if err != nil {
		log.WithError(err).Error("failed to get anmly count")
		return totalCount, anmlyCount, err
	}

	log.Debugf("counts: %d, %d, %d", resultID, totalCount, anmlyCount)
	return totalCount, anmlyCount, err
}

// ListResults return the list of results
func ListResults(sess sqlbuilder.Database) ([]ResultNetwork, []ResultNetwork, error) {
	doneResults := []ResultNetwork{}
	incompleteResults := []ResultNetwork{}
	req := sess.Select(
		db.Raw("networks.*"),
		db.Raw("results.*"),
	).From("results").
		Join("networks").On("results.network_id = networks.network_id").
		OrderBy("results.result_start_time")
	if err := req.Where("result_is_done = true").All(&doneResults); err != nil {
		return doneResults, incompleteResults, errors.Wrap(err, "failed to get result done list")
	}
	if err := req.Where("result_is_done = false").All(&incompleteResults); err != nil {
		return doneResults, incompleteResults, errors.Wrap(err, "failed to get result done list")
	}
	return doneResults, incompleteResults, nil
}

// DeleteResult will delete a particular result and the relative measurement on
// disk.
func DeleteResult(sess sqlbuilder.Database, resultID int64) error {
	var result Result
	res := sess.Collection("results").Find("result_id", resultID)
	if err := res.One(&result); err != nil {
		if err == db.ErrNoMoreRows {
			return err
		}
		log.WithError(err).Error("error in obtaining the result")
		return err
	}
	if err := res.Delete(); err != nil {
		log.WithError(err).Error("failed to delete the result directory")
		return err
	}

	os.RemoveAll(result.MeasurementDir)
	return nil
}

// UpdateUploadedStatus will check if all the measurements inside of a given result set have been uploaded and if so will set the is_uploaded flag to true
func UpdateUploadedStatus(sess sqlbuilder.Database, result *Result) error {
	tx, err := sess.NewTx(nil)
	if err != nil {
		log.WithError(err).Error("failed to create transaction")
		return err
	}

	uploadedTotal := UploadedTotalCount{}
	req := tx.Select(
		db.Raw("SUM(measurements.measurement_is_uploaded)"),
		db.Raw("COUNT(*)"),
	).From("results").
		Join("measurements").On("measurements.result_id = results.result_id").
		Where("results.result_id = ?", result.ID)

	err = req.One(&uploadedTotal)
	if err != nil {
		log.WithError(err).Error("failed to retrieve total vs uploaded counts")
		return err
	}
	if uploadedTotal.UploadedCount == uploadedTotal.TotalCount {
		result.IsUploaded = true
	} else {
		result.IsUploaded = false
	}
	err = tx.Collection("results").Find("result_id", result.ID).Update(result)
	if err != nil {
		log.WithError(err).Error("failed to update result")
		return errors.Wrap(err, "updating result")
	}
	err = tx.Commit()
	if err != nil {
		log.WithError(err).Error("Failed to write to the results table")
		return err
	}

	return nil
}

// CreateMeasurement writes the measurement to the database a returns a pointer
// to the Measurement
func CreateMeasurement(sess sqlbuilder.Database, reportID sql.NullString, testName string, measurementDir string, idx int, resultID int64, urlID sql.NullInt64) (*Measurement, error) {
	// TODO we should look into generating this file path in a more robust way.
	// If there are two identical test_names in the same test group there is
	// going to be a clash of test_name
	msmtFilePath := filepath.Join(measurementDir, fmt.Sprintf("msmt-%s-%d.json", testName, idx))
	msmt := Measurement{
		ReportID:            reportID,
		TestName:            testName,
		ResultID:            resultID,
		MeasurementFilePath: sql.NullString{String: msmtFilePath, Valid: true},
		URLID:               urlID,
		IsFailed:            false,
		IsDone:              false,
		// XXX Do we want to have this be part of something else?
		StartTime: time.Now().UTC(),
		TestKeys:  "",
	}

	newID, err := sess.Collection("measurements").Insert(msmt)
	if err != nil {
		return nil, errors.Wrap(err, "creating measurement")
	}
	msmt.ID = newID.(int64)
	return &msmt, nil
}

// CreateResult writes the Result to the database a returns a pointer
// to the Result
func CreateResult(sess sqlbuilder.Database, homePath string, testGroupName string, networkID int64) (*Result, error) {
	startTime := time.Now().UTC()

	p, err := utils.MakeResultsDir(homePath, testGroupName, startTime)
	if err != nil {
		return nil, err
	}

	result := Result{
		TestGroupName: testGroupName,
		StartTime:     startTime,
		NetworkID:     networkID,
	}
	result.MeasurementDir = p
	log.Debugf("Creating result %v", result)

	newID, err := sess.Collection("results").Insert(result)
	if err != nil {
		return nil, errors.Wrap(err, "creating result")
	}
	result.ID = newID.(int64)
	return &result, nil
}

// CreateNetwork will create a new network in the network table
func CreateNetwork(sess sqlbuilder.Database, loc enginex.LocationProvider) (*Network, error) {
	network := Network{
		ASN:         loc.ProbeASN(),
		CountryCode: loc.ProbeCC(),
		NetworkName: loc.ProbeNetworkName(),
		// On desktop we consider it to always be wifi
		NetworkType: "wifi",
		IP:          loc.ProbeIP(),
	}
	newID, err := sess.Collection("networks").Insert(network)
	if err != nil {
		return nil, err
	}

	network.ID = newID.(int64)
	return &network, nil
}

// CreateOrUpdateURL will create a new URL entry to the urls table if it doesn't
// exists, otherwise it will update the category code of the one already in
// there.
func CreateOrUpdateURL(sess sqlbuilder.Database, urlStr string, categoryCode string, countryCode string) (int64, error) {
	var url URL

	tx, err := sess.NewTx(nil)
	if err != nil {
		log.WithError(err).Error("failed to create transaction")
		return 0, err
	}
	res := tx.Collection("urls").Find(
		db.Cond{"url": urlStr, "url_country_code": countryCode},
	)
	err = res.One(&url)

	if err == db.ErrNoMoreRows {
		url = URL{
			URL:          sql.NullString{String: urlStr, Valid: true},
			CategoryCode: sql.NullString{String: categoryCode, Valid: true},
			CountryCode:  sql.NullString{String: countryCode, Valid: true},
		}
		newID, insErr := tx.Collection("urls").Insert(url)
		if insErr != nil {
			log.Error("Failed to insert into the URLs table")
			return 0, insErr
		}
		url.ID = sql.NullInt64{Int64: newID.(int64), Valid: true}
	} else if err != nil {
		log.WithError(err).Error("Failed to get single result")
		return 0, err
	} else {
		url.CategoryCode = sql.NullString{String: categoryCode, Valid: true}
		res.Update(url)
	}

	err = tx.Commit()
	if err != nil {
		log.WithError(err).Error("Failed to write to the URL table")
		return 0, err
	}

	log.Debugf("returning url %d", url.ID.Int64)

	return url.ID.Int64, nil
}

// AddTestKeys writes the summary to the measurement
func AddTestKeys(sess sqlbuilder.Database, msmt *Measurement, tk interface{}) error {
	var (
		isAnomaly      bool
		isAnomalyValid bool
	)
	tkBytes, err := json.Marshal(tk)
	if err != nil {
		log.WithError(err).Error("failed to serialize summary")
	}

	// This is necessary so that we can extract from the the opaque testKeys just
	// the IsAnomaly field of bool type.
	// Maybe generics are not so bad after-all, heh golang?
	isAnomalyValue := reflect.ValueOf(tk).FieldByName("IsAnomaly")
	if isAnomalyValue.IsValid() == true && isAnomalyValue.Kind() == reflect.Bool {
		isAnomaly = isAnomalyValue.Bool()
		isAnomalyValid = true
	}
	msmt.TestKeys = string(tkBytes)
	msmt.IsAnomaly = sql.NullBool{Bool: isAnomaly, Valid: isAnomalyValid}

	err = sess.Collection("measurements").Find("measurement_id", msmt.ID).Update(msmt)
	if err != nil {
		log.WithError(err).Error("failed to update measurement")
		return errors.Wrap(err, "updating measurement")
	}
	return nil
}
