package vowifi

import (
	"context"
	"errors"
	"fmt"
	"net"
	"strings"
	"sync"
	"time"
)

const defaultCleanupTimeout = 10 * time.Second

type runtimeResources struct {
	cancel       context.CancelFunc
	radio        RadioSnapshot
	radioChanged bool
	tunnel       TunnelSession
	ims          IMSSession
}

// Orchestrator serializes lifecycle mutations while allowing concurrent state
// readers and subscribers. Disable cancels an in-flight Enable before waiting
// for the mutation lock, so a blocked provider cannot deadlock shutdown.
type Orchestrator struct {
	deps    Dependencies
	options Options

	operation chan struct{}

	mu             sync.Mutex
	state          State
	resources      *runtimeResources
	subscribers    map[uint64]chan State
	nextSubscriber uint64
}

func New(deps Dependencies, options Options) (*Orchestrator, error) {
	if err := deps.validate(); err != nil {
		return nil, err
	}
	if err := options.validate(); err != nil {
		return nil, err
	}
	if options.CleanupTimeout == 0 {
		options.CleanupTimeout = defaultCleanupTimeout
	}
	options.DeviceID = strings.TrimSpace(options.DeviceID)

	now := time.Now().UTC()
	orchestrator := &Orchestrator{
		deps:      deps,
		options:   options,
		operation: make(chan struct{}, 1),
		state: State{
			DeviceID:  strings.TrimSpace(options.DeviceID),
			Phase:     PhaseIdle,
			Sequence:  1,
			UpdatedAt: now,
			Security: SecurityAudit{
				ResponderAUTH: ResponderAUTHUnknown,
			},
		},
		subscribers: make(map[uint64]chan State),
	}
	orchestrator.operation <- struct{}{}
	return orchestrator, nil
}

// State returns a detached snapshot safe for mutation by the caller.
func (orchestrator *Orchestrator) State() State {
	orchestrator.mu.Lock()
	defer orchestrator.mu.Unlock()
	return orchestrator.state.clone()
}

// Subscribe returns the current state immediately and then the newest state on
// every mutation. Slow subscribers lose intermediate snapshots rather than
// blocking the modem lifecycle.
func (orchestrator *Orchestrator) Subscribe(buffer int) (<-chan State, func()) {
	if buffer < 1 {
		buffer = 1
	}
	channel := make(chan State, buffer)

	orchestrator.mu.Lock()
	id := orchestrator.nextSubscriber
	orchestrator.nextSubscriber++
	orchestrator.subscribers[id] = channel
	channel <- orchestrator.state.clone()
	orchestrator.mu.Unlock()

	var once sync.Once
	cancel := func() {
		once.Do(func() {
			orchestrator.mu.Lock()
			if existing, ok := orchestrator.subscribers[id]; ok {
				delete(orchestrator.subscribers, id)
				close(existing)
			}
			orchestrator.mu.Unlock()
		})
	}
	return channel, cancel
}

// Enable executes one evidence-backed transaction. The order intentionally
// follows the working Linux/QMI path: live identity and home PLMN, AKA
// availability, ePDG derivation, runtime-owned RF off, cellular-data stop,
// country proxy resolution, SWu tunnel, IMS registration, and SMS readiness.
func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) {
	if ctx == nil {
		ctx = context.Background()
	}
	if err := orchestrator.lockOperation(ctx); err != nil {
		return orchestrator.State(), err
	}
	defer orchestrator.unlockOperation()

	current := orchestrator.State()
	switch current.Phase {
	case PhaseSIMReady, PhaseAccessReady, PhaseTunnelReady, PhaseIMSReady, PhaseSMSReady, PhaseStopping:
		return current, ErrAlreadyEnabled
	}

	now := time.Now().UTC()
	orchestrator.mutate(func(state *State) {
		attempt := state.Attempt + 1
		sequence := state.Sequence
		*state = State{
			DeviceID:   orchestrator.options.DeviceID,
			Phase:      PhaseIdle,
			Enabled:    true,
			Attempt:    attempt,
			Sequence:   sequence,
			StartedAt:  &now,
			UpdatedAt:  now,
			LastReason: "enable_requested",
			Security: SecurityAudit{
				ResponderAUTH: ResponderAUTHUnknown,
			},
		}
	})

	runtimeContext, runtimeCancel := context.WithCancel(context.Background())
	resources := &runtimeResources{cancel: runtimeCancel}
	orchestrator.mu.Lock()
	orchestrator.resources = resources
	orchestrator.mu.Unlock()

	setupContext, stopSetup := mergedContext(ctx, runtimeContext)
	defer stopSetup()

	fail := func(stage Phase, cause error) (State, error) {
		runtimeCancel()
		cleanupErrors := orchestrator.cleanup(resources)
		orchestrator.mu.Lock()
		orchestrator.resources = nil
		orchestrator.mu.Unlock()

		orchestrator.mutate(func(state *State) {
			state.Phase = PhaseFailed
			state.Active = false
			state.TunnelReady = false
			state.IMSReady = false
			state.SMSReady = false
			state.LastErrorClass = classifyError(stage, cause)
			state.LastError = cause.Error()
			state.LastReason = "enable_failed"
			state.CleanupErrors = append([]string(nil), cleanupErrors...)
		})

		stageError := error(&StageError{Stage: stage, Err: cause})
		if len(cleanupErrors) > 0 {
			stageError = errors.Join(
				stageError,
				fmt.Errorf("vowifi cleanup: %s", strings.Join(cleanupErrors, "; ")),
			)
		}
		return orchestrator.State(), stageError
	}

	identity, err := orchestrator.deps.SIM.ReadIdentity(setupContext, orchestrator.options.DeviceID)
	if err != nil {
		return fail(PhaseSIMReady, err)
	}
	if err := identity.validate(); err != nil {
		return fail(PhaseSIMReady, err)
	}
	akaEvidence, err := orchestrator.deps.AKA.CheckReady(setupContext, identity)
	if err != nil {
		return fail(PhaseSIMReady, err)
	}
	if !akaEvidence.Ready {
		return fail(PhaseSIMReady, errors.New("AKA application is not ready"))
	}
	if reader, ok := orchestrator.deps.SIM.(SMSCenterReader); ok {
		if smsc, smscErr := reader.ReadSMSCenter(setupContext, orchestrator.options.DeviceID); smscErr == nil {
			identity.SMSC = strings.TrimSpace(smsc)
		} else {
			orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error())
		}
	}
	orchestrator.mutate(func(state *State) {
		state.Phase = PhaseSIMReady
		state.SIMReady = true
		state.HomeMCC = strings.TrimSpace(identity.HomeMCC)
		state.HomeMNC = strings.TrimSpace(identity.HomeMNC)
		state.LastReason = "sim_and_aka_ready"
	})

	epdg, err := DeriveEPDG(identity)
	if err != nil {
		return fail(PhaseAccessReady, err)
	}
	resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID)
	if err != nil {
		return fail(PhaseAccessReady, err)
	}
	orchestrator.mutate(func(state *State) {
		state.PureAirplanePolicy = resources.radio.PureAirplanePolicy
	})
	// Mark the radio transaction before the first mutating call: a provider
	// may return an error after partially changing the modem.
	resources.radioChanged = true
	// Enter RF-off before reconciling PDP contexts. Some QMI-capable EC20
	// firmware automatically owns CID 1 while CFUN=1 and rejects a direct
	// CGACT=0 command even though the Linux data interface is down. CFUN=4
	// tears down packet service at the baseband; StopCellularData then acts as
	// a fail-closed verification and removes any context that unexpectedly
	// survived RF-off.
	if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil {
		return fail(PhaseAccessReady, err)
	}
	if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil {
		return fail(PhaseAccessReady, err)
	}

	proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{
		DeviceID:    orchestrator.options.DeviceID,
		HomeMCC:     strings.TrimSpace(identity.HomeMCC),
		HomeMNC:     strings.TrimSpace(identity.HomeMNC),
		CountryCode: strings.ToUpper(strings.TrimSpace(identity.HomeCountryCode)),
	})
	if err != nil {
		return fail(PhaseAccessReady, err)
	}
	proxy, err = normalizeProxyRoute(proxy)
	if err != nil {
		return fail(PhaseAccessReady, err)
	}
	orchestrator.mutate(func(state *State) {
		state.Phase = PhaseAccessReady
		state.AccessReady = true
		state.EPDG = epdg
		state.ProxyMode = proxy.Mode
		state.ProxyID = proxy.ID
		state.LastReason = "epdg_access_ready"
	})

	tunnel, err := orchestrator.deps.Tunnel.Start(setupContext, TunnelRequest{
		DeviceID: orchestrator.options.DeviceID,
		Identity: identity,
		EPDG:     epdg,
		Proxy:    proxy,
		AKA:      orchestrator.deps.AKA,
		Security: TunnelSecurityPolicy{
			AllowMissingResponderAUTH: orchestrator.options.AllowMissingResponderAUTH,
		},
	})
	if err != nil {
		return fail(PhaseTunnelReady, err)
	}
	if tunnel == nil {
		return fail(PhaseTunnelReady, errors.New("tunnel provider returned a nil session"))
	}
	resources.tunnel = tunnel
	tunnelEvidence := tunnel.Evidence()
	if !tunnelEvidence.Established {
		orchestrator.mutate(func(state *State) {
			state.Security = securityAuditFromEvidence(tunnelEvidence)
		})
		return fail(PhaseTunnelReady, ErrTunnelNotEstablished)
	}
	securityAudit, err := orchestrator.validateTunnelEvidence(tunnelEvidence)
	orchestrator.mutate(func(state *State) {
		state.Security = securityAudit
	})
	if err != nil {
		return fail(PhaseTunnelReady, err)
	}
	orchestrator.mutate(func(state *State) {
		state.Phase = PhaseTunnelReady
		state.Active = true
		state.TunnelReady = true
		state.TunnelName = strings.TrimSpace(tunnelEvidence.Name)
		state.DataplaneMode = strings.TrimSpace(tunnelEvidence.DataplaneMode)
		state.LastReason = "ipsec_tunnel_ready"
	})
	orchestrator.watchRuntimeTunnel(runtimeContext, resources, tunnel)

	ims, err := orchestrator.deps.IMS.Start(setupContext, IMSRequest{
		DeviceID: orchestrator.options.DeviceID,
		Identity: identity,
		Tunnel:   tunnel,
	})
	if err != nil {
		return fail(PhaseIMSReady, err)
	}
	if ims == nil {
		return fail(PhaseIMSReady, errors.New("IMS provider returned a nil session"))
	}
	resources.ims = ims
	orchestrator.watchRuntimeIMS(runtimeContext, resources, ims)
	imsEvidence := ims.Evidence()
	if !imsEvidence.Registered {
		return fail(PhaseIMSReady, ErrIMSNotRegistered)
	}
	orchestrator.mutate(func(state *State) {
		state.Phase = PhaseIMSReady
		state.IMSReady = true
		state.IMSRegistration = strings.TrimSpace(imsEvidence.RegistrationState)
		state.LastReason = "ims_registered"
	})

	if number, source, ok := ExtractAssociatedMSISDN(imsEvidence); ok {
		record := PhoneRecord{
			ICCID:     strings.TrimSpace(identity.ICCID),
			Number:    number,
			Source:    source,
			UpdatedAt: time.Now().UTC(),
		}
		if err := orchestrator.deps.Phones.SaveAssociatedNumber(setupContext, record); err != nil {
			orchestrator.addWarning("IMS associated number is valid but could not be persisted: " + err.Error())
		} else {
			orchestrator.mutate(func(state *State) {
				state.PhoneNumber = number
				state.PhoneNumberSource = source
			})
		}
	} else {
		orchestrator.addWarning("IMS did not publish an associated MSISDN; the number was not inferred from IMSI")
	}

	smsEvidence, err := ims.EnableSMS(setupContext)
	if err != nil {
		if orchestrator.options.AllowIMSWithoutSMS {
			orchestrator.addWarning("IMS is registered but SMS capability was not confirmed: " + err.Error())
			orchestrator.mutate(func(state *State) {
				state.LastReason = "ims_registered_sms_unavailable"
				state.LastError = ""
				state.LastErrorClass = ""
				state.CleanupErrors = nil
			})
			return orchestrator.State(), nil
		}
		return fail(PhaseSMSReady, err)
	}
	if !smsEvidence.Ready {
		if orchestrator.options.AllowIMSWithoutSMS {
			orchestrator.addWarning("IMS is registered but SMS capability was not confirmed")
			orchestrator.mutate(func(state *State) {
				state.LastReason = "ims_registered_sms_unavailable"
				state.LastError = ""
				state.LastErrorClass = ""
				state.CleanupErrors = nil
			})
			return orchestrator.State(), nil
		}
		return fail(PhaseSMSReady, ErrSMSNotReady)
	}
	orchestrator.mutate(func(state *State) {
		state.Phase = PhaseSMSReady
		state.SMSReady = true
		state.LastReason = "sms_ready"
		state.LastError = ""
		state.LastErrorClass = ""
		state.CleanupErrors = nil
	})
	return orchestrator.State(), nil
}

// Disable is idempotent. It interrupts setup when necessary and closes IMS,
// tunnel, then restores the captured radio state.
func (orchestrator *Orchestrator) Disable(ctx context.Context) (State, error) {
	if ctx == nil {
		ctx = context.Background()
	}
	orchestrator.cancelCurrentRuntime()
	if err := orchestrator.lockOperation(ctx); err != nil {
		return orchestrator.State(), err
	}
	defer orchestrator.unlockOperation()

	orchestrator.mu.Lock()
	resources := orchestrator.resources
	orchestrator.mu.Unlock()
	current := orchestrator.State()
	if resources == nil && current.Phase == PhaseIdle {
		return current, nil
	}

	orchestrator.mutate(func(state *State) {
		state.Phase = PhaseStopping
		state.Enabled = false
		state.LastReason = "disable_requested"
	})
	if resources != nil && resources.cancel != nil {
		resources.cancel()
	}
	cleanupErrors := orchestrator.cleanup(resources)
	orchestrator.mu.Lock()
	orchestrator.resources = nil
	orchestrator.mu.Unlock()

	if len(cleanupErrors) > 0 {
		cause := fmt.Errorf("%w: %s", ErrCleanupIncomplete, strings.Join(cleanupErrors, "; "))
		orchestrator.mutate(func(state *State) {
			// cleanup() has already released every local resource and restored
			// the radio. A rejected best-effort SIP deregistration is useful
			// diagnostic evidence, but it must not leave a disabled runtime in
			// Failed/Stopping or prevent a later cellular/VoWiFi transition.
			state.Phase = PhaseIdle
			state.Enabled = false
			state.Active = false
			state.SIMReady = false
			state.AccessReady = false
			state.TunnelReady = false
			state.IMSReady = false
			state.SMSReady = false
			state.TunnelName = ""
			state.DataplaneMode = ""
			state.IMSRegistration = ""
			state.LastErrorClass = "cleanup_warning"
			state.LastError = cause.Error()
			state.LastReason = "disabled_with_cleanup_errors"
			state.CleanupErrors = append([]string(nil), cleanupErrors...)
			state.StartedAt = nil
		})
		return orchestrator.State(), cause
	}

	orchestrator.mutate(func(state *State) {
		state.Phase = PhaseIdle
		state.Enabled = false
		state.Active = false
		state.SIMReady = false
		state.AccessReady = false
		state.TunnelReady = false
		state.IMSReady = false
		state.SMSReady = false
		state.TunnelName = ""
		state.DataplaneMode = ""
		state.IMSRegistration = ""
		state.LastErrorClass = ""
		state.LastError = ""
		state.LastReason = "disabled"
		state.CleanupErrors = nil
		state.StartedAt = nil
	})
	return orchestrator.State(), nil
}

func (orchestrator *Orchestrator) Retry(ctx context.Context) (State, error) {
	if orchestrator.State().Phase != PhaseFailed {
		return orchestrator.State(), ErrRetryRequiresFailure
	}
	return orchestrator.Enable(ctx)
}

func (orchestrator *Orchestrator) Reconnect(ctx context.Context) (State, error) {
	current := orchestrator.State()
	if !current.Enabled && current.Phase == PhaseIdle {
		return current, ErrNotRunning
	}
	// Teardown during a reconnect is best-effort. Disable already releases the
	// local IMS, tunnel, and radio resources, so a non-fatal cleanup error
	// (e.g. the network rejecting SIP deregistration) must not block the
	// rebuild — otherwise the device wedges in PhaseFailed. Only propagate
	// errors that prevented the teardown itself (e.g. the operation lock).
	if _, err := orchestrator.Disable(ctx); err != nil && !errors.Is(err, ErrCleanupIncomplete) {
		return orchestrator.State(), err
	}
	return orchestrator.Enable(ctx)
}

// SendSMS submits through the currently registered IMS session. The lifecycle
// operation lock prevents teardown from closing the session mid-transaction.
func (orchestrator *Orchestrator) SendSMS(
	ctx context.Context,
	request SMSSubmitRequest,
) (SMSSubmitResult, error) {
	if ctx == nil {
		ctx = context.Background()
	}
	if err := orchestrator.lockOperation(ctx); err != nil {
		return SMSSubmitResult{}, err
	}
	defer orchestrator.unlockOperation()
	orchestrator.mu.Lock()
	resources := orchestrator.resources
	ready := orchestrator.state.IMSReady && orchestrator.state.SMSReady
	orchestrator.mu.Unlock()
	if resources == nil || resources.ims == nil || !ready {
		return SMSSubmitResult{}, ErrSMSNotReady
	}
	sender, ok := resources.ims.(SMSSender)
	if !ok {
		return SMSSubmitResult{}, ErrSMSNotReady
	}
	return sender.SendSMS(ctx, request)
}

func (orchestrator *Orchestrator) Close(ctx context.Context) error {
	_, err := orchestrator.Disable(ctx)
	return err
}

// DeriveEPDG uses an explicitly provided carrier endpoint or the 3GPP standard
// home-PLMN form. It never derives a phone number or MNC length from IMSI.
func DeriveEPDG(identity SIMIdentity) (string, error) {
	if configured := strings.TrimSpace(identity.EPDG); configured != "" {
		if strings.ContainsAny(configured, " \t\r\n/:") || len(configured) > 253 {
			return "", errors.New("vowifi: configured ePDG must be a hostname")
		}
		return strings.ToLower(configured), nil
	}
	if err := identity.validate(); err != nil {
		return "", err
	}
	mnc := strings.TrimSpace(identity.HomeMNC)
	for len(mnc) < 3 {
		mnc = "0" + mnc
	}
	return fmt.Sprintf(
		"epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org",
		mnc,
		strings.TrimSpace(identity.HomeMCC),
	), nil
}

func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) {
	if route.Mode == "" {
		route.Mode = ProxyModeDirect
	}
	switch route.Mode {
	case ProxyModeDirect:
		route.Address = ""
		route.Username = ""
		route.Password = ""
	case ProxyModeSOCKS5:
		if strings.TrimSpace(route.Address) == "" {
			return ProxyRoute{}, errors.New("vowifi: SOCKS5 proxy address is empty")
		}
	default:
		return ProxyRoute{}, fmt.Errorf("vowifi: unsupported proxy mode %q", route.Mode)
	}
	route.ID = strings.TrimSpace(route.ID)
	route.Address = strings.TrimSpace(route.Address)
	return route, nil
}

func (orchestrator *Orchestrator) validateTunnelEvidence(evidence TunnelEvidence) (SecurityAudit, error) {
	audit := securityAuditFromEvidence(evidence)
	switch evidence.ResponderAUTH {
	case ResponderAUTHVerified:
		return audit, nil
	case ResponderAUTHMissing:
		if !orchestrator.options.AllowMissingResponderAUTH {
			return audit, ErrResponderAUTHRequired
		}
		audit.CompatibilityOverride = true
		audit.HighRisk = true
		audit.Level = AuditLevelHigh
		audit.Code = AuditCodeMissingResponderAUTH
		audit.Message = "IKE responder AUTH was missing and accepted by explicit compatibility policy"
		return audit, nil
	case ResponderAUTHInvalid:
		return audit, fmt.Errorf("%w: responder AUTH is invalid", ErrResponderAUTHRequired)
	default:
		return audit, fmt.Errorf("%w: responder AUTH evidence is unknown", ErrResponderAUTHRequired)
	}
}

func securityAuditFromEvidence(evidence TunnelEvidence) SecurityAudit {
	return SecurityAudit{
		ResponderAUTH: evidence.ResponderAUTH,
		IKEEncryption: strings.TrimSpace(evidence.IKEEncryption),
		IKEIntegrity:  strings.TrimSpace(evidence.IKEIntegrity),
		IKEDHGroup:    strings.TrimSpace(evidence.IKEDHGroup),
		ESPEncryption: strings.TrimSpace(evidence.ESPEncryption),
		ESPIntegrity:  strings.TrimSpace(evidence.ESPIntegrity),
	}
}

func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string {
	if resources == nil {
		return nil
	}
	var cleanupErrors []string
	if resources.ims != nil {
		if err := orchestrator.cleanupCall(resources.ims.Close); err != nil {
			cleanupErrors = append(cleanupErrors, "close IMS: "+err.Error())
		}
		resources.ims = nil
	}
	if resources.tunnel != nil {
		if err := orchestrator.cleanupCall(resources.tunnel.Close); err != nil {
			cleanupErrors = append(cleanupErrors, "close tunnel: "+err.Error())
		}
		resources.tunnel = nil
	}
	if resources.radioChanged {
		if err := orchestrator.cleanupCall(func(ctx context.Context) error {
			return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio)
		}); err != nil {
			cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error())
		}
		resources.radioChanged = false
	}
	return cleanupErrors
}

func (orchestrator *Orchestrator) cleanupCall(call func(context.Context) error) error {
	ctx, cancel := context.WithTimeout(context.Background(), orchestrator.options.CleanupTimeout)
	defer cancel()
	return call(ctx)
}

func (orchestrator *Orchestrator) cancelCurrentRuntime() {
	orchestrator.mu.Lock()
	resources := orchestrator.resources
	orchestrator.mu.Unlock()
	if resources != nil && resources.cancel != nil {
		resources.cancel()
	}
}

func (orchestrator *Orchestrator) watchRuntimeTunnel(
	runtimeContext context.Context,
	resources *runtimeResources,
	tunnel TunnelSession,
) {
	notifier, ok := tunnel.(RuntimeFailureNotifier)
	if !ok {
		return
	}
	orchestrator.watchRuntimeFailure(
		runtimeContext,
		resources,
		notifier,
		"tunnel_runtime",
		"runtime_tunnel_failed",
	)
}

func (orchestrator *Orchestrator) watchRuntimeIMS(
	runtimeContext context.Context,
	resources *runtimeResources,
	ims IMSSession,
) {
	notifier, ok := ims.(RuntimeFailureNotifier)
	if !ok {
		return
	}
	orchestrator.watchRuntimeFailure(
		runtimeContext,
		resources,
		notifier,
		"ims_runtime",
		"runtime_ims_failed",
	)
}

func (orchestrator *Orchestrator) watchRuntimeFailure(
	runtimeContext context.Context,
	resources *runtimeResources,
	notifier RuntimeFailureNotifier,
	errorClass string,
	reason string,
) {
	failures := notifier.Failures()
	if failures == nil {
		return
	}
	go func() {
		select {
		case <-runtimeContext.Done():
			return
		case cause := <-failures:
			if cause == nil {
				cause = errors.New("VoWiFi runtime session stopped")
			}
			// Interrupt any still-running IMS setup before waiting for the
			// serialized lifecycle lock.
			if resources.cancel != nil {
				resources.cancel()
			}
			if err := orchestrator.lockOperation(context.Background()); err != nil {
				return
			}
			defer orchestrator.unlockOperation()

			orchestrator.mu.Lock()
			current := orchestrator.resources == resources
			orchestrator.mu.Unlock()
			if !current {
				return
			}
			cleanupErrors := orchestrator.cleanup(resources)
			orchestrator.mu.Lock()
			if orchestrator.resources == resources {
				orchestrator.resources = nil
			}
			orchestrator.mu.Unlock()
			orchestrator.mutate(func(state *State) {
				state.Phase = PhaseFailed
				state.Active = false
				state.TunnelReady = false
				state.IMSReady = false
				state.SMSReady = false
				state.LastErrorClass = errorClass
				state.LastError = cause.Error()
				state.LastReason = reason
				state.CleanupErrors = append([]string(nil), cleanupErrors...)
			})
		}
	}()
}

func (orchestrator *Orchestrator) lockOperation(ctx context.Context) error {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-orchestrator.operation:
		return nil
	}
}

func (orchestrator *Orchestrator) unlockOperation() {
	orchestrator.operation <- struct{}{}
}

func (orchestrator *Orchestrator) mutate(change func(*State)) {
	orchestrator.mu.Lock()
	change(&orchestrator.state)
	orchestrator.state.Sequence++
	orchestrator.state.UpdatedAt = time.Now().UTC()
	snapshot := orchestrator.state.clone()
	for _, subscriber := range orchestrator.subscribers {
		select {
		case subscriber <- snapshot:
		default:
			select {
			case <-subscriber:
			default:
			}
			select {
			case subscriber <- snapshot:
			default:
			}
		}
	}
	orchestrator.mu.Unlock()
}

func (orchestrator *Orchestrator) addWarning(warning string) {
	orchestrator.mutate(func(state *State) {
		state.Warnings = append(state.Warnings, warning)
	})
}

func classifyError(stage Phase, err error) string {
	switch {
	case errors.Is(err, context.Canceled):
		return "canceled"
	case errors.Is(err, context.DeadlineExceeded):
		return "timeout"
	case isTimeoutError(err):
		return "network_timeout"
	case errors.Is(err, ErrInvalidIdentity):
		return "sim_identity"
	case errors.Is(err, ErrEAPAuthenticationRejected):
		return "eap_authentication_rejected"
	case errors.Is(err, ErrResponderAUTHRequired):
		return "responder_auth"
	case errors.Is(err, ErrTunnelNotEstablished):
		return "tunnel"
	case errors.Is(err, ErrIMSNotRegistered):
		return "ims_registration"
	case errors.Is(err, ErrSMSNotReady):
		return "sms"
	default:
		return string(stage)
	}
}

func isTimeoutError(err error) bool {
	var networkError net.Error
	return errors.As(err, &networkError) && networkError.Timeout()
}

func mergedContext(caller context.Context, runtime context.Context) (context.Context, func()) {
	merged, cancel := context.WithCancel(caller)
	stop := context.AfterFunc(runtime, cancel)
	return merged, func() {
		stop()
		cancel()
	}
}
