// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package runtime

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"sync"
	"time"

	backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types"
	pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata"
	"go.woodpecker-ci.org/woodpecker/v3/pipeline/state"
)

// executeStep is the single entry point called per step from runStage.
// It checks whether the step should be skipped, emits a "started" trace,
// sets up drone-compat env vars, then hands off to blocking or detached execution.
func (r *Runtime) executeStep(runnerCtx context.Context, step *backend_types.Step) error {
	logger := r.makeLogger()
	logger.Debug().Str("step", step.Name).Msg("prepare")

	if r.shouldSkipStep(step) {
		// Trace the skip so the server marks the step as skipped immediately,
		// rather than leaving it in "pending" until workflow Done.
		return r.traceStep(&backend_types.State{Skipped: true}, nil, step)
	}

	// Emit a "step started" trace before doing any real work.
	if err := r.traceStep(nil, nil, step); err != nil {
		return err
	}

	// Set runtime specific step environment
	if err := r.setStepEnv(step); err != nil {
		return err
	}

	logger.Debug().Str("step", step.Name).Msg("executing")

	if step.Detached {
		return r.runDetachedStep(runnerCtx, step)
	}
	return r.runBlockingStep(runnerCtx, step)
}

// shouldSkipStep returns true when the step should not run based on the current
// pipeline error state and the step's OnSuccess / OnFailure flags.
// It logs the reason for skipping before returning.
func (r *Runtime) shouldSkipStep(step *backend_types.Step) bool {
	logger := r.makeLogger()
	currentErr := r.err.Get()

	if currentErr != nil && !step.OnFailure {
		logger.Debug().
			Str("step", step.Name).
			Err(currentErr).
			Msgf("skipped due to OnFailure=%t", step.OnFailure)
		return true
	}

	if currentErr == nil && !step.OnSuccess {
		logger.Debug().
			Str("step", step.Name).
			Msgf("skipped due to OnSuccess=%t", step.OnSuccess)
		return true
	}

	return false
}

// setStepEnv sets runtime specific step environment variables.
// It also adds the drone plugin compatibility layer.
func (r *Runtime) setStepEnv(step *backend_types.Step) error {
	if step.Environment == nil {
		return fmt.Errorf("step %q (%q) has no environment variables initialized", step.Name, step.UUID)
	}

	// Add compatibility environment variables for drone-ci plugins.
	if step.Type == backend_types.StepTypePlugin {
		metadata.SetDroneEnviron(step.Environment)
	}

	if r.err.Get() != nil {
		step.Environment["CI_PIPELINE_STATUS"] = "failure"
	} else {
		step.Environment["CI_PIPELINE_STATUS"] = "success"
	}
	step.Environment["CI_PIPELINE_STARTED"] = strconv.FormatInt(r.started, 10)
	step.Environment["CI_STEP_STARTED"] = strconv.FormatInt(time.Now().Unix(), 10)
	step.Environment["CI_STEP_TYPE"] = string(step.Type)
	step.Environment["CI_STEP_NAME"] = step.Name

	return nil
}

// startStep starts the step container and spawns a goroutine to stream its logs.
// It returns:
//   - waitForLogs: must be called before WaitStep — it blocks until the log stream
//     is fully drained. Some backends (e.g. local) close the log stream when
//     WaitStep is called, so draining first is required.
//   - startTime: unix timestamp recorded right after the container started, used
//     later to fill waitState.Started.
//
// If StartStep or TailStep fail, startStep returns a non-nil error and the caller
// must not call waitForLogs.
func (r *Runtime) startStep(step *backend_types.Step) (func(), int64, error) {
	if err := r.engine.StartStep(r.ctx, step, r.taskUUID); err != nil {
		return nil, 0, err
	}
	startTime := time.Now().Unix()

	rc, err := r.engine.TailStep(r.ctx, step, r.taskUUID)
	if err != nil {
		return nil, 0, err
	}

	var wg sync.WaitGroup
	wg.Go(func() {
		logger := r.makeLogger()
		if err := r.logger(step, rc); err != nil {
			logger.Error().Err(err).Str("step", step.Name).Msg("step log streaming failed")
		}
		_ = rc.Close()
	})

	return wg.Wait, startTime, nil
}

// completeStep drains the log stream, waits for the process to exit, destroys
// the container, and maps exit conditions (OOM kill, non-zero exit code, context
// cancellation) to typed errors.
//
// The runnerCtx is intentionally used for DestroyStep so that container cleanup can
// still reach the backend even after the workflow context (r.ctx) is canceled.
func (r *Runtime) completeStep(runnerCtx context.Context, step *backend_types.Step, waitForLogs func(), startTime int64) (*backend_types.State, error) {
	// Drain the log stream before waiting on the process exit.
	waitForLogs()

	waitState, err := r.engine.WaitStep(r.ctx, step, r.taskUUID) //nolint:contextcheck
	if err != nil {
		if errors.Is(err, context.Canceled) {
			if waitState == nil {
				waitState = &backend_types.State{}
			}
			waitState.Error = pipeline_errors.ErrCancel
		} else {
			return nil, err
		}
	}

	// Use runnerCtx here: the workflow context may already be canceled but we
	// still need to reach the backend to stop/remove the container.
	if err := r.engine.DestroyStep(runnerCtx, step, r.taskUUID); err != nil {
		return nil, err
	}

	waitState.Started = startTime

	// Re-check context cancellation: the wait may have raced with cancellation.
	if ctxErr := r.ctx.Err(); ctxErr != nil && errors.Is(ctxErr, context.Canceled) {
		waitState.Error = pipeline_errors.ErrCancel
	}

	if waitState.OOMKilled {
		return waitState, &pipeline_errors.OomError{
			UUID: step.UUID,
			Code: waitState.ExitCode,
		}
	}
	if waitState.ExitCode != 0 {
		return waitState, &pipeline_errors.ExitError{
			UUID: step.UUID,
			Code: waitState.ExitCode,
		}
	}

	return waitState, nil
}

// runBlockingStep starts the step and blocks until it fully completes.
// The error is traced and returned to runStage, which feeds it into the
// stage error group.
func (r *Runtime) runBlockingStep(runnerCtx context.Context, step *backend_types.Step) error {
	logger := r.makeLogger()

	waitForLogs, startTime, err := r.startStep(step)
	if err != nil {
		// The step never ran — trace the start failure and surface it.
		return r.traceStep(nil, err, step)
	}

	processState, err := r.completeStep(runnerCtx, step, waitForLogs, startTime)
	logger.Debug().Str("step", step.Name).Msg("complete")

	if errors.Is(err, context.Canceled) {
		err = pipeline_errors.ErrCancel
	}

	err = r.traceStep(processState, err, step)
	if err != nil && metadata.Failure(step.Failure) == metadata.FailureIgnore {
		return nil
	}
	return err
}

// runDetachedStep starts the step and returns as soon as the container is running
// and log streaming is set up. The rest of the step lifecycle runs in the background.
//
// Any error that occurs after setup is logged but not propagated — it cannot
// influence the pipeline outcome at that point.
func (r *Runtime) runDetachedStep(runnerCtx context.Context, step *backend_types.Step) error {
	waitForLogs, startTime, err := r.startStep(step)
	if err != nil {
		// Setup failed before the container was running — treat it like a
		// blocking failure so the pipeline is aware.
		return r.traceStep(nil, err, step)
	}

	// Container is up and logging is streaming — hand off to background.
	r.uploadWait.Add(1)
	go func() {
		defer r.uploadWait.Done()

		logger := r.makeLogger()

		processState, err := r.completeStep(runnerCtx, step, waitForLogs, startTime)
		logger.Debug().Str("step", step.Name).Msg("complete")

		if errors.Is(err, context.Canceled) {
			err = pipeline_errors.ErrCancel
		}
		if err != nil {
			logger.Error().Err(err).Str("step", step.Name).Msg("detached step failed while running")
		}

		if traceErr := r.traceStep(processState, err, step); traceErr != nil {
			logger.Error().Err(traceErr).Str("step", step.Name).Msg("failed to trace detached step result")
		}
	}()

	return nil
}

// traceStep reports the current state of a step to the tracer.
//
//   - processState == nil, err == nil  →  step is being marked as started
//   - processState == nil, err != nil  →  step failed to start
//   - processState != nil              →  step has finished (err may or may not be set)
//
// Always returns err unchanged so callers can write: return r.traceStep(state, err, step).
func (r *Runtime) traceStep(processState *backend_types.State, err error, step *backend_types.Step) error {
	s := new(state.State)
	s.Workflow.Started = r.started
	s.CurrStep = step
	s.Workflow.Error = r.err.Get()

	switch {
	case processState == nil && err != nil:
		// Step failed to start — create an dummy exited process state.
		s.CurrStepState = backend_types.State{
			Error:     err,
			Exited:    true,
			OOMKilled: false,
		}
	case processState != nil:
		s.CurrStepState = *processState
		// processState == nil && err == nil: step just started, leave s.CurrStepState zero-valued.
	}

	if traceErr := r.tracer.Trace(s); traceErr != nil {
		logger := r.makeLogger()
		logger.Error().Err(traceErr).Msg("could not trace step state change")
	}

	return err
}
