param([string]$AssemblyPath = (Join-Path $PSScriptRoot '..\bin\Debug\netstandard2.0\ClumsyPilot.dll'))
$ErrorActionPreference = 'Stop'

$plannerRoot = Join-Path $PSScriptRoot '..\ParkrobTrajplanner'
$coarsePathReadme = Join-Path $plannerRoot 'CoarsePath\README.md'
$legacyRootReadme = Join-Path $plannerRoot 'README.md'
if (-not (Test-Path -LiteralPath $coarsePathReadme -PathType Leaf)) {
    throw 'CoarsePath module README must be located inside the CoarsePath directory.'
}
if (Test-Path -LiteralPath $legacyRootReadme -PathType Leaf) {
    throw 'The coarse-path-only README must not remain at the ParkrobTrajplanner root.'
}
$coarsePathReadmeContent = Get-Content -LiteralPath $coarsePathReadme -Raw
if (-not $coarsePathReadmeContent.Contains('## 总预算与取消')) {
    throw 'CoarsePath README must document total timeout and cancellation semantics.'
}
if (-not $coarsePathReadmeContent.Contains('PlanningMapBuildStatus')) {
    throw 'CoarsePath README must document map-stage termination status handling.'
}

$assembly = [Reflection.Assembly]::LoadFrom((Resolve-Path $AssemblyPath))

function Assert-True($Actual, [string]$Message) {
    if (-not $Actual) { throw $Message }
}

function Assert-False($Actual, [string]$Message) {
    if ($Actual) { throw $Message }
}

function Assert-Equal($Expected, $Actual, [string]$Message) {
    if ($Expected -ne $Actual) { throw "$Message Expected=$Expected Actual=$Actual" }
}

function Assert-Near([double]$Expected, [double]$Actual, [string]$Message) {
    if ([Math]::Abs($Expected - $Actual) -gt 0.000001) { throw "$Message Expected=$Expected Actual=$Actual" }
}

function Find-Method($Type, [string]$Name, [Type[]]$ParameterTypes) {
    foreach ($candidate in $Type.GetMethods()) {
        if ($candidate.Name -ne $Name) { continue }
        $parameters = $candidate.GetParameters()
        if ($parameters.Length -ne $ParameterTypes.Length) { continue }
        $matches = $true
        for ($index = 0; $index -lt $parameters.Length; $index++) {
            if ($parameters[$index].ParameterType -ne $ParameterTypes[$index]) {
                $matches = $false
                break
            }
        }
        if ($matches) { return $candidate }
    }
    return $null
}

function New-EmptyPlanningMap {
    $mapping = 'MultiWheelC.TrajectoryPlanning.Mapping.'
    $boundsType = $assembly.GetType($mapping + 'MapBoundsMm', $true)
    $requestType = $assembly.GetType($mapping + 'PlanningMapRequest', $true)
    $factoryType = $assembly.GetType($mapping + 'PlanningMapFactory', $true)
    $request = [Activator]::CreateInstance($requestType)
    $request.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
    $request.ResolutionMm = [single]50
    $request.AllowExplicitEmptyMap = $true
    $result = [Activator]::CreateInstance($factoryType).Create($request)
    Assert-True $result.Succeeded 'Empty integration map must be created.'
    Assert-True $result.Map.PlanningReady 'Explicit empty integration map must be ready.'
    return $result.Map
}

$coarsePath = 'MultiWheelC.TrajectoryPlanning.CoarsePath.'
$plannerType = $assembly.GetType($coarsePath + 'HybridAStarPlanner', $true)
$planner = [Activator]::CreateInstance($plannerType)
$requestType = $assembly.GetType($coarsePath + 'PlanningRequest', $true)
$poseType = $assembly.GetType($coarsePath + 'Pose2D', $true)
$vehicleType = $assembly.GetType($coarsePath + 'VehicleParameters', $true)
$configurationType = $assembly.GetType($coarsePath + 'HybridAStarConfiguration', $true)
$goalDirectionType = $assembly.GetType($coarsePath + 'GoalDirectionConstraint', $true)
$plan = Find-Method $plannerType 'Plan' @($requestType, [Threading.CancellationToken])
Assert-True ($plan -ne $null) 'HybridAStarPlanner must expose Plan(request, cancellationToken).'

$defaultConfiguration = [Activator]::CreateInstance($configurationType)
Assert-Near 0.15 $defaultConfiguration.GoalPositionToleranceMeters 'Default goal-position tolerance must retain the documented 0.15 m terminal acceptance radius.'

$request = [Activator]::CreateInstance($requestType)
$request.Map = New-EmptyPlanningMap
$request.Start = [Activator]::CreateInstance($poseType, @(1.0, 1.0, 0.0))
$request.Goal = [Activator]::CreateInstance($poseType, @(1.20, 1.0, 0.0))
$request.Vehicle = [Activator]::CreateInstance($vehicleType)
$request.Vehicle.LengthMeters = 0.20
$request.Vehicle.WidthMeters = 0.20
$request.Vehicle.SafetyMarginMeters = 0.0
$request.Vehicle.MaximumCurvaturePerMeter = 1.0
$request.Configuration = [Activator]::CreateInstance($configurationType)
$request.Configuration.MaximumExpandedNodes = 10000
$request.Configuration.SearchTimeout = [TimeSpan]::FromSeconds(2.0)
$request.Configuration.GoalPositionToleranceMeters = 0.001
$request.Configuration.GoalHeadingToleranceRadians = 0.001
$request.Configuration.AllowReverse = $false
$request.GoalDirection = [Enum]::Parse($goalDirectionType, 'Forward')
$request.StartVehicleCurvature = [double]0.20

$result = $plan.Invoke($planner, @($request, [Threading.CancellationToken]::None))
Assert-Equal 'Success' $result.Status.ToString() 'Planner must publish a validated direct path on an empty map.'
Assert-True ($result.Path.Count -ge 2) 'Validated path must retain its start and goal points.'
Assert-Near 0.0 $result.Path[0].ArcLength 'The first path point must have zero arc length.'
Assert-Near 0.20 $result.Path[0].VehicleCurvature `
    'The assembled start point must retain the requested physical vehicle curvature.'
Assert-Equal 'Start' $result.Path[0].Source.ToString() 'The first path point must retain the start source.'
Assert-Equal 'GoalTruncation' $result.Path[-1].Source.ToString() 'The terminal truncated point must retain its source.'
Assert-True ($result.Diagnostics.PeakOpenListCount -ge 1) 'Planner diagnostics must retain the actual Open List peak count.'
Assert-True ($result.Diagnostics.StaleOpenListEntryCount -ge 0) 'Planner diagnostics must retain a non-negative stale Open List count.'
Assert-True ($result.Diagnostics.GetType().GetProperty('PathSearchElapsed') -ne $null) 'Planner diagnostics must expose path-search elapsed time.'
Assert-True ($result.Diagnostics.PathSearchElapsed -ge [TimeSpan]::Zero) 'Successful planning must retain non-negative path-search time.'
Assert-True ($result.Diagnostics.PathSearchElapsed -le $result.Diagnostics.Elapsed) 'Path-search time must not exceed total elapsed time.'

$nodeLimitedRequest = [Activator]::CreateInstance($requestType)
$nodeLimitedRequest.Map = $request.Map
$nodeLimitedRequest.Start = $request.Start
$nodeLimitedRequest.Goal = $request.Goal
$nodeLimitedRequest.Vehicle = $request.Vehicle
$nodeLimitedRequest.Configuration = [Activator]::CreateInstance($configurationType)
$nodeLimitedRequest.Configuration.MaximumExpandedNodes = 0
$nodeLimitedRequest.Configuration.SearchTimeout = [TimeSpan]::FromSeconds(2)
$nodeLimitedRequest.Configuration.GoalPositionToleranceMeters = 0.001
$nodeLimitedRequest.Configuration.GoalHeadingToleranceRadians = 0.001
$nodeLimitedRequest.Configuration.AllowReverse = $false
$nodeLimitedRequest.GoalDirection = [Enum]::Parse($goalDirectionType, 'Forward')
$nodeLimitedResult = $plan.Invoke($planner, @($nodeLimitedRequest, [Threading.CancellationToken]::None))
Assert-Equal 'SearchNodeLimitExceeded' $nodeLimitedResult.Status.ToString() 'A zero node limit must fail after planner preflight.'
Assert-True ($nodeLimitedResult.Diagnostics.PathSearchElapsed -ge [TimeSpan]::Zero) 'Search-stage node-limit failure must retain path-search time.'
Assert-True ($nodeLimitedResult.Diagnostics.PathSearchElapsed -le $nodeLimitedResult.Diagnostics.Elapsed) 'Failed path-search time must not exceed total elapsed time.'
Assert-True (-not [string]::IsNullOrWhiteSpace($nodeLimitedResult.Diagnostics.TerminationReason)) 'Planner diagnostics must retain a node-limit reason.'

for ($index = 1; $index -lt $result.Path.Count; $index++) {
    $previous = $result.Path[$index - 1]
    $current = $result.Path[$index]
    Assert-True ($current.ArcLength -ge $previous.ArcLength) 'Path arc length must be non-decreasing.'
    Assert-True ([Math]::Abs($current.UnwrappedHeading - $previous.UnwrappedHeading) -le ([Math]::PI + 0.000001)) 'Unwrapped heading must remain continuous between adjacent points.'
    $samePose = ([Math]::Abs($current.X - $previous.X) -le 0.000001) -and
        ([Math]::Abs($current.Y - $previous.Y) -le 0.000001) -and
        ([Math]::Abs($current.Heading - $previous.Heading) -le 0.000001) -and
        ([Math]::Abs($current.ArcLength - $previous.ArcLength) -le 0.000001)
    if ($samePose) {
        Assert-True (($current.Direction -ne $previous.Direction) -and $current.IsGearSwitchPoint) 'Adjacent duplicate points are permitted only as a marked gear-switch pair.'
    }
}

Assert-True ($result.Segments.Count -ge 1) 'Validated path must include inclusive direction segments.'
Assert-Equal 0 $result.Segments[0].StartIndex 'The first segment must include the first path point.'
Assert-Equal ($result.Path.Count - 1) $result.Segments[-1].EndIndex 'The final segment must include the final path point.'
for ($index = 0; $index -lt $result.Segments.Count; $index++) {
    $segment = $result.Segments[$index]
    Assert-Equal $index $segment.SegmentIndex 'Segment indexes must be contiguous.'
    Assert-True ($segment.StartIndex -le $segment.EndIndex) 'Each segment must use inclusive ordered indexes.'
    if ($index -gt 0) {
        $previousSegment = $result.Segments[$index - 1]
        Assert-Equal ($previousSegment.EndIndex + 1) $segment.StartIndex 'Segments must cover each path point exactly once.'
    }
}

Write-Output 'Coarse path integration checks passed.'

# Facade checks intentionally use only ASCII text so the script can also run in legacy PowerShell hosts.
$facade = $coarsePath + 'Facade.'
$serviceType = $assembly.GetType($facade + 'CoarsePathPlanningService', $true)
$jobType = $assembly.GetType($facade + 'CoarsePathPlanningJob', $true)
$jobResultType = $assembly.GetType($facade + 'CoarsePathPlanningJobResult', $true)
$debugOptionsType = $assembly.GetType($facade + 'PlanningDebugOptions', $true)
$debugSinkType = $assembly.GetType($facade + 'IPlanningDebugSink', $true)
$mapRequestType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.PlanningMapRequest', $true)
$boundsType = $assembly.GetType('MultiWheelC.TrajectoryPlanning.Mapping.MapBoundsMm', $true)
$servicePlan = Find-Method $serviceType 'Plan' @($jobType, [Threading.CancellationToken])
Assert-True ($servicePlan -ne $null) 'CoarsePathPlanningService must expose Plan(job, cancellationToken).'
Assert-True ($jobResultType.GetProperty('MapResult') -ne $null) 'Facade result must retain the map result.'
Assert-True ($jobResultType.GetProperty('PlanningResult') -ne $null) 'Facade result must retain the planning result.'
Assert-True ($jobResultType.GetProperty('DebugDiagnostics') -ne $null) 'Facade result must retain debug diagnostics.'

function New-FacadeJob {
    $job = [Activator]::CreateInstance($jobType)
    $mapRequest = [Activator]::CreateInstance($mapRequestType)
    $mapRequest.Bounds = [Activator]::CreateInstance($boundsType, @([single]0, [single]5000, [single]0, [single]5000))
    $mapRequest.ResolutionMm = [single]50
    $mapRequest.AllowExplicitEmptyMap = $true
    $job.MapRequest = $mapRequest
    $job.Start = [Activator]::CreateInstance($poseType, @(1.0, 1.0, 0.0))
    $job.Goal = [Activator]::CreateInstance($poseType, @(1.20, 1.0, 0.0))
    $job.Vehicle = [Activator]::CreateInstance($vehicleType)
    $job.Vehicle.LengthMeters = 0.20
    $job.Vehicle.WidthMeters = 0.20
    $job.Vehicle.SafetyMarginMeters = 0.0
    $job.Vehicle.MaximumCurvaturePerMeter = 1.0
    $job.Configuration = [Activator]::CreateInstance($configurationType)
    $job.Configuration.MaximumExpandedNodes = 10000
    $job.Configuration.SearchTimeout = [TimeSpan]::FromSeconds(2.0)
    $job.Configuration.GoalPositionToleranceMeters = 0.001
    $job.Configuration.GoalHeadingToleranceRadians = 0.001
    $job.Configuration.AllowReverse = $false
    $job.GoalDirection = [Enum]::Parse($goalDirectionType, 'Forward')
    return $job
}

$service = [Activator]::CreateInstance($serviceType)
$firstFacadeResult = $servicePlan.Invoke($service, @((New-FacadeJob), [Threading.CancellationToken]::None))
Assert-True $firstFacadeResult.MapResult.Succeeded 'Facade must build the requested map first.'
Assert-Equal 'Success' $firstFacadeResult.PlanningResult.Status.ToString() 'Facade must pass a successful map to the planner.'
Assert-Equal 0 $firstFacadeResult.DebugDiagnostics.Count 'Default debug sink must not add diagnostics.'

$secondFacadeResult = $servicePlan.Invoke($service, @((New-FacadeJob), [Threading.CancellationToken]::None))
Assert-True $secondFacadeResult.MapResult.Succeeded 'Repeated facade request must retain a map result.'
Assert-Equal 'Input' $secondFacadeResult.MapResult.CacheHit.ToString() 'One facade service must retain its map factory cache.'
Assert-Equal $firstFacadeResult.MapResult.Map.InputFingerprint $secondFacadeResult.MapResult.Map.InputFingerprint 'Map fingerprint must remain stable for equal input.'
Assert-Equal $firstFacadeResult.PlanningResult.Status $secondFacadeResult.PlanningResult.Status 'Map cache reuse must not change planning status.'
Assert-Equal $firstFacadeResult.PlanningResult.Path.Count $secondFacadeResult.PlanningResult.Path.Count 'Map cache reuse must not change path point count.'

$invalidJob = [Activator]::CreateInstance($jobType)
$invalidJob.MapRequest = [Activator]::CreateInstance($mapRequestType)
$mapFailureResult = $servicePlan.Invoke($service, @($invalidJob, [Threading.CancellationToken]::None))
Assert-False $mapFailureResult.MapResult.Succeeded 'Invalid map input must be returned as a map failure.'
Assert-Equal 'InvalidMap' $mapFailureResult.PlanningResult.Status.ToString() 'Map failure must return an empty non-search planning result.'
Assert-Equal 0 $mapFailureResult.PlanningResult.Diagnostics.ExpandedNodeCount 'Map failure must not start the search.'
Assert-Equal 0 $mapFailureResult.PlanningResult.Path.Count 'Map failure must not publish a path.'
Assert-Equal ([TimeSpan]::Zero) $mapFailureResult.PlanningResult.Diagnostics.PathSearchElapsed 'Map failure must report zero path-search time.'

$cancelledFacadeSource = New-Object Threading.CancellationTokenSource
$cancelledFacadeSource.Cancel()
$cancelledFacadeResult = $servicePlan.Invoke($service, @((New-FacadeJob), $cancelledFacadeSource.Token))
Assert-Equal 'Cancelled' $cancelledFacadeResult.MapResult.Status.ToString() 'Facade must retain a cancelled map result.'
Assert-Equal 'Cancelled' $cancelledFacadeResult.PlanningResult.Status.ToString() 'Facade must map map-stage cancellation to planning cancellation.'
Assert-Equal 0 $cancelledFacadeResult.PlanningResult.Path.Count 'Cancelled facade planning must publish no path.'
Assert-Equal 0 $cancelledFacadeResult.PlanningResult.Segments.Count 'Cancelled facade planning must publish no segments.'

$timedOutFacadeJob = New-FacadeJob
$timedOutFacadeJob.Configuration.SearchTimeout = [TimeSpan]::Zero
$timedOutFacadeResult = $servicePlan.Invoke($service, @($timedOutFacadeJob, [Threading.CancellationToken]::None))
Assert-Equal 'TimedOut' $timedOutFacadeResult.MapResult.Status.ToString() 'Facade total timeout must stop before publishing a map.'
Assert-Equal 'SearchTimeout' $timedOutFacadeResult.PlanningResult.Status.ToString() 'Facade must map map-stage timeout to planning timeout.'
Assert-Equal 0 $timedOutFacadeResult.PlanningResult.Path.Count 'Timed out facade planning must publish no path.'

$defaultDebugOptions = [Activator]::CreateInstance($debugOptionsType)
Assert-False $defaultDebugOptions.Enabled 'Debug output must be opt-in.'
Assert-True ($defaultDebugOptions.Sink -ne $null) 'Debug options must default to a no-op sink.'

$debugSinkSource = @'
using System;
using MultiWheelC.TrajectoryPlanning.Mapping;
using MultiWheelC.TrajectoryPlanning.CoarsePath;
using MultiWheelC.TrajectoryPlanning.CoarsePath.Facade;

public sealed class FacadeRecordingDebugSink : IPlanningDebugSink
{
    public int PublishCount { get; private set; }
    public void Publish(PlanningMapBuildResult mapResult, PlanningResult planningResult)
    {
        PublishCount++;
    }
}

public sealed class FacadeThrowingDebugSink : IPlanningDebugSink
{
    public void Publish(PlanningMapBuildResult mapResult, PlanningResult planningResult)
    {
        throw new InvalidOperationException("debug sink failure");
    }
}
'@
$runtimeDirectory = [Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory()
$debugSinkReferences = @(
    (Join-Path $runtimeDirectory 'mscorlib.dll'),
    (Join-Path $runtimeDirectory 'System.dll'),
    (Join-Path $runtimeDirectory 'System.Core.dll'),
    $AssemblyPath,
    'C:\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETFramework\v4.8\Facades\netstandard.dll'
)
Add-Type -TypeDefinition $debugSinkSource -ReferencedAssemblies $debugSinkReferences
$recordingSinkType = 'FacadeRecordingDebugSink' -as [type]
$throwingSinkType = 'FacadeThrowingDebugSink' -as [type]

$disabledDebugOptions = [Activator]::CreateInstance($debugOptionsType)
$recordingSink = [Activator]::CreateInstance($recordingSinkType)
$disabledDebugOptions.Sink = $recordingSink
$disabledJob = New-FacadeJob
$disabledJob.DebugOptions = $disabledDebugOptions
$disabledResult = $servicePlan.Invoke($service, @($disabledJob, [Threading.CancellationToken]::None))
Assert-Equal 0 $recordingSink.PublishCount 'Disabled debug output must not publish to the sink.'
Assert-Equal $firstFacadeResult.PlanningResult.Status $disabledResult.PlanningResult.Status 'Disabled debug output must not change planning status.'

$throwingDebugOptions = [Activator]::CreateInstance($debugOptionsType)
$throwingDebugOptions.Enabled = $true
$throwingDebugOptions.Sink = [Activator]::CreateInstance($throwingSinkType)
$throwingJob = New-FacadeJob
$throwingJob.DebugOptions = $throwingDebugOptions
$throwingResult = $servicePlan.Invoke($service, @($throwingJob, [Threading.CancellationToken]::None))
Assert-Equal $firstFacadeResult.MapResult.Map.InputFingerprint $throwingResult.MapResult.Map.InputFingerprint 'Debug sink errors must not change the map fingerprint.'
Assert-Equal $firstFacadeResult.PlanningResult.Status $throwingResult.PlanningResult.Status 'Debug sink errors must not change planning status.'
Assert-Equal $firstFacadeResult.PlanningResult.Path.Count $throwingResult.PlanningResult.Path.Count 'Debug sink errors must not change the path.'
Assert-Equal 1 $throwingResult.DebugDiagnostics.Count 'Debug sink errors must be retained as diagnostics.'

Write-Output 'Coarse path facade checks passed.'

# P1 UI scenario factory contract checks. These run against the same built assembly
# and intentionally fail until the factory is introduced.
$testNamespace = $coarsePath + 'Test.'
$scenarioEnumType = $assembly.GetType($testNamespace + 'CoarsePathTestScenario', $false)
$scenarioFactoryType = $assembly.GetType($testNamespace + 'CoarsePathScenarioFactory', $false)
Assert-True ($scenarioEnumType -ne $null) 'P1 scenario enum must exist.'
Assert-True ($scenarioFactoryType -ne $null) 'P1 scenario factory must exist.'

$factoryCreate = Find-Method $scenarioFactoryType 'Create' @($scenarioEnumType)
$factoryCreateAtAmr = Find-Method $scenarioFactoryType 'Create' @(
    $scenarioEnumType, [double], [double], [double])
$factoryManual = Find-Method $scenarioFactoryType 'CreateManualGoalDemo' @(
    [double], [double], [double], [double], [double], [double])
Assert-True ($factoryCreate -ne $null) 'P1 scenario factory must expose Create(scenario).'
Assert-True ($factoryCreateAtAmr -ne $null) 'Scenario factory must expose Create(scenario, amrX, amrY, amrHeading).'
Assert-True ($factoryManual -ne $null) 'P1 scenario factory must expose CreateManualGoalDemo with six doubles.'

$scenarioNames = @('ExplicitEmpty', 'RectangleDetour', 'ManualAndTwoLeg', 'CacheHit', 'ReverseGearSwitch', 'NoFeasiblePath')
foreach ($scenarioName in $scenarioNames) {
    $scenario = [Enum]::Parse($scenarioEnumType, $scenarioName)
    $jobA = $factoryCreate.Invoke($null, @($scenario))
    $jobB = $factoryCreate.Invoke($null, @($scenario))
    Assert-True ($jobA -ne $null) "Scenario $scenarioName must return a job."
    Assert-False ([object]::ReferenceEquals($jobA, $jobB)) "Scenario $scenarioName must return a new job per call."
}

foreach ($scenarioName in $scenarioNames) {
    $scenario = [Enum]::Parse($scenarioEnumType, $scenarioName)
    $liveJob = $factoryCreateAtAmr.Invoke($null, @($scenario, 12345.0, -6789.0, 135.0))
    Assert-Near 12.345 $liveJob.Start.X "Live $scenarioName start X must equal the AMR X."
    Assert-Near -6.789 $liveJob.Start.Y "Live $scenarioName start Y must equal the AMR Y."
    Assert-Near (3.0 * [Math]::PI / 4.0) $liveJob.Start.Heading "Live $scenarioName heading must equal the AMR heading."
}

$rectangleScenario = [Enum]::Parse($scenarioEnumType, 'RectangleDetour')
$liveRectangle = $factoryCreateAtAmr.Invoke($null, @($rectangleScenario, 12000.0, -3000.0, 90.0))
Assert-Near 12.0 $liveRectangle.Start.X 'Live rectangle start X must equal the AMR X.'
Assert-Near -3.0 $liveRectangle.Start.Y 'Live rectangle start Y must equal the AMR Y.'
Assert-Near ([Math]::PI / 2.0) $liveRectangle.Start.Heading 'Live rectangle start heading must equal the AMR heading.'
Assert-Near 16.0 $liveRectangle.Goal.X 'Live rectangle goal X must preserve the four-metre relative offset.'
Assert-Near -3.0 $liveRectangle.Goal.Y 'Live rectangle goal Y must preserve the relative offset.'
Assert-Near ([Math]::PI / 2.0) $liveRectangle.Goal.Heading 'Live rectangle goal heading must preserve the zero baseline heading delta.'
Assert-Near 11000.0 $liveRectangle.MapRequest.Bounds.XMin 'Live rectangle map X minimum must translate with the AMR.'
Assert-Near -1000.0 $liveRectangle.MapRequest.Bounds.YMax 'Live rectangle map Y maximum must translate with the AMR.'
$rectangleProjection = $liveRectangle.MapRequest.ObstacleSources[0].ProjectToWorld()
$rectangleObstacle = $rectangleProjection.Obstacles[0]
Assert-Near 13700.0 $rectangleObstacle.XMin 'Live rectangle obstacle X minimum must translate with the AMR.'
Assert-Near -2200.0 $rectangleObstacle.YMax 'Live rectangle obstacle Y maximum must translate with the AMR.'

$multiScenario = [Enum]::Parse($scenarioEnumType, 'ManualAndTwoLeg')
$baselineMulti = $factoryCreate.Invoke($null, @($multiScenario))
$liveMulti = $factoryCreateAtAmr.Invoke($null, @($multiScenario, 7000.0, 8000.0, 45.0))
Assert-Near 7.0 $liveMulti.Start.X 'Live multi-source start X must equal AMR X.'
Assert-Near 8.0 $liveMulti.Start.Y 'Live multi-source start Y must equal AMR Y.'
Assert-Near ([Math]::PI / 4.0) $liveMulti.Goal.Heading 'Live multi-source goal heading must follow AMR heading.'
$baselineTwoLeg = $baselineMulti.MapRequest.ObstacleSources[1].ProjectToWorld().Obstacles
$liveTwoLeg = $liveMulti.MapRequest.ObstacleSources[1].ProjectToWorld().Obstacles
Assert-Near ($baselineTwoLeg[0].CenterX + 6000.0) $liveTwoLeg[0].CenterX 'TwoLeg X must translate without rotation.'
Assert-Near ($baselineTwoLeg[0].CenterY + 7000.0) $liveTwoLeg[0].CenterY 'TwoLeg Y must translate without rotation.'

$noPathScenario = [Enum]::Parse($scenarioEnumType, 'NoFeasiblePath')
$liveNoPath = $factoryCreateAtAmr.Invoke($null, @($noPathScenario, 9000.0, -1000.0, -180.0))
Assert-Near 9.0 $liveNoPath.Start.X 'Live infeasible scenario must use AMR X.'
Assert-Near -1.0 $liveNoPath.Start.Y 'Live infeasible scenario must use AMR Y.'
Assert-Near 8000.0 $liveNoPath.MapRequest.Bounds.XMin 'Live infeasible map must translate with its baseline start.'

$invalidLivePoseRejected = $false
try { $null = $factoryCreateAtAmr.Invoke($null, @($rectangleScenario, [double]::NaN, 0.0, 0.0)) }
catch [ArgumentOutOfRangeException] {
    $invalidLivePoseRejected = $true
}
catch [Reflection.TargetInvocationException] {
    $invalidLivePoseRejected = $_.Exception.InnerException -is [ArgumentOutOfRangeException]
}
Assert-True $invalidLivePoseRejected 'Live factory must reject non-finite AMR coordinates.'

$manualJob = $factoryManual.Invoke($null, @(1000.0, 2000.0, 90.0, 4000.0, 2000.0, 0.0))
Assert-Near 1.0 $manualJob.Start.X 'Manual AMR X must convert mm to m.'
Assert-Near 2.0 $manualJob.Start.Y 'Manual AMR Y must convert mm to m.'
Assert-Near ([Math]::PI / 2.0) $manualJob.Start.Heading 'Manual AMR heading must convert degrees to radians.'
Assert-Near 4.0 $manualJob.Goal.X 'Manual goal X must convert mm to m.'
Assert-Near 0.0 $manualJob.Goal.Heading 'Manual goal heading must convert degrees to radians.'
Assert-True $manualJob.MapRequest.AllowExplicitEmptyMap 'Manual goal demo must declare its empty map explicitly.'

$slowFeasibleJob = $factoryManual.Invoke($null, @(1000.0, 2000.0, 0.0, 1500.0, 2500.0, 90.0))
$slowFeasibleJob.Configuration.SearchTimeout = [TimeSpan]::FromSeconds(30)
$slowFeasibleJob.Configuration.MaximumExpandedNodes = 1000000
$slowFeasibleService = [Activator]::CreateInstance($serviceType)
$slowFeasibleResult = $servicePlan.Invoke($slowFeasibleService, @($slowFeasibleJob, [Threading.CancellationToken]::None))
Assert-Equal 'Success' $slowFeasibleResult.PlanningResult.Status.ToString() 'The previously five-second-limited feasible pose must succeed with a caller-selected longer budget.'
Assert-True ($slowFeasibleResult.PlanningResult.Diagnostics.PathSearchElapsed -le $slowFeasibleResult.PlanningResult.Diagnostics.Elapsed) 'Slow feasible path-search time must remain within total elapsed time.'

$manualObstacleKindType = $assembly.GetType($testNamespace + 'ManualCoarsePathObstacleKind', $false)
$manualObstacleType = $assembly.GetType($testNamespace + 'ManualCoarsePathObstacle', $false)
Assert-True ($manualObstacleKindType -ne $null) 'Manual obstacle kind enum must exist.'
Assert-True ($manualObstacleType -ne $null) 'Manual obstacle value type must exist.'

$manualCircle = Find-Method $manualObstacleType 'Circle' @([double], [double], [double])
$manualRectangle = Find-Method $manualObstacleType 'AxisAlignedRectangle' @([double], [double], [double], [double])
$manualObstacleFactory = $scenarioFactoryType.GetMethods() | Where-Object {
    $_.Name -eq 'CreateManualObstacleDemo' -and $_.GetParameters().Length -eq 8
} | Select-Object -First 1
Assert-True ($manualCircle -ne $null) 'Manual obstacle type must create circles from center and radius.'
Assert-True ($manualRectangle -ne $null) 'Manual obstacle type must create rectangles from center and X/Y dimensions.'
Assert-True ($manualObstacleFactory -ne $null) 'Scenario factory must expose CreateManualObstacleDemo with six poses, obstacles and version.'

$manualObstacles = [Array]::CreateInstance($manualObstacleType, 2)
$manualObstacles.SetValue($manualCircle.Invoke($null, @([double]6500, [double]2000, [double]200)), 0)
$manualObstacles.SetValue($manualRectangle.Invoke($null, @([double]-2000, [double]500, [double]600, [double]400)), 1)
$manualObstacleJob = $manualObstacleFactory.Invoke($null, @(
    1000.0, 2000.0, 0.0, 4000.0, 2000.0, 0.0, $manualObstacles, [long]77))
Assert-False $manualObstacleJob.MapRequest.AllowExplicitEmptyMap 'Manual obstacles must disable the explicit-empty-map mode.'
Assert-Equal 1 $manualObstacleJob.MapRequest.ObstacleSources.Count 'Manual obstacles must create one unified source.'
Assert-Equal 'manual-user-input' $manualObstacleJob.MapRequest.ObstacleSources[0].SourceId 'Manual source ID must be stable.'
Assert-Equal 77 $manualObstacleJob.MapRequest.ObstacleSources[0].SourceVersion 'Manual source version must be preserved.'
Assert-True ($manualObstacleJob.MapRequest.Bounds.XMin -le -4300.0) 'Manual map must include the rectangle outline and padding.'
Assert-True ($manualObstacleJob.MapRequest.Bounds.XMax -ge 8700.0) 'Manual map must include the circle outline and padding.'

$emptyManualObstacles = [Array]::CreateInstance($manualObstacleType, 0)
$emptyManualJob = $manualObstacleFactory.Invoke($null, @(
    1000.0, 2000.0, 0.0, 4000.0, 2000.0, 0.0, $emptyManualObstacles, [long]0))
Assert-True $emptyManualJob.MapRequest.AllowExplicitEmptyMap 'Zero manual obstacles must retain explicit empty-map mode.'
Assert-Equal 0 $emptyManualJob.MapRequest.ObstacleSources.Count 'Zero manual obstacles must not create a fake source.'

$invalidGeometryRejected = $false
try {
    $null = $manualCircle.Invoke($null, @([double]1000, [double]1000, [double]0))
}
catch [ArgumentOutOfRangeException] { $invalidGeometryRejected = $true }
catch [Reflection.TargetInvocationException] {
    $invalidGeometryRejected = $_.Exception.InnerException -is [ArgumentOutOfRangeException]
}
Assert-True $invalidGeometryRejected 'Invalid manual geometry must report argument range.'

$scenarioService = [Activator]::CreateInstance($serviceType)
foreach ($scenarioName in @('ExplicitEmpty', 'RectangleDetour', 'ManualAndTwoLeg', 'ReverseGearSwitch')) {
    $job = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, $scenarioName)))
    $result = $servicePlan.Invoke($scenarioService, @($job, [Threading.CancellationToken]::None))
    Assert-Equal 'Success' $result.PlanningResult.Status.ToString() "Scenario $scenarioName must succeed."
}

$reverseJob = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'ReverseGearSwitch')))
$reverseResult = $servicePlan.Invoke($scenarioService, @($reverseJob, [Threading.CancellationToken]::None))
Assert-True (($reverseResult.PlanningResult.Path | Where-Object { $_.IsGearSwitchPoint }).Count -ge 1) 'Reverse scenario must expose a gear-switch point.'

$noPathJob = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'NoFeasiblePath')))
$noPathResult = $servicePlan.Invoke($scenarioService, @($noPathJob, [Threading.CancellationToken]::None))
Assert-Equal 'NoFeasiblePath' $noPathResult.PlanningResult.Status.ToString() 'Barrier scenario must be infeasible.'
Assert-Equal 0 $noPathResult.PlanningResult.Path.Count 'Infeasible scenario must not publish a path.'
Assert-True ($noPathResult.PlanningResult.Diagnostics.TerminationReason.Contains('Open List')) 'No-path diagnostics must retain the exact search exhaustion reason.'
Assert-True (-not [string]::IsNullOrWhiteSpace($noPathResult.PlanningResult.Diagnostics.TerminationReason)) 'No-path diagnostics must retain a reason.'

$cacheJobA = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'CacheHit')))
$cacheJobB = $factoryCreate.Invoke($null, @([Enum]::Parse($scenarioEnumType, 'CacheHit')))
$cacheFirst = $servicePlan.Invoke($scenarioService, @($cacheJobA, [Threading.CancellationToken]::None))
$cacheSecond = $servicePlan.Invoke($scenarioService, @($cacheJobB, [Threading.CancellationToken]::None))
Assert-Equal 'Input' $cacheSecond.MapResult.CacheHit.ToString() 'Cache-hit scenario must reuse the complete map input.'
Assert-Equal $cacheFirst.PlanningResult.Status $cacheSecond.PlanningResult.Status 'Map cache reuse must not change planning status.'

Write-Output 'Coarse path P1 scenario checks passed.'
