// Package torsf contains the torsf experiment.
//
// See https://github.com/ooni/spec/blob/master/nettests/ts-030-torsf.md
package torsf

import (
	"context"
	"errors"
	"fmt"
	"path"
	"time"

	"github.com/ooni/probe-cli/v3/internal/bytecounter"
	"github.com/ooni/probe-cli/v3/internal/model"
	"github.com/ooni/probe-cli/v3/internal/ptx"
	"github.com/ooni/probe-cli/v3/internal/runtimex"
	"github.com/ooni/probe-cli/v3/internal/torlogs"
	"github.com/ooni/probe-cli/v3/internal/tracex"
	"github.com/ooni/probe-cli/v3/internal/tunnel"
)

// Implementation note: this file is written with easy diffing with respect
// to internal/engine/experiment/vanillator/vanillator.go in mind.
//
// We may want to have a single implementation for both nettests in the future.

// testVersion is the experiment version.
const testVersion = "0.3.0"

// Config contains the experiment config.
type Config struct {
	// DisablePersistentDatadir disables using a persistent datadir.
	DisablePersistentDatadir bool `ooni:"Disable using a persistent tor datadir"`

	// DisableProgress disables printing progress messages.
	DisableProgress bool `ooni:"Disable printing progress messages"`

	// RendezvousMethod allows to choose the method with which to rendezvous.
	RendezvousMethod string `ooni:"Choose the method with which to rendezvous. Must be one of amp and domain_fronting. Leaving this field empty means we should use the default."`
}

// TestKeys contains the experiment's result.
type TestKeys struct {
	// BootstrapTime contains the bootstrap time on success.
	BootstrapTime float64 `json:"bootstrap_time"`

	// Error is one of `null`, `"timeout-reached"`, and `"unknown-error"` (this
	// field exists for backward compatibility with the previous
	// `vanilla_tor` implementation).
	Error *string `json:"error"`

	// Failure contains the failure string or nil.
	Failure *string `json:"failure"`

	// Success indicates whether we succeded (this field exists for
	// backward compatibility with the previous `vanilla_tor` implementation).
	Success bool `json:"success"`

	// PersistentDatadir indicates whether we're using a persistent tor datadir.
	PersistentDatadir bool `json:"persistent_datadir"`

	// RendezvousMethod contains the method used to perform the rendezvous.
	RendezvousMethod string `json:"rendezvous_method"`

	// Timeout contains the default timeout for this experiment
	Timeout float64 `json:"timeout"`

	// TorLogs contains the bootstrap logs.
	TorLogs []string `json:"tor_logs"`

	// TorProgress contains the percentage of the maximum progress reached.
	TorProgress int64 `json:"tor_progress"`

	// TorProgressTag contains the tag of the maximum progress reached.
	TorProgressTag string `json:"tor_progress_tag"`

	// TorProgressSummary contains the summary of the maximum progress reached.
	TorProgressSummary string `json:"tor_progress_summary"`

	// TorVersion contains the version of tor (if it's possible to obtain it).
	TorVersion string `json:"tor_version"`

	// TransportName is always set to "snowflake" for this experiment.
	TransportName string `json:"transport_name"`
}

// Measurer performs the measurement.
type Measurer struct {
	// config contains the experiment settings.
	config Config

	// mockStartListener is an optional function that allows us to override
	// the function we actually use to start the ptx listener.
	mockStartListener func() error

	// mockStartTunnel is an optional function that allows us to override the
	// default tunnel.Start function used to start a tunnel.
	mockStartTunnel func(
		ctx context.Context, config *tunnel.Config) (tunnel.Tunnel, tunnel.DebugInfo, error)
}

// ExperimentName implements model.ExperimentMeasurer.ExperimentName.
func (m *Measurer) ExperimentName() string {
	return "torsf"
}

// ExperimentVersion implements model.ExperimentMeasurer.ExperimentVersion.
func (m *Measurer) ExperimentVersion() string {
	return testVersion
}

// registerExtensions registers the extensions used by this experiment.
func (m *Measurer) registerExtensions(measurement *model.Measurement) {
	// currently none
}

// maxRuntime is the maximum runtime for this experiment
const maxRuntime = 600 * time.Second

// Run runs the experiment with the specified context, session,
// measurement, and experiment calbacks. This method should only
// return an error in case the experiment could not run (e.g.,
// a required input is missing). Otherwise, the code should just
// set the relevant OONI error inside of the measurement and
// return nil. This is important because the caller may not submit
// the measurement if this method returns an error.
func (m *Measurer) Run(
	ctx context.Context, sess model.ExperimentSession,
	measurement *model.Measurement, callbacks model.ExperimentCallbacks,
) error {
	ptl, sfdialer, err := m.setup(ctx, sess.Logger())
	if err != nil {
		// we cannot setup the experiment
		return err
	}
	defer ptl.Stop()
	m.registerExtensions(measurement)
	start := time.Now()
	ctx, cancel := context.WithTimeout(ctx, maxRuntime)
	defer cancel()
	tkch := make(chan *TestKeys)
	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()
	go m.bootstrap(ctx, maxRuntime, sess, tkch, ptl, sfdialer)
	for {
		select {
		case tk := <-tkch:
			measurement.TestKeys = tk
			callbacks.OnProgress(1.0, "torsf experiment is finished")
			return nil
		case <-ticker.C:
			if !m.config.DisableProgress {
				elapsedTime := time.Since(start)
				progress := elapsedTime.Seconds() / maxRuntime.Seconds()
				callbacks.OnProgress(progress, fmt.Sprintf(
					"torsf: elapsedTime: %.0f s; maxRuntime: %.0f s",
					elapsedTime.Seconds(), maxRuntime.Seconds()))
			}
		}
	}
}

// setup prepares for running the torsf experiment. Returns a valid ptx listener
// and snowflake dialer on success. Returns an error on failure. On success,
// remember to Stop the ptx listener when you're done.
func (m *Measurer) setup(ctx context.Context,
	logger model.Logger) (*ptx.Listener, *ptx.SnowflakeDialer, error) {
	rm, err := ptx.NewSnowflakeRendezvousMethod(m.config.RendezvousMethod)
	if err != nil {
		// cannot run the experiment with unknown rendezvous method
		return nil, nil, err
	}
	sfdialer := ptx.NewSnowflakeDialerWithRendezvousMethod(rm)
	ptl := &ptx.Listener{
		ExperimentByteCounter: bytecounter.ContextExperimentByteCounter(ctx),
		Logger:                logger,
		PTDialer:              sfdialer,
		SessionByteCounter:    bytecounter.ContextSessionByteCounter(ctx),
	}
	if err := m.startListener(ptl.Start); err != nil {
		// This error condition mostly means "I could not open a local
		// listening port", which strikes as fundamental failure.
		return nil, nil, err
	}
	logger.Infof("torsf: rendezvous method: '%s'", m.config.RendezvousMethod)
	return ptl, sfdialer, nil
}

// values for the backward compatible error field.
var (
	timeoutReachedError = "timeout-reached"
	unknownError        = "unknown-error"
)

// bootstrap runs the bootstrap.
func (m *Measurer) bootstrap(ctx context.Context, timeout time.Duration, sess model.ExperimentSession,
	out chan<- *TestKeys, ptl *ptx.Listener, sfdialer *ptx.SnowflakeDialer) {
	tk := &TestKeys{
		// initialized later
		BootstrapTime:      0,
		Error:              nil,
		Failure:            nil,
		Success:            false,
		TorLogs:            []string{},
		TorProgress:        0,
		TorProgressTag:     "",
		TorProgressSummary: "",
		TorVersion:         "",
		// initialized now
		PersistentDatadir: !m.config.DisablePersistentDatadir,
		RendezvousMethod:  sfdialer.RendezvousMethod.Name(),
		//
		Timeout:       timeout.Seconds(),
		TransportName: "snowflake",
	}
	sess.Logger().Infof(
		"torsf: disable persistent datadir: %+v", m.config.DisablePersistentDatadir)
	defer func() {
		out <- tk
	}()
	tun, debugInfo, err := m.startTunnel()(ctx, &tunnel.Config{
		Name:      "tor",
		Session:   sess,
		TunnelDir: path.Join(m.baseTunnelDir(sess), "torsf"),
		Logger:    sess.Logger(),
		TorArgs: []string{
			"UseBridges", "1",
			"ClientTransportPlugin", ptl.AsClientTransportPluginArgument(),
			"Bridge", sfdialer.AsBridgeArgument(),
		},
	})
	tk.TorVersion = debugInfo.Version
	m.readTorLogs(sess.Logger(), tk, debugInfo.LogFilePath)
	if err != nil {
		// Note: tracex.NewFailure scrubs IP addresses
		tk.Failure = tracex.NewFailure(err)
		if errors.Is(err, context.DeadlineExceeded) {
			tk.Error = &timeoutReachedError
		} else {
			tk.Error = &unknownError
		}
		tk.Success = false
		return
	}
	defer tun.Stop()
	tk.BootstrapTime = tun.BootstrapTime().Seconds()
	tk.Success = true
}

// readTorLogs attempts to read and include the tor logs into
// the test keys if this operation is possible.
func (m *Measurer) readTorLogs(logger model.Logger, tk *TestKeys, logFilePath string) {
	tk.TorLogs = append(tk.TorLogs, torlogs.ReadBootstrapLogsOrWarn(logger, logFilePath)...)
	if len(tk.TorLogs) <= 0 {
		return
	}
	last := tk.TorLogs[len(tk.TorLogs)-1]
	bi, err := torlogs.ParseBootstrapLogLine(last)
	// Implementation note: parsing cannot fail here because we're using the same code
	// for selecting and for parsing the bootstrap logs, so we panic on error.
	runtimex.PanicOnError(err, fmt.Sprintf("cannot parse bootstrap line: %s", last))
	tk.TorProgress = bi.Progress
	tk.TorProgressTag = bi.Tag
	tk.TorProgressSummary = bi.Summary
}

// baseTunnelDir returns the base directory to use for tunnelling
func (m *Measurer) baseTunnelDir(sess model.ExperimentSession) string {
	if m.config.DisablePersistentDatadir {
		return sess.TempDir()
	}
	return sess.TunnelDir()
}

// startListener either calls f or mockStartListener depending
// on whether mockStartListener is nil or not.
func (m *Measurer) startListener(f func() error) error {
	if m.mockStartListener != nil {
		return m.mockStartListener()
	}
	return f()
}

// startTunnel returns the proper function to start a tunnel.
func (m *Measurer) startTunnel() func(
	ctx context.Context, config *tunnel.Config) (tunnel.Tunnel, tunnel.DebugInfo, error) {
	if m.mockStartTunnel != nil {
		return m.mockStartTunnel
	}
	return tunnel.Start
}

// NewExperimentMeasurer creates a new ExperimentMeasurer.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
	return &Measurer{config: config}
}

// SummaryKeys contains summary keys for this experiment.
//
// Note that this structure is part of the ABI contract with ooniprobe
// therefore we should be careful when changing it.
type SummaryKeys struct {
	IsAnomaly bool `json:"-"`
}

var (
	// errInvalidTestKeysType indicates the test keys type is invalid.
	errInvalidTestKeysType = errors.New("torsf: invalid test keys type")

	//errNilTestKeys indicates that the test keys are nil.
	errNilTestKeys = errors.New("torsf: nil test keys")
)

// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys.
func (m *Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) {
	testkeys, good := measurement.TestKeys.(*TestKeys)
	if !good {
		return nil, errInvalidTestKeysType
	}
	if testkeys == nil {
		return nil, errNilTestKeys
	}
	return SummaryKeys{IsAnomaly: testkeys.Failure != nil}, nil
}
