// Package jobs manages background jobs that the rc is running.
package jobs

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"runtime/debug"
	"slices"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/google/uuid"
	"github.com/rclone/rclone/fs"
	"github.com/rclone/rclone/fs/accounting"
	"github.com/rclone/rclone/fs/cache"
	"github.com/rclone/rclone/fs/rc"
	"golang.org/x/sync/errgroup"
)

// Fill in these to avoid circular dependencies
func init() {
	cache.JobOnFinish = OnFinish
	cache.JobGetJobID = GetJobID
}

// Job describes an asynchronous task started via the rc package
type Job struct {
	mu        sync.Mutex
	ID        int64     `json:"id"`
	ExecuteID string    `json:"executeId"`
	Group     string    `json:"group"`
	StartTime time.Time `json:"startTime"`
	EndTime   time.Time `json:"endTime"`
	Error     string    `json:"error"`
	Finished  bool      `json:"finished"`
	Success   bool      `json:"success"`
	Duration  float64   `json:"duration"`
	Output    rc.Params `json:"output"`
	Stop      func()    `json:"-"`
	listeners []*func()

	// realErr is the Error before printing it as a string, it's used to return
	// the real error to the upper application layers while still printing the
	// string error message.
	realErr error
}

// mark the job as finished
func (job *Job) finish(out rc.Params, err error) {
	job.mu.Lock()
	job.EndTime = time.Now()
	if out == nil {
		out = make(rc.Params)
	}
	job.Output = out
	job.Duration = job.EndTime.Sub(job.StartTime).Seconds()
	if err != nil {
		job.realErr = err
		job.Error = err.Error()
		job.Success = false
	} else {
		job.realErr = nil
		job.Error = ""
		job.Success = true
	}
	job.Finished = true

	// Notify listeners that the job is finished
	for i := range job.listeners {
		go (*job.listeners[i])()
	}

	job.mu.Unlock()
	running.kickExpire() // make sure this job gets expired
}

func (job *Job) removeListener(fn *func()) {
	job.mu.Lock()
	defer job.mu.Unlock()
	for i, ln := range job.listeners {
		if ln == fn {
			job.listeners = slices.Delete(job.listeners, i, i+1)
			return
		}
	}
}

// OnFinish adds listener to job that will be triggered when job is finished.
// It returns a function to cancel listening.
func (job *Job) OnFinish(fn func()) func() {
	job.mu.Lock()
	defer job.mu.Unlock()
	if job.Finished {
		go fn()
	} else {
		job.listeners = append(job.listeners, &fn)
	}
	return func() { job.removeListener(&fn) }
}

// run the job until completion writing the return status
func (job *Job) run(ctx context.Context, fn rc.Func, in rc.Params) {
	defer func() {
		if r := recover(); r != nil {
			// Log the full stack trace server-side only - it must not
			// be returned to the rc caller as it leaks internal paths,
			// dependency versions and memory addresses.
			fs.Errorf(nil, "rc: job %d panic: %v\n%s", job.ID, r, string(debug.Stack()))
			job.finish(nil, fmt.Errorf("panic received: %v", r))
		}
	}()
	job.finish(fn(ctx, in))
}

// Jobs describes a collection of running tasks
type Jobs struct {
	mu            sync.RWMutex
	jobs          map[int64]*Job
	opt           *rc.Options
	expireRunning bool
}

var (
	running = newJobs()
	jobID   atomic.Int64
	// executeID is a unique ID for this rclone execution
	executeID = uuid.New().String()
)

// newJobs makes a new Jobs structure
func newJobs() *Jobs {
	return &Jobs{
		jobs: map[int64]*Job{},
		opt:  &rc.Opt,
	}
}

// SetOpt sets the options when they are known
func SetOpt(opt *rc.Options) {
	running.opt = opt
}

// SetInitialJobID allows for setting jobID before starting any jobs.
func SetInitialJobID(id int64) {
	if !jobID.CompareAndSwap(0, id) {
		panic("Setting jobID is only possible before starting any jobs")
	}
}

// kickExpire makes sure Expire is running
func (jobs *Jobs) kickExpire() {
	jobs.mu.Lock()
	defer jobs.mu.Unlock()
	if !jobs.expireRunning {
		time.AfterFunc(time.Duration(jobs.opt.JobExpireInterval), jobs.Expire)
		jobs.expireRunning = true
	}
}

// Expire expires any jobs that haven't been collected
func (jobs *Jobs) Expire() {
	jobs.mu.Lock()
	defer jobs.mu.Unlock()
	now := time.Now()
	for ID, job := range jobs.jobs {
		job.mu.Lock()
		if job.Finished && now.Sub(job.EndTime) > time.Duration(jobs.opt.JobExpireDuration) {
			delete(jobs.jobs, ID)
		}
		job.mu.Unlock()
	}
	if len(jobs.jobs) != 0 {
		time.AfterFunc(time.Duration(jobs.opt.JobExpireInterval), jobs.Expire)
		jobs.expireRunning = true
	} else {
		jobs.expireRunning = false
	}
}

// IDs returns the IDs of the running jobs
func (jobs *Jobs) IDs() (IDs []int64) {
	jobs.mu.RLock()
	defer jobs.mu.RUnlock()
	IDs = []int64{}
	for ID := range jobs.jobs {
		IDs = append(IDs, ID)
	}
	return IDs
}

// Stats returns the IDs of the running and finished jobs
func (jobs *Jobs) Stats() (running []int64, finished []int64) {
	jobs.mu.RLock()
	defer jobs.mu.RUnlock()
	running = []int64{}
	finished = []int64{}
	for jobID := range jobs.jobs {
		if jobs.jobs[jobID].Finished {
			finished = append(finished, jobID)
		} else {
			running = append(running, jobID)
		}
	}
	return running, finished
}

// Get a job with a given ID or nil if it doesn't exist
func (jobs *Jobs) Get(ID int64) *Job {
	jobs.mu.RLock()
	defer jobs.mu.RUnlock()
	return jobs.jobs[ID]
}

// Check to see if the group is set
func getGroup(ctx context.Context, in rc.Params, id int64) (context.Context, string, error) {
	group, err := in.GetString("_group")
	if rc.NotErrParamNotFound(err) {
		return ctx, "", err
	}
	delete(in, "_group")
	if group == "" {
		group = fmt.Sprintf("job/%d", id)
	}
	ctx = accounting.WithStatsGroup(ctx, group)
	return ctx, group, nil
}

// See if _async is set returning a boolean and a possible new context
func getAsync(ctx context.Context, in rc.Params) (context.Context, bool, error) {
	isAsync, err := in.GetBool("_async")
	if rc.NotErrParamNotFound(err) {
		return ctx, false, err
	}
	delete(in, "_async") // remove the async parameter after parsing
	if isAsync {
		// unlink this job from the current context
		ctx = context.Background()
	}
	return ctx, isAsync, nil
}

type jobKeyType struct{}

// Key for adding jobs to ctx
var jobKey = jobKeyType{}

// NewJob creates a Job and executes it, possibly in the background if _async is set
func (jobs *Jobs) NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Job, out rc.Params, err error) {
	id := jobID.Add(1)
	in = in.Copy() // copy input so we can change it

	ctx, isAsync, err := getAsync(ctx, in)
	if err != nil {
		return nil, nil, err
	}

	ctx, err = rc.AddConfig(ctx, in)
	if err != nil {
		return nil, nil, err
	}

	ctx, err = rc.AddFilter(ctx, in)
	if err != nil {
		return nil, nil, err
	}

	ctx, group, err := getGroup(ctx, in, id)
	if err != nil {
		return nil, nil, err
	}

	ctx, cancel := context.WithCancel(ctx)
	stop := func() {
		cancel()
		// Wait for cancel to propagate before returning.
		<-ctx.Done()
	}
	job = &Job{
		ID:        id,
		ExecuteID: executeID,
		Group:     group,
		StartTime: time.Now(),
		Stop:      stop,
	}

	jobs.mu.Lock()
	jobs.jobs[job.ID] = job
	jobs.mu.Unlock()

	// Add the job to the context
	ctx = context.WithValue(ctx, jobKey, job)

	// Mark the context as created from an rc request
	ctx = fs.WithRCRequest(ctx)

	if isAsync {
		go job.run(ctx, fn, in)
		out = make(rc.Params)
		out["jobid"] = job.ID
		out["executeId"] = job.ExecuteID
		err = nil
	} else {
		job.run(ctx, fn, in)
		out = job.Output
		err = job.realErr
	}
	return job, out, err
}

// NewJob creates a Job and executes it on the global job queue,
// possibly in the background if _async is set
func NewJob(ctx context.Context, fn rc.Func, in rc.Params) (job *Job, out rc.Params, err error) {
	return running.NewJob(ctx, fn, in)
}

// OnFinish adds listener to jobid that will be triggered when job is finished.
// It returns a function to cancel listening.
func OnFinish(jobID int64, fn func()) (func(), error) {
	job := running.Get(jobID)
	if job == nil {
		return func() {}, errors.New("job not found")
	}
	return job.OnFinish(fn), nil
}

// GetJob gets the Job from the context if possible
func GetJob(ctx context.Context) (job *Job, ok bool) {
	job, ok = ctx.Value(jobKey).(*Job)
	return job, ok
}

// GetJobID gets the Job from the context if possible
func GetJobID(ctx context.Context) (jobID int64, ok bool) {
	job, ok := GetJob(ctx)
	if !ok {
		return -1, ok
	}
	return job.ID, true
}

func init() {
	rc.Add(rc.Call{
		Path:   "job/status",
		NoAuth: true,
		Fn:     rcJobStatus,
		Title:  "Reads the status of the job ID",
		Help: `Parameters:

- jobid - id of the job (integer).

Results:

- finished - boolean
- duration - time in seconds that the job ran for
- endTime - time the job finished (e.g. "2018-10-26T18:50:20.528746884+01:00")
- error - error from the job or empty string for no error
- finished - boolean whether the job has finished or not
- id - as passed in above
- executeId - rclone instance ID (changes after restart); combined with id uniquely identifies a job
- startTime - time the job started (e.g. "2018-10-26T18:50:20.528336039+01:00")
- success - boolean - true for success false otherwise
- output - output of the job as would have been returned if called synchronously
- progress - output of the progress related to the underlying job
`,
	})
}

// Returns the status of a job
func rcJobStatus(ctx context.Context, in rc.Params) (out rc.Params, err error) {
	jobID, err := in.GetInt64("jobid")
	if err != nil {
		return nil, err
	}
	job := running.Get(jobID)
	if job == nil {
		return nil, errors.New("job not found")
	}
	job.mu.Lock()
	defer job.mu.Unlock()
	out = make(rc.Params)
	err = rc.Reshape(&out, job)
	if err != nil {
		return nil, fmt.Errorf("reshape failed in job status: %w", err)
	}
	return out, nil
}

func init() {
	rc.Add(rc.Call{
		Path:   "job/list",
		NoAuth: true,
		Fn:     rcJobList,
		Title:  "Lists the IDs of the running jobs",
		Help: `Parameters: None.

Results:

- executeId - string id of rclone executing (change after restart)
- jobids - array of integer job ids (starting at 1 on each restart)
- runningIds - array of integer job ids that are running
- finishedIds - array of integer job ids that are finished
`,
	})
}

// Returns list of job ids.
func rcJobList(ctx context.Context, in rc.Params) (out rc.Params, err error) {
	out = make(rc.Params)
	out["jobids"] = running.IDs()
	runningIDs, finishedIDs := running.Stats()
	out["runningIds"] = runningIDs
	out["finishedIds"] = finishedIDs
	out["executeId"] = executeID
	return out, nil
}

func init() {
	rc.Add(rc.Call{
		Path:  "job/stop",
		Fn:    rcJobStop,
		Title: "Stop the running job",
		Help: `Parameters:

- jobid - id of the job (integer).
`,
	})
}

// Stops the running job.
func rcJobStop(ctx context.Context, in rc.Params) (out rc.Params, err error) {
	jobID, err := in.GetInt64("jobid")
	if err != nil {
		return nil, err
	}
	job := running.Get(jobID)
	if job == nil {
		return nil, errors.New("job not found")
	}
	job.mu.Lock()
	defer job.mu.Unlock()
	out = make(rc.Params)
	job.Stop()
	return out, nil
}

func init() {
	rc.Add(rc.Call{
		Path:  "job/stopgroup",
		Fn:    rcGroupStop,
		Title: "Stop all running jobs in a group",
		Help: `Parameters:

- group - name of the group (string).
`,
	})
}

// Stops all running jobs in a group
func rcGroupStop(ctx context.Context, in rc.Params) (out rc.Params, err error) {
	group, err := in.GetString("group")
	if err != nil {
		return nil, err
	}
	running.mu.RLock()
	defer running.mu.RUnlock()
	for _, job := range running.jobs {
		if job.Group == group {
			job.mu.Lock()
			job.Stop()
			job.mu.Unlock()
		}
	}
	out = make(rc.Params)
	return out, nil
}

// NewJobFromParams creates an rc job rc.Params.
//
// The JSON blob should contain a _path entry.
//
// It returns a rc.Params as output which may be an error.
func NewJobFromParams(ctx context.Context, in rc.Params) (out rc.Params) {
	path := "unknown"

	// Return an rc error blob
	rcError := func(err error, status int) rc.Params {
		fs.Errorf(nil, "rc: %q: error: %v", path, err)
		out, _ = rc.Error(path, in, err, status)
		return out
	}

	// Find the call
	path, err := in.GetString("_path")
	if err != nil {
		return rcError(err, http.StatusNotFound)
	}
	delete(in, "_path")
	call := rc.Calls.Get(path)
	if call == nil {
		return rcError(fmt.Errorf("couldn't find path %q", path), http.StatusNotFound)
	}
	if call.NeedsRequest {
		return rcError(fmt.Errorf("can't run path %q as it needs the request", path), http.StatusBadRequest)
	}
	if call.NeedsResponse {
		return rcError(fmt.Errorf("can't run path %q as it needs the response", path), http.StatusBadRequest)
	}

	// Pass on the group if one is set in the context and it isn't set in the input.
	if _, found := in["_group"]; !found {
		group, ok := accounting.StatsGroupFromContext(ctx)
		if ok {
			in["_group"] = group
		}
	}

	fs.Debugf(nil, "rc: %q: with parameters %+v", path, in)
	_, out, err = NewJob(ctx, call.Fn, in)
	if err != nil {
		return rcError(err, http.StatusInternalServerError)
	}
	if out == nil {
		out = make(rc.Params)
	}

	fs.Debugf(nil, "rc: %q: reply %+v: %v", path, out, err)
	return out
}

// NewJobFromBytes creates an rc job from a JSON blob as bytes.
//
// The JSON blob should contain a _path entry.
//
// It returns a JSON blob as output which may be an error.
func NewJobFromBytes(ctx context.Context, inBuf []byte) (outBuf []byte) {
	var in rc.Params
	var out rc.Params

	// Parse a JSON blob from the input
	err := json.Unmarshal(inBuf, &in)
	if err != nil {
		out, _ = rc.Error("unknown", in, err, http.StatusBadRequest)
	} else {
		out = NewJobFromParams(ctx, in)
	}

	var w bytes.Buffer
	err = rc.WriteJSON(&w, out)
	if err != nil {
		fs.Errorf(nil, "rc: NewJobFromBytes: failed to write JSON output: %v", err)
		return []byte(`{"error":"failed to write JSON output"}`)
	}
	return w.Bytes()
}

func init() {
	rc.Add(rc.Call{
		Path:  "job/batch",
		Fn:    rcBatch,
		Title: "Run a batch of rclone rc commands concurrently.",
		Help: strings.ReplaceAll(`
This takes the following parameters:

- concurrency - int - do this many commands concurrently. Defaults to |--transfers| if not set.
- inputs - an list of inputs to the commands with an extra |_path| parameter

|||json
{
    "_path": "rc/path",
    "param1": "parameter for the path as documented",
    "param2": "parameter for the path as documented, etc",
}
|||

The inputs may use |_async|, |_group|, |_config| and |_filter| as normal when using the rc.

Returns:

- results - a list of results from the commands with one entry for each in inputs.

For example:

|||sh
rclone rc job/batch --json '{
  "inputs": [
    {
      "_path": "rc/noop",
      "parameter": "OK"
    },
    {
      "_path": "rc/error",
      "parameter": "BAD"
    }
  ]
}
'
|||

Gives the result:

|||json
{
  "results": [
    {
      "parameter": "OK"
    },
    {
      "error": "arbitrary error on input map[parameter:BAD]",
      "input": {
        "parameter": "BAD"
      },
      "path": "rc/error",
      "status": 500
    }
  ]
}
|||
`, "|", "`"),
	})
}

/*
// Run a single batch job
func runBatchJob(ctx context.Context, inputAny any) (out rc.Params, err error) {
	var in rc.Params
	path := "unknown"
	defer func() {
		if err != nil {
			out, _ = rc.Error(path, in, err, http.StatusInternalServerError)
		}
	}()

	// get the inputs to the job
	input, ok := inputAny.(map[string]any)
	if !ok {
		return nil, rc.NewErrParamInvalid(fmt.Errorf("\"inputs\" items must be objects not %T", inputAny))
	}
	in = rc.Params(input)
	path, err = in.GetString("_path")
	if err != nil {
		return nil, err
	}
	delete(in, "_path")
	call := rc.Calls.Get(path)

	// Check call
	if call == nil {
		return nil, rc.NewErrParamInvalid(fmt.Errorf("path %q does not exist", path))
	}
	path = call.Path
	if call.NeedsRequest {
		return nil, rc.NewErrParamInvalid(fmt.Errorf("can't run path %q as it needs the request", path))
	}
	if call.NeedsResponse {
		return nil, rc.NewErrParamInvalid(fmt.Errorf("can't run path %q as it needs the response", path))
	}

	// Run the job
	_, out, err = NewJob(ctx, call.Fn, in)
	if err != nil {
		return nil, err
	}

	// Reshape (serialize then deserialize) the data so it is in the form expected
	err = rc.Reshape(&out, out)
	if err != nil {
		return nil, err
	}
	return out, nil
        }
*/

// Batch the registered commands
func rcBatch(ctx context.Context, in rc.Params) (out rc.Params, err error) {
	out = make(rc.Params)

	// Read inputs
	inputsAny, err := in.Get("inputs")
	if err != nil {
		return nil, err
	}
	inputs, ok := inputsAny.([]any)
	if !ok {
		return nil, rc.NewErrParamInvalid(fmt.Errorf("expecting list key %q (was %T)", "inputs", inputsAny))
	}

	// Read concurrency
	concurrency, err := in.GetInt64("concurrency")
	if rc.IsErrParamNotFound(err) {
		ci := fs.GetConfig(ctx)
		concurrency = int64(ci.Transfers)
	} else if err != nil {
		return nil, err
	}

	// Prepare outputs
	results := make([]rc.Params, len(inputs))
	out["results"] = results

	g, gCtx := errgroup.WithContext(ctx)
	g.SetLimit(int(concurrency))
	for i, inputAny := range inputs {
		input, ok := inputAny.(map[string]any)
		if !ok {
			results[i], _ = rc.Error("unknown", nil, fmt.Errorf("\"inputs\" items must be objects not %T", inputAny), http.StatusBadRequest)
			continue
		}
		in := rc.Params(input)
		if concurrency <= 1 {
			results[i] = NewJobFromParams(ctx, in)
		} else {
			g.Go(func() error {
				results[i] = NewJobFromParams(gCtx, in)
				return nil
			})
		}
	}
	_ = g.Wait()
	return out, nil
}
