package deploy

import (
	"fmt"
	"math/rand/v2"
	"slices"

	"github.com/psviderski/uncloud/internal/machine/api/pb"
	"github.com/psviderski/uncloud/internal/secret"
	"github.com/psviderski/uncloud/pkg/api"
	"github.com/psviderski/uncloud/pkg/client/deploy/operation"
	"github.com/psviderski/uncloud/pkg/client/deploy/scheduler"
)

// Strategy defines how a service should be deployed or updated. Different implementations can provide various
// deployment patterns such as rolling updates, blue/green deployments, etc.
type Strategy interface {
	// Type returns the type of the deployment strategy, e.g. "rolling", "blue-green".
	Type() string
	// Plan returns the operation to reconcile the service to the desired state.
	// If the service does not exist (new deployment), svc will be nil. state provides the current and planned state
	// of the cluster for scheduling decisions.
	Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (ServicePlan, error)
}

// RollingStrategy implements a rolling update deployment pattern where containers are updated one at a time
// to minimize service disruption.
type RollingStrategy struct {
	// ForceRecreate indicates whether all containers should be recreated during the deployment,
	// regardless of whether their specifications have changed.
	ForceRecreate bool
	// SkipHealthMonitor skips the monitoring period and health checks for faster emergency deployments.
	SkipHealthMonitor bool

	// state is the current and planned state of the cluster used for scheduling decisions.
	state *scheduler.ClusterState
}

func (s *RollingStrategy) Type() string {
	return "rolling"
}

func (s *RollingStrategy) Plan(state *scheduler.ClusterState, svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
	if state == nil {
		return ServicePlan{}, fmt.Errorf("cluster state must be provided")
	}
	s.state = state

	// We can assume that the spec is valid at this point because it has been validated by the deployment.
	switch spec.Mode {
	case api.ServiceModeReplicated:
		return s.planReplicated(svc, spec)
	case api.ServiceModeGlobal:
		return s.planGlobal(svc, spec)
	default:
		return ServicePlan{}, fmt.Errorf("unsupported service mode: '%s'", spec.Mode)
	}
}

// planReplicated creates a plan for a replicated service deployment.
// For replicated services, we want to maintain a specific number of containers (replicas) across the available machines
// in the cluster.
// TODO: schedule containers only on machines that contain the image if pull policy is set to 'never'.
func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
	plan, err := newEmptyServicePlan(svc, spec)
	if err != nil {
		return plan, err
	}

	// Build a machine ID to name map from the cluster state to resolve IDs for operations.
	machineNames := make(map[string]string, len(s.state.Machines))
	for _, m := range s.state.Machines {
		machineNames[m.Info.Id] = m.Info.Name
	}

	sched := scheduler.NewServiceScheduler(s.state, spec)
	// TODO: return a detailed report on required constraints and which ones are satisfied?
	availableMachines, err := sched.EligibleMachines()
	if err != nil {
		return plan, err
	}

	var matchedMachines []*pb.MachineInfo
	for _, m := range availableMachines {
		matchedMachines = append(matchedMachines, m.Info)
	}

	// Randomise the order of machines to avoid always deploying to the same machines first.
	rand.Shuffle(len(matchedMachines), func(i, j int) {
		matchedMachines[i], matchedMachines[j] = matchedMachines[j], matchedMachines[i]
	})

	// Organise existing containers by machine.
	containersOnMachine := make(map[string][]api.ServiceContainer)
	upToDateContainersOnMachine := make(map[string]int)
	containerSpecStatuses := make(map[string]ContainerSpecStatus)
	if svc != nil {
		for _, c := range svc.Containers {
			if !c.Container.State.Running || c.Container.State.Paused {
				// Skip containers that are not running.
				continue
			}

			var status ContainerSpecStatus
			if s.ForceRecreate {
				status = ContainerNeedsRecreate
			} else {
				status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
			}
			containerSpecStatuses[c.Container.ID] = status

			if status == ContainerUpToDate {
				upToDateContainersOnMachine[c.MachineID] += 1
			}
		}

		// Sort containers such that running containers with the desired spec are first.
		slices.SortFunc(svc.Containers, func(c1, c2 api.MachineServiceContainer) int {
			if status, ok := containerSpecStatuses[c1.Container.ID]; ok && status == ContainerUpToDate {
				return -1
			}
			if status, ok := containerSpecStatuses[c2.Container.ID]; ok && status == ContainerUpToDate {
				return 1
			}
			return 0
		})

		for _, c := range svc.Containers {
			containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c.Container)
		}

		// Sort machines such that machines with the most up-to-date containers are first, followed by machines with
		// existing containers, and finally machines without containers.
		slices.SortFunc(matchedMachines, func(m1, m2 *pb.MachineInfo) int {
			if upToDateContainersOnMachine[m1.Id] > 0 && upToDateContainersOnMachine[m2.Id] > 0 {
				return upToDateContainersOnMachine[m2.Id] - upToDateContainersOnMachine[m1.Id]
			}
			if upToDateContainersOnMachine[m1.Id] > 0 {
				return -1
			}
			if upToDateContainersOnMachine[m2.Id] > 0 {
				return 1
			}
			return len(containersOnMachine[m2.Id]) - len(containersOnMachine[m1.Id])
		})
	}

	// Spread the containers across the available machines evenly using a simple round-robin approach, starting with
	// machines that already have containers and prioritising machines with containers that match the desired spec.
	for i := 0; i < int(spec.Replicas); i++ {
		m := matchedMachines[i%len(matchedMachines)]
		containers := containersOnMachine[m.Id]

		if len(containers) == 0 {
			// No more existing containers on this machine, create a new one.
			plan.Operations = append(plan.Operations, &operation.RunContainerOperation{
				ServiceID:         plan.ServiceID,
				Spec:              spec,
				MachineID:         m.Id,
				MachineName:       m.Name,
				SkipHealthMonitor: s.SkipHealthMonitor,
			})
			continue
		}

		ctr := containers[0]
		containersOnMachine[m.Id] = containers[1:]

		if status, ok := containerSpecStatuses[ctr.ID]; ok { // Contains statuses for only running containers.
			if status == ContainerUpToDate {
				continue
			}
			// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
		}

		// Replace the old container with a new one.
		order := determineUpdateOrder(ctr, spec)
		plan.Operations = append(plan.Operations, &operation.ReplaceContainerOperation{
			ServiceID:         plan.ServiceID,
			Spec:              spec,
			MachineID:         m.Id,
			MachineName:       m.Name,
			OldContainer:      ctr,
			Order:             order,
			SkipHealthMonitor: s.SkipHealthMonitor,
			StopGracePeriod:   spec.Container.StopGracePeriod,
		})
	}

	// Remove any remaining containers that are not needed.
	for mid, containers := range containersOnMachine {
		for _, c := range containers {
			plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
				MachineID:       mid,
				MachineName:     machineNames[mid],
				Container:       c,
				StopGracePeriod: spec.Container.StopGracePeriod,
			})
		}
	}

	if ops := s.preDeployOperations(svc, plan); len(ops) > 0 {
		plan.Operations = append(ops, plan.Operations...)
	}

	return plan, nil
}

// planGlobal creates a plan for a global service deployment, ensuring one container runs on each available machine.
// For machines with an existing container, it attempts to start a new container before removing the old one if
// possible. If the new container would have port conflicts with the existing one, the old container is removed first.
// It handles multiple containers per machine (though this should not occur in normal operation) and skips machines
// that are down.
func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
	plan, err := newEmptyServicePlan(svc, spec)
	if err != nil {
		return plan, err
	}

	// Build a machine ID to name map from the cluster state to resolve IDs for operations.
	machineNames := make(map[string]string, len(s.state.Machines))
	for _, m := range s.state.Machines {
		machineNames[m.Info.Id] = m.Info.Name
	}

	// Map machineID to service containers on that machine. For the global mode, there should be at most one
	// container per machine but we use a slice to handle multiple containers that may exist due to a bug
	// or interruption in the previous deployment.
	containersOnMachine := make(map[string][]api.MachineServiceContainer)
	if svc != nil {
		for _, c := range svc.Containers {
			containersOnMachine[c.MachineID] = append(containersOnMachine[c.MachineID], c)
		}
	}

	sched := scheduler.NewServiceScheduler(s.state, spec)
	availableMachines, err := sched.EligibleMachines()
	if err != nil {
		return plan, err
	}

	for _, m := range availableMachines {
		containers := containersOnMachine[m.Info.Id]
		ops, err := reconcileGlobalContainer(
			containers,
			spec,
			plan.ServiceID,
			m.Info,
			s.ForceRecreate,
			s.SkipHealthMonitor,
		)
		if err != nil {
			return plan, err
		}
		plan.Operations = append(plan.Operations, ops...)

		delete(containersOnMachine, m.Info.Id)
	}

	// Remove any remaining containers on machines that don't match the new placement constraints.
	for _, containers := range containersOnMachine {
		for _, c := range containers {
			plan.Operations = append(plan.Operations, &operation.RemoveContainerOperation{
				MachineID:       c.MachineID,
				MachineName:     machineNames[c.MachineID],
				Container:       c.Container,
				StopGracePeriod: spec.Container.StopGracePeriod,
			})
		}
	}

	if ops := s.preDeployOperations(svc, plan); len(ops) > 0 {
		plan.Operations = append(ops, plan.Operations...)
	}

	return plan, nil
}

// reconcileGlobalContainer returns a sequence of operations to reconcile containers on a machine for a global service.
// It ensures exactly one container with the desired spec is running on the machine by creating a new container and
// removing old ones. If there is a host port conflict, it stops the old container before starting a new one.
func reconcileGlobalContainer(
	containers []api.MachineServiceContainer,
	spec api.ServiceSpec,
	serviceID string,
	machine *pb.MachineInfo,
	forceRecreate, skipHealthCheck bool,
) ([]operation.Operation, error) {
	var ops []operation.Operation

	if len(containers) == 0 {
		// No containers on this machine, create a new one.
		ops = append(ops, &operation.RunContainerOperation{
			ServiceID:         serviceID,
			Spec:              spec,
			MachineID:         machine.Id,
			MachineName:       machine.Name,
			SkipHealthMonitor: skipHealthCheck,
		})
		return ops, nil
	}

	// Check if there is a container with the same spec already running. If so, remove the rest.
	upToDate := false
	for i, c := range containers {
		if !c.Container.State.Running || c.Container.State.Paused {
			// Skip containers that are not running.
			continue
		}

		var status ContainerSpecStatus
		if forceRecreate {
			status = ContainerNeedsRecreate
		} else {
			status = EvalContainerSpecChange(c.Container.ServiceSpec, spec)
		}

		if status == ContainerUpToDate {
			// The container is already running with the same spec.
			upToDate = true
			for j, old := range containers {
				if i == j {
					continue
				}
				ops = append(ops, &operation.RemoveContainerOperation{
					MachineID:       old.MachineID,
					MachineName:     machine.Name,
					Container:       old.Container,
					StopGracePeriod: spec.Container.StopGracePeriod,
				})
			}
			break
		}
		// TODO: handle ContainerNeedsUpdate when update of mutable fields on a container is supported.
		//  Make sure to update preDeployOperation accordingly.
	}
	if upToDate {
		return ops, nil
	}

	// The machine has containers but none of them match the new spec.
	// Find the first running container to replace (there should typically be only one).
	var containerToReplace *api.MachineServiceContainer
	for i, c := range containers {
		if c.Container.State.Running {
			containerToReplace = &containers[i]
			break
		}
	}

	if containerToReplace != nil {
		// Stop any other running containers that have conflicting ports before replacing the container.
		// This handles the edge case where multiple running containers exist (due to bugs or interrupted deployments)
		// and more than one has ports that conflict with the new spec.
		for _, c := range containers {
			if c.Container.ID == containerToReplace.Container.ID || !c.Container.State.Running {
				continue
			}
			conflictingPorts, err := c.Container.ConflictingServicePorts(spec.Ports)
			if err != nil || len(conflictingPorts) > 0 {
				ops = append(ops, &operation.StopContainerOperation{
					ServiceID:       serviceID,
					ContainerID:     c.Container.ID,
					MachineID:       machine.Id,
					MachineName:     machine.Name,
					StopGracePeriod: spec.Container.StopGracePeriod,
				})
			}
		}

		// Replace the running container with a new one.
		order := determineUpdateOrder(containerToReplace.Container, spec)
		ops = append(ops, &operation.ReplaceContainerOperation{
			ServiceID:         serviceID,
			Spec:              spec,
			MachineID:         machine.Id,
			MachineName:       machine.Name,
			OldContainer:      containerToReplace.Container,
			Order:             order,
			SkipHealthMonitor: skipHealthCheck,
			StopGracePeriod:   spec.Container.StopGracePeriod,
		})

		// Remove any other containers (there shouldn't be any in normal operation).
		for _, c := range containers {
			if c.Container.ID == containerToReplace.Container.ID {
				continue
			}
			ops = append(ops, &operation.RemoveContainerOperation{
				MachineID:       c.MachineID,
				MachineName:     machine.Name,
				Container:       c.Container,
				StopGracePeriod: spec.Container.StopGracePeriod,
			})
		}
	} else {
		// No running containers, create a new one and remove all stopped containers.
		ops = append(ops, &operation.RunContainerOperation{
			ServiceID:         serviceID,
			Spec:              spec,
			MachineID:         machine.Id,
			MachineName:       machine.Name,
			SkipHealthMonitor: skipHealthCheck,
		})
		for _, c := range containers {
			ops = append(ops, &operation.RemoveContainerOperation{
				MachineID:       c.MachineID,
				MachineName:     machine.Name,
				Container:       c.Container,
				StopGracePeriod: spec.Container.StopGracePeriod,
			})
		}
	}

	return ops, nil
}

// determineUpdateOrder determines the update order for replacing a container based on the service spec
// and current container state. The order can be explicitly set in UpdateConfig, or automatically determined:
// - If the user explicitly set order, respect it
// - Services with port conflicts require stop-first (ports must be freed first)
// - Single-replica services with data volumes default to stop-first (prevents data corruption)
// - Multi-replica services use start-first (concurrent access already happening)
// - All other services default to start-first (minimizes downtime)
func determineUpdateOrder(oldContainer api.ServiceContainer, spec api.ServiceSpec) string {
	// User explicitly set order - respect it
	if spec.UpdateConfig.Order != "" {
		return spec.UpdateConfig.Order
	}

	// Port conflicts require stop-first
	conflictingPorts, err := oldContainer.ConflictingServicePorts(spec.Ports)
	if err != nil || len(conflictingPorts) > 0 {
		return api.UpdateOrderStopFirst
	}

	// Single-replica services with data volumes default to stop-first to prevent data corruption.
	// Multi-replica services already have concurrent access, so start-first is safe.
	if spec.Replicas <= 1 && len(spec.MountedDockerVolumes()) > 0 {
		return api.UpdateOrderStopFirst
	}

	// Default: start-first for minimal downtime
	return api.UpdateOrderStartFirst
}

// preDeployOperations returns operations for the pre-deploy hook if the spec has one and the plan updates the service.
// It prepends StopPreDeployOperations for any running hook containers, followed by a RunPreDeployOperation on the same
// machine as the first run/replace operation.
func (s *RollingStrategy) preDeployOperations(svc *api.Service, plan ServicePlan) []operation.Operation {
	if plan.Spec.PreDeploy == nil {
		return nil
	}

	// Find the first run or replace operation to determine the target machine.
	var machineID, machineName string
	for _, op := range plan.Operations {
		switch o := op.(type) {
		case *operation.RunContainerOperation:
			machineID = o.MachineID
			machineName = o.MachineName
		case *operation.ReplaceContainerOperation:
			machineID = o.MachineID
			machineName = o.MachineName
		default:
			continue
		}
		break
	}

	// Skip the hook as there are no run or replace operations.
	if machineID == "" {
		return nil
	}

	var ops []operation.Operation

	// Collect old pre-deploy container IDs to clean up and stop any that are still running.
	var oldContainerIDs []string
	if svc != nil {
		for _, c := range svc.HookContainers {
			oldContainerIDs = append(oldContainerIDs, c.Container.ID)
			if c.Container.State.Running {
				hookMachineName, _ := s.state.MachineName(c.MachineID)
				ops = append(ops, &operation.StopPreDeployOperation{
					MachineID:   c.MachineID,
					MachineName: hookMachineName,
					Container:   c.Container,
				})
			}
		}
	}

	ops = append(ops, &operation.RunPreDeployOperation{
		ServiceID:       plan.ServiceID,
		Spec:            plan.Spec,
		MachineID:       machineID,
		MachineName:     machineName,
		OldContainerIDs: oldContainerIDs,
	})

	return ops
}

// newEmptyServicePlan creates a new empty plan for a service deployment with initialised service ID and name.
func newEmptyServicePlan(svc *api.Service, spec api.ServiceSpec) (ServicePlan, error) {
	plan := ServicePlan{
		Spec: spec,
	}

	// Generate a new service ID for the initial service deployment if it doesn't exist yet.
	if svc != nil {
		plan.ServiceID = svc.ID
		plan.ServiceName = svc.Name
	} else {
		plan.IsNewService = true
		var err error
		plan.ServiceID, err = secret.NewID()
		if err != nil {
			return plan, fmt.Errorf("generate service ID: %w", err)
		}
		plan.ServiceName = spec.Name
	}

	return plan, nil
}
