package oonimkall

import (
	"context"
	"encoding/json"
	"errors"
	"net/url"
	"runtime"
	"sync"

	"github.com/ooni/probe-cli/v3/internal/atomicx"
	"github.com/ooni/probe-cli/v3/internal/engine"
	"github.com/ooni/probe-cli/v3/internal/engine/probeservices"
	"github.com/ooni/probe-cli/v3/internal/kvstore"
	"github.com/ooni/probe-cli/v3/internal/legacy/assetsdir"
	"github.com/ooni/probe-cli/v3/internal/model"
	"github.com/ooni/probe-cli/v3/internal/runtimex"
)

// AtomicInt64 allows us to export atomicx.Int64 variables to
// mobile libraries so we can use them in testing.
type AtomicInt64 struct {
	*atomicx.Int64
}

// These two variables contain metrics pertaining to the number
// of Sessions and Contexts that are currently being used.
var (
	ActiveSessions = &AtomicInt64{&atomicx.Int64{}}
	ActiveContexts = &AtomicInt64{&atomicx.Int64{}}
)

// Logger is the logger used by a Session. You should implement a class
// compatible with this interface in Java/ObjC and then save a reference
// to this instance in the SessionConfig object. All log messages that
// the Session will generate will be routed to this Logger.
type Logger interface {
	// Debug handles debug messages.
	Debug(msg string)

	// Info handles informational messages.
	Info(msg string)

	// Warn handles warning messages.
	Warn(msg string)
}

// ExperimentCallbacks contains experiment callbacks.
type ExperimentCallbacks interface {
	// OnProgress provides information about an experiment progress.
	OnProgress(percentage float64, message string)
}

// SessionConfig contains configuration for a Session. You should
// fill all the mandatory fields and could also optionally fill some of
// the optional fields. Then pass this struct to NewSession.
type SessionConfig struct {
	// AssetsDir is the mandatory directory where to store assets
	// required by a Session, e.g. MaxMind DB files.
	//
	// This field is currently deprecated and unused. We will
	// remove it when we'll bump the major number.
	AssetsDir string

	// Logger is the optional logger that will receive all the
	// log messages generated by a Session. If this field is nil
	// then the session will not emit any log message.
	Logger Logger

	// Proxy allows you to optionally force a specific proxy
	// rather than using no proxy (the default).
	//
	// Use `psiphon:///` to force using Psiphon with the
	// embedded configuration file. Not all builds have
	// an embedded configuration file, but OONI builds have
	// such a file, so they can use this functionality.
	//
	// Use `socks5://10.0.0.1:9050/` to connect to a SOCKS5
	// proxy running on 10.0.0.1:9050. This could be, for
	// example, a suitably configured `tor` instance.
	Proxy string

	// ProbeServicesURL allows you to optionally force the
	// usage of an alternative probe service instance. This setting
	// should only be used for implementing integration tests.
	ProbeServicesURL string

	// SoftwareName is the mandatory name of the application
	// that will be using the new Session.
	SoftwareName string

	// SoftwareVersion is the mandatory version of the application
	// that will be using the new Session.
	SoftwareVersion string

	// StateDir is the mandatory directory where to store state
	// information required by a Session.
	StateDir string

	// TempDir is the mandatory directory where the Session shall
	// store temporary files. Among other tasks, Session.Close will
	// remove any temporary file created within this Session.
	TempDir string

	// TunnelDir is the directory where the Session shall store
	// persistent data regarding circumvention tunnels. This directory
	// is mandatory if you want to use tunnels.
	TunnelDir string

	// Verbose is optional. If there is a non-null Logger and this
	// field is true, then the Logger will also receive Debug messages,
	// otherwise it will not receive such messages.
	Verbose bool
}

// Session contains shared state for running experiments and/or other
// OONI related task (e.g. geolocation). Note that the Session isn't
// mean to be a long living object. The workflow is to create a Session,
// do the operations you need to do with it now, then make sure it is
// not referenced by other variables, so the Go GC can finalize it. This
// is what you would normally done with Java/ObjC.
type Session struct {
	// Hooks for testing (should not appear in Java/ObjC, because they
	// cannot be automatically transformed to Java/ObjC code.)
	TestingCheckInBeforeNewProbeServicesClient func(ctx *Context)
	TestingCheckInBeforeCheckIn                func(ctx *Context)

	cl        []context.CancelFunc
	mtx       sync.Mutex
	submitter *probeservices.Submitter
	sessp     *engine.Session
}

// NewSession is like NewSessionWithContext but without context. This
// factory is deprecated and will be removed when we bump the major
// version number of ooni/probe-cli.
func NewSession(config *SessionConfig) (*Session, error) {
	return newSessionWithContext(context.Background(), config)
}

// NewSessionWithContext creates a new session. You should use a session for running
// a set of operations in a relatively short time frame. You SHOULD NOT create
// a single session and keep it all alive for the whole app lifecyle, since
// the Session code is not specifically designed for this use case.
func NewSessionWithContext(ctx *Context, config *SessionConfig) (*Session, error) {
	return newSessionWithContext(ctx.ctx, config)
}

// newSessionWithContext implements NewSessionWithContext.
func newSessionWithContext(ctx context.Context, config *SessionConfig) (*Session, error) {
	kvstore, err := kvstore.NewFS(config.StateDir)
	if err != nil {
		return nil, err
	}

	// We cleanup the assets files used by versions of ooniprobe
	// older than v3.9.0, where we started embedding the assets
	// into the binary and use that directly. This cleanup doesn't
	// remove the whole directory but only known files inside it
	// and then the directory itself, if empty. We explicitly discard
	// the return value as it does not matter to us here.
	_, _ = assetsdir.Cleanup(config.AssetsDir)

	var availableps []model.OOAPIService
	if config.ProbeServicesURL != "" {
		availableps = append(availableps, model.OOAPIService{
			Address: config.ProbeServicesURL,
			Type:    "https",
		})
	}

	// TODO(bassosimone): write tests for this functionality.
	// See https://github.com/ooni/probe/issues/1465.
	var proxyURL *url.URL
	if config.Proxy != "" {
		var err error
		proxyURL, err = url.Parse(config.Proxy)
		if err != nil {
			return nil, err
		}
	}

	engineConfig := engine.SessionConfig{
		AvailableProbeServices: availableps,
		KVStore:                kvstore,
		Logger:                 newLogger(config.Logger, config.Verbose),
		ProxyURL:               proxyURL,
		SoftwareName:           config.SoftwareName,
		SoftwareVersion:        config.SoftwareVersion,
		TempDir:                config.TempDir,
		TunnelDir:              config.TunnelDir,
	}
	sessp, err := engine.NewSession(ctx, engineConfig)
	if err != nil {
		return nil, err
	}
	sess := &Session{sessp: sessp}
	// We use finalizers to reduce the burden of managing the
	// session from languages with a garbage collector.
	runtime.SetFinalizer(sess, sessionFinalizer)
	ActiveSessions.Add(1)
	return sess, nil
}

// sessionFinalizer finalizes a Session. While in general in Go code using a
// finalizer is probably unclean, it seems that using a finalizer when binding
// with Java/ObjC code is actually useful to simplify the apps.
func sessionFinalizer(sess *Session) {
	for _, fn := range sess.cl {
		fn()
	}
	sess.sessp.Close() // ignore return value
	ActiveSessions.Add(-1)
}

// Context is the context of an operation. You use this context
// to cancel a long running operation by calling Cancel(). Because
// you create a Context from a Session and because the Session is
// keeping track of the Context instances it owns, you do don't
// need to call the Cancel method when you're done.
type Context struct {
	cancel context.CancelFunc
	ctx    context.Context
}

// Cancel cancels pending operations using this context. This method
// is idempotent. Calling it more than once is fine. The first invocation
// cancels the context. Subsequent invocations are no-operations.
func (ctx *Context) Cancel() {
	ctx.cancel()
}

// NewContext creates an new interruptible Context.
func (sess *Session) NewContext() *Context {
	return sess.NewContextWithTimeout(-1)
}

// NewContextWithTimeout creates an new interruptible Context that will automatically
// cancel itself after the given timeout. Setting a zero or negative timeout implies
// there is no actual timeout configured for the Context, making this invocation
// equivalent to calling NewContext().
func (sess *Session) NewContextWithTimeout(timeout int64) *Context {
	sess.mtx.Lock()
	defer sess.mtx.Unlock()
	ctx, origcancel := newContext(timeout)
	ActiveContexts.Add(1)
	var once sync.Once
	cancel := func() {
		once.Do(func() {
			ActiveContexts.Add(-1)
			origcancel()
		})
	}
	sess.cl = append(sess.cl, cancel)
	return &Context{cancel: cancel, ctx: ctx}
}

// GeolocateResults contains the results of session.Geolocate.
type GeolocateResults struct {
	// ASN is the autonomous system number.
	ASN string

	// Country is the country code.
	Country string

	// IP is the IP address.
	IP string

	// Org is the commercial name of the ASN.
	Org string
}

// MaybeUpdateResources is a legacy stub. It does nothing. We will
// remove it when we're ready to bump the major number.
func (sess *Session) MaybeUpdateResources(ctx *Context) error {
	return nil
}

// Geolocate performs a geolocate operation and returns the results.
//
// This function locks the session until it's done. That is, no other operation
// can be performed as long as this function is pending.
func (sess *Session) Geolocate(ctx *Context) (*GeolocateResults, error) {
	sess.mtx.Lock()
	defer sess.mtx.Unlock()
	info, err := sess.sessp.LookupLocationContext(ctx.ctx)
	if err != nil {
		return nil, err
	}
	return &GeolocateResults{
		ASN:     info.ASNString(),
		Country: info.CountryCode,
		IP:      info.ProbeIP,
		Org:     info.NetworkName,
	}, nil
}

// SubmitMeasurementResults contains the results of a single measurement submission
// to the OONI backends using the OONI collector API.
type SubmitMeasurementResults struct {
	// UpdateMeasurement is the measurement with updated report ID.
	UpdatedMeasurement string

	// UpdatedReportID is the report ID used for the measurement.
	UpdatedReportID string
}

// Submit submits the given measurement and returns the results.
//
// This function locks the session until it's done. That is, no other operation
// can be performed as long as this function is pending.
func (sess *Session) Submit(ctx *Context, measurement string) (*SubmitMeasurementResults, error) {
	sess.mtx.Lock()
	defer sess.mtx.Unlock()
	if sess.submitter == nil {
		psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
		if err != nil {
			return nil, err
		}
		sess.submitter = probeservices.NewSubmitter(psc, sess.sessp.Logger())
	}
	var mm model.Measurement
	if err := json.Unmarshal([]byte(measurement), &mm); err != nil {
		return nil, err
	}
	if err := sess.submitter.Submit(ctx.ctx, &mm); err != nil {
		return nil, err
	}
	data, err := json.Marshal(mm)
	runtimex.PanicOnError(err, "json.Marshal should not fail here")
	return &SubmitMeasurementResults{
		UpdatedMeasurement: string(data),
		UpdatedReportID:    mm.ReportID,
	}, nil
}

// CheckInConfigWebConnectivity contains WebConnectivity
// configuration for the check-in API.
type CheckInConfigWebConnectivity struct {
	// CategoryCodes contains zero or more category codes (e.g. "HUMR").
	CategoryCodes []string
}

// Add adds a category code to ckw.CategoryCode. This method allows you to
// edit ckw.CategoryCodes, which is inaccessible from Java/ObjC.
func (ckw *CheckInConfigWebConnectivity) Add(cat string) {
	ckw.CategoryCodes = append(ckw.CategoryCodes, cat)
}

func (ckw *CheckInConfigWebConnectivity) toModel() model.OOAPICheckInConfigWebConnectivity {
	return model.OOAPICheckInConfigWebConnectivity{
		CategoryCodes: ckw.CategoryCodes,
	}
}

// CheckInConfig contains configuration for the check-in API.
type CheckInConfig struct {
	// Charging indicates whether the phone is charging.
	Charging bool

	// OnWiFi indicates whether the phone is using the Wi-Fi.
	OnWiFi bool

	// Platform is the mobile platform (e.g. "android")
	Platform string

	// RunType indicates whether this is an automated (model.RunTypeTimed) run
	// or otherwise a manual run initiated by the user.
	RunType string

	// SoftwareName is the name of the application.
	SoftwareName string

	// SoftwareVersion is the version of the application.
	SoftwareVersion string

	// WebConnectivity contains configuration items specific of
	// the WebConnectivity experiment.
	WebConnectivity *CheckInConfigWebConnectivity
}

// CheckInInfoWebConnectivity contains the WebConnectivity
// specific results of the check-in API call.
type CheckInInfoWebConnectivity struct {
	// ReportID is the report ID we should be using.
	ReportID string

	// URLs contains the list of URLs to measure.
	URLs []model.OOAPIURLInfo
}

// URLInfo contains info on a specific URL to measure.
type URLInfo struct {
	// CategoryCode is the URL's category code (e.g. "HUMR").
	CategoryCode string

	// CountryCode is the test list from which this URL
	// comes from (e.g. "IT", "FR").
	CountryCode string

	// URL is the URL itself.
	URL string
}

// Size returns the number of URLs included into the result.
func (ckw *CheckInInfoWebConnectivity) Size() int64 {
	return int64(len(ckw.URLs))
}

// At returns the URLInfo at index idx. Note that this function will
// return nil/null if the index is out of bounds.
func (ckw *CheckInInfoWebConnectivity) At(idx int64) *URLInfo {
	if idx < 0 || int(idx) >= len(ckw.URLs) {
		return nil
	}
	w := ckw.URLs[idx]
	return &URLInfo{
		CategoryCode: w.CategoryCode,
		CountryCode:  w.CountryCode,
		URL:          w.URL,
	}
}

func newCheckInInfoWebConnectivity(ckw *model.OOAPICheckInInfoWebConnectivity) *CheckInInfoWebConnectivity {
	if ckw == nil {
		return nil
	}
	return &CheckInInfoWebConnectivity{
		ReportID: ckw.ReportID,
		URLs:     ckw.URLs,
	}
}

// CheckInInfo contains the result of the check-in API.
type CheckInInfo struct {
	// WebConnectivity contains results that are specific to
	// the WebConnectivity experiment. This field MAY be null
	// if the server's response did not contain any info.
	WebConnectivity *CheckInInfoWebConnectivity
}

// CheckIn calls the check-in API. Both ctx and config MUST NOT be nil. This
// function will fail if config is missing required settings. The return value
// is either an error or a valid CheckInInfo instance. Beware that the returned
// object MAY still contain nil fields depending on the server's response.
//
// This function locks the session until it's done. That is, no other operation
// can be performed as long as this function is pending.
func (sess *Session) CheckIn(ctx *Context, config *CheckInConfig) (*CheckInInfo, error) {
	sess.mtx.Lock()
	defer sess.mtx.Unlock()
	if config.WebConnectivity == nil {
		return nil, errors.New("oonimkall: missing webconnectivity config")
	}
	info, err := sess.sessp.LookupLocationContext(ctx.ctx)
	if err != nil {
		return nil, err
	}
	if sess.TestingCheckInBeforeNewProbeServicesClient != nil {
		sess.TestingCheckInBeforeNewProbeServicesClient(ctx) // for testing
	}
	psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
	if err != nil {
		return nil, err
	}
	if sess.TestingCheckInBeforeCheckIn != nil {
		sess.TestingCheckInBeforeCheckIn(ctx) // for testing
	}
	cfg := model.OOAPICheckInConfig{
		Charging:        config.Charging,
		OnWiFi:          config.OnWiFi,
		Platform:        config.Platform,
		ProbeASN:        info.ASNString(),
		ProbeCC:         info.CountryCode,
		RunType:         model.RunType(config.RunType),
		SoftwareVersion: config.SoftwareVersion,
		WebConnectivity: config.WebConnectivity.toModel(),
	}
	result, err := psc.CheckIn(ctx.ctx, cfg)
	if err != nil {
		return nil, err
	}
	return &CheckInInfo{
		WebConnectivity: newCheckInInfoWebConnectivity(result.WebConnectivity),
	}, nil
}

// URLListConfig contains configuration for fetching the URL list.
type URLListConfig struct {
	Categories  []string // Categories to query for (empty means all)
	CountryCode string   // CountryCode is the optional country code
	Limit       int64    // Max number of URLs (<= 0 means no limit)
}

// URLListResult contains the URLs returned from the FetchURL API
type URLListResult struct {
	Results []model.OOAPIURLInfo
}

// AddCategory adds category code to the array in URLListConfig
func (ckw *URLListConfig) AddCategory(cat string) {
	ckw.Categories = append(ckw.Categories, cat)
}

// At gets the URLInfo at position idx from CheckInInfoWebConnectivity.URLs. It returns
// nil if you are using an outs of bound index.
func (ckw *URLListResult) At(idx int64) *URLInfo {
	if idx < 0 || int(idx) >= len(ckw.Results) {
		return nil
	}
	w := ckw.Results[idx]
	return &URLInfo{
		CategoryCode: w.CategoryCode,
		CountryCode:  w.CountryCode,
		URL:          w.URL,
	}
}

// Size returns the number of URLs.
func (ckw *URLListResult) Size() int64 {
	return int64(len(ckw.Results))
}

// FetchURLList fetches the list of URLs to test
func (sess *Session) FetchURLList(ctx *Context, config *URLListConfig) (*URLListResult, error) {
	sess.mtx.Lock()
	defer sess.mtx.Unlock()
	psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
	if err != nil {
		return nil, err
	}
	if config.CountryCode == "" {
		config.CountryCode = "XX"
		info, err := sess.sessp.LookupLocationContext(ctx.ctx)
		// TODO(bassosimone): this piece of code feels wrong to me. We don't
		// want to continue if we cannot discover the country.
		if err == nil && info != nil {
			config.CountryCode = info.CountryCode
		}
	}
	cfg := model.OOAPIURLListConfig{
		Categories:  config.Categories,
		CountryCode: config.CountryCode,
		Limit:       config.Limit,
	}
	result, err := psc.FetchURLList(ctx.ctx, cfg)
	if err != nil {
		return nil, err
	}
	return &URLListResult{
		Results: result,
	}, nil
}
