package wait

import (
	"context"
	"errors"
	"fmt"
	"net"
	"os"
	"time"

	"github.com/docker/go-connections/nat"

	"github.com/testcontainers/testcontainers-go/log"
)

const (
	exitEaccess     = 126 // container cmd can't be invoked (permission denied)
	exitCmdNotFound = 127 // container cmd not found/does not exist or invalid bind-mount
)

// Implement interface
var (
	_ Strategy        = (*HostPortStrategy)(nil)
	_ StrategyTimeout = (*HostPortStrategy)(nil)
)

var (
	errShellNotExecutable = errors.New("/bin/sh command not executable")
	errShellNotFound      = errors.New("/bin/sh command not found")
)

type HostPortStrategy struct {
	// Port is a string containing port number and protocol in the format "80/tcp"
	// which
	Port nat.Port
	// all WaitStrategies should have a startupTimeout to avoid waiting infinitely
	timeout      *time.Duration
	PollInterval time.Duration

	// skipInternalCheck is a flag to skip the internal check, which is useful when
	// a shell is not available in the container or when the container doesn't bind
	// the port internally until additional conditions are met.
	skipInternalCheck bool

	// skipExternalCheck is a flag to skip the external check, which, if used with
	// skipInternalCheck, makes strategy waiting only for port mapping completion
	// without accessing port.
	skipExternalCheck bool
}

// NewHostPortStrategy constructs a default host port strategy that waits for the given
// port to be exposed. The default startup timeout is 60 seconds.
func NewHostPortStrategy(port nat.Port) *HostPortStrategy {
	return &HostPortStrategy{
		Port:         port,
		PollInterval: defaultPollInterval(),
	}
}

// fluent builders for each property
// since go has neither covariance nor generics, the return type must be the type of the concrete implementation
// this is true for all properties, even the "shared" ones like startupTimeout

// ForListeningPort returns a host port strategy that waits for the given port
// to be exposed and bound internally the container.
// Alias for `NewHostPortStrategy(port)`.
func ForListeningPort(port nat.Port) *HostPortStrategy {
	return NewHostPortStrategy(port)
}

// ForExposedPort returns a host port strategy that waits for the first port
// to be exposed and bound internally the container.
func ForExposedPort() *HostPortStrategy {
	return NewHostPortStrategy("")
}

// ForMappedPort returns a host port strategy that waits for the given port
// to be mapped without accessing the port itself.
func ForMappedPort(port nat.Port) *HostPortStrategy {
	return NewHostPortStrategy(port).SkipInternalCheck().SkipExternalCheck()
}

// SkipInternalCheck changes the host port strategy to skip the internal check,
// which is useful when a shell is not available in the container or when the
// container doesn't bind the port internally until additional conditions are met.
func (hp *HostPortStrategy) SkipInternalCheck() *HostPortStrategy {
	hp.skipInternalCheck = true

	return hp
}

// SkipExternalCheck changes the host port strategy to skip the external check,
// which, if used with SkipInternalCheck, makes strategy waiting only for port
// mapping completion without accessing port.
func (hp *HostPortStrategy) SkipExternalCheck() *HostPortStrategy {
	hp.skipExternalCheck = true

	return hp
}

// WithStartupTimeout can be used to change the default startup timeout
func (hp *HostPortStrategy) WithStartupTimeout(startupTimeout time.Duration) *HostPortStrategy {
	hp.timeout = &startupTimeout
	return hp
}

// WithPollInterval can be used to override the default polling interval of 100 milliseconds
func (hp *HostPortStrategy) WithPollInterval(pollInterval time.Duration) *HostPortStrategy {
	hp.PollInterval = pollInterval
	return hp
}

func (hp *HostPortStrategy) Timeout() *time.Duration {
	return hp.timeout
}

// detectInternalPort returns the lowest internal port that is currently bound.
// If no internal port is found, it returns the zero nat.Port value which
// can be checked against an empty string.
func (hp *HostPortStrategy) detectInternalPort(ctx context.Context, target StrategyTarget) (nat.Port, error) {
	var internalPort nat.Port
	inspect, err := target.Inspect(ctx)
	if err != nil {
		return internalPort, fmt.Errorf("inspect: %w", err)
	}

	for port := range inspect.NetworkSettings.Ports {
		if internalPort == "" || port.Int() < internalPort.Int() {
			internalPort = port
		}
	}

	return internalPort, nil
}

// WaitUntilReady implements Strategy.WaitUntilReady
func (hp *HostPortStrategy) WaitUntilReady(ctx context.Context, target StrategyTarget) error {
	timeout := defaultStartupTimeout()
	if hp.timeout != nil {
		timeout = *hp.timeout
	}

	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	waitInterval := hp.PollInterval

	internalPort := hp.Port
	i := 0
	if internalPort == "" {
		var err error
		// Port is not specified, so we need to detect it.
		internalPort, err = hp.detectInternalPort(ctx, target)
		if err != nil {
			return fmt.Errorf("detect internal port: %w", err)
		}

		for internalPort == "" {
			select {
			case <-ctx.Done():
				return fmt.Errorf("detect internal port: retries: %d, last err: %w, ctx err: %w", i, err, ctx.Err())
			case <-time.After(waitInterval):
				if err := checkTarget(ctx, target); err != nil {
					return fmt.Errorf("detect internal port: check target: retries: %d, last err: %w", i, err)
				}

				internalPort, err = hp.detectInternalPort(ctx, target)
				if err != nil {
					return fmt.Errorf("detect internal port: %w", err)
				}
			}
		}
	}

	port, err := target.MappedPort(ctx, internalPort)
	i = 0

	for port == "" {
		i++

		select {
		case <-ctx.Done():
			return fmt.Errorf("mapped port: retries: %d, port: %q, last err: %w, ctx err: %w", i, port, err, ctx.Err())
		case <-time.After(waitInterval):
			if err := checkTarget(ctx, target); err != nil {
				return fmt.Errorf("mapped port: check target: retries: %d, port: %q, last err: %w", i, port, err)
			}
			port, err = target.MappedPort(ctx, internalPort)
			if err != nil {
				log.Printf("mapped port: retries: %d, port: %q, err: %s\n", i, port, err)
			}
		}
	}

	if !hp.skipExternalCheck {
		ipAddress, err := target.Host(ctx)
		if err != nil {
			return fmt.Errorf("host: %w", err)
		}

		if err := externalCheck(ctx, ipAddress, port, target, waitInterval); err != nil {
			return fmt.Errorf("external check: %w", err)
		}
	}

	if hp.skipInternalCheck {
		return nil
	}

	if err = internalCheck(ctx, internalPort, target); err != nil {
		switch {
		case errors.Is(err, errShellNotExecutable):
			log.Printf("Shell not executable in container, only external port validated")
			return nil
		case errors.Is(err, errShellNotFound):
			log.Printf("Shell not found in container")
			return nil
		default:
			return fmt.Errorf("internal check: %w", err)
		}
	}

	return nil
}

func externalCheck(ctx context.Context, ipAddress string, port nat.Port, target StrategyTarget, waitInterval time.Duration) error {
	proto := port.Proto()

	dialer := net.Dialer{}
	address := net.JoinHostPort(ipAddress, port.Port())
	for i := 0; ; i++ {
		if err := checkTarget(ctx, target); err != nil {
			return fmt.Errorf("check target: retries: %d address: %s: %w", i, address, err)
		}
		conn, err := dialer.DialContext(ctx, proto, address)
		if err != nil {
			var v *net.OpError
			if errors.As(err, &v) {
				var v2 *os.SyscallError
				if errors.As(v.Err, &v2) {
					if isConnRefusedErr(v2.Err) {
						time.Sleep(waitInterval)
						continue
					}
				}
			}
			return fmt.Errorf("dial: %w", err)
		}

		conn.Close()
		return nil
	}
}

func internalCheck(ctx context.Context, internalPort nat.Port, target StrategyTarget) error {
	command := buildInternalCheckCommand(internalPort.Int())
	for {
		if ctx.Err() != nil {
			return ctx.Err()
		}
		if err := checkTarget(ctx, target); err != nil {
			return err
		}
		exitCode, _, err := target.Exec(ctx, []string{"/bin/sh", "-c", command})
		if err != nil {
			return fmt.Errorf("%w, host port waiting failed", err)
		}

		// Docker has an issue which override exit code 127 to 126 due to:
		// https://github.com/moby/moby/issues/45795
		// Handle both to ensure compatibility with Docker and Podman for now.
		switch exitCode {
		case 0:
			return nil
		case exitEaccess:
			return errShellNotExecutable
		case exitCmdNotFound:
			return errShellNotFound
		}
	}
}

func buildInternalCheckCommand(internalPort int) string {
	command := `(
					cat /proc/net/tcp* | awk '{print $2}' | grep -i :%04x ||
					nc -vz -w 1 localhost %d ||
					/bin/sh -c '</dev/tcp/localhost/%d'
				)
				`
	return "true && " + fmt.Sprintf(command, internalPort, internalPort, internalPort)
}
