# Shared env for Claude Proxy Control (product). Dot-source from other scripts.
# ASCII-only on purpose: this file is dot-sourced by every elevated script and must
# parse identically with or without a BOM under Windows PowerShell 5.1.
$ErrorActionPreference = "Continue"

if (-not $env:CLAUDE_PROXY_HOME -or -not (Test-Path $env:CLAUDE_PROXY_HOME)) {
  $env:CLAUDE_PROXY_HOME = Join-Path $env:USERPROFILE ".claude-proxy-control"
}
$ProductHome = $env:CLAUDE_PROXY_HOME
$MergedDir = Join-Path $ProductHome "merged"
$SingBoxRoot = Join-Path $ProductHome "sing-box"
$TaskWatcher = "CPC-SotaCaptureWatcher"
$TaskMonitor = "CPC-NetMonitor"

# Adapters we own. Everything else on the machine is somebody else's VPN.
$CpcAdapterPattern = '^cpc\d'

$DesktopApp = $null
$dp = Join-Path $ProductHome "desktop-path.txt"
if (Test-Path $dp) {
  $DesktopApp = (Get-Content $dp -Raw -Encoding UTF8).Trim().Trim([char]0xFEFF)
}
if (-not $DesktopApp -or -not (Test-Path $DesktopApp)) {
  $cand = Join-Path $env:USERPROFILE ".claude-proxy\product\desktop"
  if (Test-Path $cand) { $DesktopApp = $cand }
}

New-Item -ItemType Directory -Force -Path $MergedDir | Out-Null

function Get-CpcSingBoxProcesses {
  Get-CimInstance Win32_Process -Filter "Name='sing-box.exe'" -ErrorAction SilentlyContinue |
    Where-Object {
      $_.ExecutablePath -and ($_.ExecutablePath.StartsWith($ProductHome, [System.StringComparison]::OrdinalIgnoreCase))
    }
}

function Get-OtherSingBoxProcesses {
  Get-CimInstance Win32_Process -Filter "Name='sing-box.exe'" -ErrorAction SilentlyContinue |
    Where-Object {
      -not $_.ExecutablePath -or (-not $_.ExecutablePath.StartsWith($ProductHome, [System.StringComparison]::OrdinalIgnoreCase))
    }
}

function Get-CpcSingBoxCandidates {
  # Broad match used for stop/detect: path under our home, or command line that
  # clearly references our config. Never matches a sing-box owned by another product.
  @(Get-CimInstance Win32_Process -Filter "Name='sing-box.exe'" -ErrorAction SilentlyContinue | Where-Object {
    ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($ProductHome, [System.StringComparison]::OrdinalIgnoreCase)) -or
    ($_.CommandLine -and (
      $_.CommandLine -like ("*" + $ProductHome + "*") -or
      $_.CommandLine -like "*claude-proxy-control*" -or
      $_.CommandLine -like "*vpn-claude-config.json*"
    ))
  })
}

function Stop-CpcSingBox {
  # Path may be blank when elevated - also match command line / any sing-box under ProductHome config
  $list = @(Get-CpcSingBoxCandidates)
  if (-not $list) {
    # last resort: if our status says we own a pid, kill it
    $statusPath = Join-Path $MergedDir "status.json"
    if (Test-Path $statusPath) {
      try {
        $st = Get-Content $statusPath -Raw -Encoding UTF8 | ConvertFrom-Json
        $ownedPid = 0
        try { $ownedPid = [int]($st.singBoxPid) } catch { $ownedPid = 0 }
        if ($ownedPid -gt 0) {
          $p = Get-Process -Id $ownedPid -ErrorAction SilentlyContinue
          if ($p -and $p.ProcessName -match 'sing-box') {
            $list = @([pscustomobject]@{ ProcessId = $ownedPid })
          }
        }
      } catch {}
    }
  }
  foreach ($p in $list) {
    Write-Host ("  stop CPC sing-box pid={0}" -f $p.ProcessId) -ForegroundColor Yellow
    Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
  }
  return @($list).Count
}

function New-CpcTunName {
  return ("cpc" + (Get-Date -Format "HHmmss") + (Get-Random -Maximum 99))
}

function Get-CpcTunDevices {
  # Wintun / sing-tun PnP devices, each annotated with the network adapter that
  # currently sits on top of it (if any). Adapter.PnPDeviceID and PnpDevice.InstanceId
  # differ in casing ("SWD\Wintun\..." vs "SWD\WINTUN\..."), so normalise before joining.
  $byPnp = @{}
  foreach ($a in @(Get-NetAdapter -ErrorAction SilentlyContinue)) {
    if ($a.PnPDeviceID) { $byPnp[([string]$a.PnPDeviceID).ToUpperInvariant()] = $a }
  }
  $devs = @(Get-PnpDevice -Class Net -ErrorAction SilentlyContinue | Where-Object {
    $_.InstanceId -match 'WINTUN' -or $_.FriendlyName -match 'sing-tun'
  })
  foreach ($d in $devs) {
    $key = ([string]$d.InstanceId).ToUpperInvariant()
    $ad = $null
    if ($byPnp.ContainsKey($key)) { $ad = $byPnp[$key] }
    [pscustomobject]@{
      InstanceId   = [string]$d.InstanceId
      FriendlyName = [string]$d.FriendlyName
      Status       = [string]$d.Status
      AdapterName  = $(if ($ad) { [string]$ad.Name } else { "" })
      AdapterState = $(if ($ad) { [string]$ad.Status } else { "" })
    }
  }
}

function Clear-CpcOrphanWintun {
  # A zombie Wintun device makes sing-box fail with
  # "Cannot create a file when that file already exists".
  #
  # Only two kinds of device may be removed:
  #   1. ours       - the adapter on top of it is named cpc<digits>
  #   2. orphaned   - no adapter sits on it at all, or the device itself is not OK
  #
  # A device backing a live adapter that is NOT ours (Sota, AmneziaVPN, WireGuard,
  # Tailscale ...) is never touched. Removing those used to tear down the user's
  # other VPN on every Apply.
  $removed = 0
  foreach ($d in @(Get-CpcTunDevices)) {
    $isOurs = ($d.AdapterName -match $CpcAdapterPattern)
    if ($d.AdapterName -and -not $isOurs) {
      Write-Host ("  keep foreign tun device: adapter=" + $d.AdapterName + " (" + $d.FriendlyName + ")") -ForegroundColor DarkGray
      continue
    }
    $isOrphan = ((-not $d.AdapterName) -or ($d.Status -ne "OK"))
    if (-not ($isOurs -or $isOrphan)) {
      Write-Host ("  keep tun device (not ours, not orphan): " + $d.FriendlyName) -ForegroundColor DarkGray
      continue
    }
    $who = $(if ($d.AdapterName) { $d.AdapterName } else { "<no adapter>" })
    Write-Host ("  remove Wintun: status=" + $d.Status + " adapter=" + $who + " | " + $d.FriendlyName) -ForegroundColor Yellow
    try { & pnputil.exe /remove-device "$($d.InstanceId)" | Out-Null } catch {}
    try { Remove-PnpDevice -InstanceId $d.InstanceId -Confirm:$false -ErrorAction SilentlyContinue } catch {}
    $removed++
  }
  if ($removed -gt 0) { Start-Sleep -Seconds 2 }
  return $removed
}

function Find-CpcSingBoxExe {
  $sbDir = Get-ChildItem $SingBoxRoot -Directory -Filter "sing-box-*-windows-amd64" -ErrorAction SilentlyContinue |
    Sort-Object Name -Descending | Select-Object -First 1
  if (-not $sbDir) { return $null }
  $exe = Join-Path $sbDir.FullName "sing-box.exe"
  if (Test-Path $exe) { return $exe }
  return $null
}

function Find-CpcBundledPython {
  # The interpreter that ships inside the product. On a machine with no Python at
  # all this is the only one there is, and on a machine that has one it is still
  # the one we tested against.
  param([switch]$Windowed)
  $name = $(if ($Windowed) { "pythonw.exe" } else { "python.exe" })
  foreach ($base in @($ProductHome, (Split-Path $PSScriptRoot -Parent))) {
    if (-not $base) { continue }
    $p = Join-Path (Join-Path $base "python") $name
    if (Test-Path $p) { return $p }
  }
  return $null
}

function Find-CpcPython {
  # 1) The interpreter the UI itself runs on. Written by the desktop app; it is the
  #    only one guaranteed to satisfy the app's version requirement.
  $pp = Join-Path $ProductHome "python-path.txt"
  if (Test-Path $pp) {
    try {
      $fromUi = (Get-Content $pp -Raw -Encoding UTF8).Trim().Trim([char]0xFEFF)
      if ($fromUi -and (Test-Path $fromUi)) { return $fromUi }
    } catch {}
  }
  # 2) The bundled interpreter, before anything the machine happens to have.
  $bundled = Find-CpcBundledPython
  if ($bundled) { return $bundled }
  # 3) Explicit override
  if ($env:CPC_PYTHON -and (Test-Path $env:CPC_PYTHON)) { return $env:CPC_PYTHON }
  # 4) PATH
  foreach ($name in @("python.exe", "python")) {
    $cmd = Get-Command $name -ErrorAction SilentlyContinue
    if ($cmd -and $cmd.Source -and (Test-Path $cmd.Source)) { return $cmd.Source }
  }
  # 5) py launcher - ask it where the newest 3.x lives
  $py = Get-Command "py.exe" -ErrorAction SilentlyContinue
  if ($py) {
    try {
      $found = & $py.Source -3 -c "import sys; print(sys.executable)" 2>$null
      $found = ($found | Select-Object -Last 1)
      if ($found) {
        $found = ([string]$found).Trim()
        if ($found -and (Test-Path $found)) { return $found }
      }
    } catch {}
  }
  # 6) Usual install locations, newest first. No developer-machine paths here.
  $roots = @(
    (Join-Path $env:LOCALAPPDATA "Programs\Python"),
    "$env:ProgramFiles",
    "${env:ProgramFiles(x86)}",
    "$env:SystemDrive\"
  )
  $cands = @()
  foreach ($r in $roots) {
    if (-not $r -or -not (Test-Path $r)) { continue }
    $cands += @(Get-ChildItem -Path $r -Directory -Filter "Python3*" -ErrorAction SilentlyContinue |
      ForEach-Object { Join-Path $_.FullName "python.exe" })
  }
  $cands = @($cands | Where-Object { $_ -and (Test-Path $_) } | Sort-Object -Descending)
  if ($cands.Count -gt 0) { return $cands[0] }
  return $null
}

function Get-CpcRouteFinal {
  # Read the actual route.final out of the built config instead of assuming "block".
  param([string]$ConfigPath = "")
  if (-not $ConfigPath) { $ConfigPath = Join-Path $MergedDir "vpn-claude-config.json" }
  if (-not (Test-Path $ConfigPath)) { return "" }
  try {
    $cfg = Get-Content $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json
    return [string]$cfg.route.final
  } catch {
    return ""
  }
}

function Enter-CpcReloadLock {
  # Cross-process guard: two elevated reloads used to run at once, stop each other's
  # sing-box and cross-read each other's reload-result.json.
  param([int]$TimeoutSec = 120)
  $lockPath = Join-Path $MergedDir "reload.lock"
  $deadline = (Get-Date).AddSeconds($TimeoutSec)
  while ($true) {
    try {
      $fs = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
      $bytes = [System.Text.Encoding]::UTF8.GetBytes(("pid={0} time={1}" -f $PID, (Get-Date).ToString("o")))
      $fs.SetLength(0)
      $fs.Write($bytes, 0, $bytes.Length)
      $fs.Flush()
      return $fs
    } catch {
      if ((Get-Date) -gt $deadline) { return $null }
      Start-Sleep -Milliseconds 500
    }
  }
}

function Exit-CpcReloadLock {
  param($Handle)
  if ($null -eq $Handle) { return }
  try { $Handle.Close() } catch {}
  try { $Handle.Dispose() } catch {}
}

# ── Kill-switch for the TUN rebuild ─────────────────────────────────────────
# Rebuilding routes means stopping sing-box and losing the adapter for a few
# seconds. Without a guard Windows falls back to the physical adapter, so during
# the very moment the user is told to wait, traffic leaves unrouted. A blocking
# firewall rule turns that window into a real pause instead of a leak.
#
# The rule outlives the process that created it, so removal is layered:
#   1. the finally of the reload script,
#   2. Enable- removes a stale rule before adding its own,
#   3. a marker file, so the app can notice a leftover after a crash.
$CpcKillSwitchRule = "CPC-KillSwitch-TunRebuild"

function Get-CpcKillSwitchMarker {
  Join-Path $MergedDir "killswitch.active"
}

function Remove-CpcKillSwitchRule {
  $existed = $false
  try {
    $r = Get-NetFirewallRule -DisplayName $CpcKillSwitchRule -ErrorAction SilentlyContinue
    if ($r) {
      $existed = $true
      Remove-NetFirewallRule -DisplayName $CpcKillSwitchRule -ErrorAction SilentlyContinue
    }
  } catch {}
  try { Remove-Item (Get-CpcKillSwitchMarker) -Force -ErrorAction SilentlyContinue } catch {}
  return $existed
}

function Enable-CpcKillSwitch {
  # Marker first: if we die between the two steps, a marker without a rule is
  # harmless, while a rule without a marker would be invisible to the app.
  try {
    $txt = "pid={0} time={1}" -f $PID, (Get-Date).ToString("s")
    Set-Content -Path (Get-CpcKillSwitchMarker) -Value $txt -Encoding UTF8 -ErrorAction SilentlyContinue
  } catch {}
  try { [void](Remove-NetFirewallRule -DisplayName $CpcKillSwitchRule -ErrorAction SilentlyContinue) } catch {}
  try {
    [void](New-NetFirewallRule -DisplayName $CpcKillSwitchRule `
      -Description "Temporary block while Claude Proxy Control rebuilds the tunnel" `
      -Direction Outbound -Action Block -Enabled True -Profile Any -ErrorAction Stop)
    return $true
  } catch {
    # Firewall service off or policy forbids it: better to rebuild without the
    # guard than to refuse applying routes at all - but say so in the trace.
    try { Remove-Item (Get-CpcKillSwitchMarker) -Force -ErrorAction SilentlyContinue } catch {}
    return $false
  }
}

function Disable-CpcKillSwitch {
  [void](Remove-CpcKillSwitchRule)
}

function Wait-CpcAdapterGone {
  # Replaces a flat sleep: the adapter usually disappears with the process that
  # owned it, so waiting for the fact is far shorter than waiting a fixed time.
  param([int]$TimeoutMs = 2500)
  $sw = [System.Diagnostics.Stopwatch]::StartNew()
  while ($sw.ElapsedMilliseconds -lt $TimeoutMs) {
    $left = @(Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $CpcAdapterPattern })
    if ($left.Count -eq 0) { return $true }
    Start-Sleep -Milliseconds 150
  }
  return $false
}
