import os
import sys
import json
import platform
import subprocess
from urllib.parse import urlparse, parse_qs
from datetime import datetime
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler

PORT = 4567

# Hiding console window for background subprocesses on Windows
SUBPROCESS_CREATIONFLAGS = 0
if platform.system() == 'Windows':
    SUBPROCESS_CREATIONFLAGS = subprocess.CREATE_NO_WINDOW
    # Avoid UnicodeEncodeError when logging paths with non-cp1252 characters
    try:
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
        sys.stderr.reconfigure(encoding='utf-8', errors='replace')
    except Exception:
        pass


def _safe_print(*args, **kwargs):
    """Print without crashing on Windows cp1252 consoles."""
    try:
        print(*args, **kwargs)
    except UnicodeEncodeError:
        text = ' '.join(str(a) for a in args)
        print(text.encode('ascii', errors='replace').decode('ascii'), **kwargs)


def choose_file_native(title="Select File", file_filter="All Files (*.*)|*.*", ext_filter=None):
    if platform.system() == 'Windows':
        ps_script = f"""
        $formsPath = [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms").Location
        Add-Type -ReferencedAssemblies $formsPath -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Windows.Forms;

public class ForegroundWindowWrapper : IWin32Window {{
    [DllImport("user32.dll")]
    private static extern IntPtr GetForegroundWindow();

    public IntPtr Handle {{
        get {{ return GetForegroundWindow(); }}
    }}
}}
"@

        $FileBrowser = New-Object System.Windows.Forms.OpenFileDialog
        $FileBrowser.Title = "{title}"
        $FileBrowser.Filter = "{file_filter}"
        
        $owner = New-Object ForegroundWindowWrapper
        $Show = $FileBrowser.ShowDialog($owner)
        if ($Show -eq "OK") {{
            Write-Host $FileBrowser.FileName
        }}
        """
        try:
            res = subprocess.run(
                ["powershell", "-NoProfile", "-STA", "-Command", ps_script],
                capture_output=True,
                text=True,
                check=True,
                creationflags=SUBPROCESS_CREATIONFLAGS
            )
            return res.stdout.strip()
        except Exception as e:
            print(f"[Agent] PowerShell file dialog failed: {e}")
            if hasattr(e, 'stderr') and e.stderr:
                print(f"[Agent] PowerShell stderr: {e.stderr}")
            if hasattr(e, 'stdout') and e.stdout:
                print(f"[Agent] PowerShell stdout: {e.stdout}")
            
    elif platform.system() == 'Darwin':  # macOS
        mac_script = f'POSIX path of (choose file with prompt "{title}")'
        try:
            res = subprocess.run(
                ["osascript", "-e", mac_script],
                capture_output=True,
                text=True,
                check=True
            )
            return res.stdout.strip()
        except Exception as e:
            print(f"[Agent] macOS AppleScript file dialog failed: {e}")
            
    else:  # Linux
        import shutil
        if shutil.which('zenity'):
            try:
                res = subprocess.run(
                    ["zenity", "--file-selection", f"--title={title}"],
                    capture_output=True,
                    text=True
                )
                return res.stdout.strip()
            except Exception as e:
                print(f"[Agent] Zenity file dialog failed: {e}")
                
    # Fallback/mock for local agent testing in Linux VMs
    print(f"[Agent] File dialog requested on non-supported platform. Returning mock path.")
    if ext_filter == 'exe':
        return "C:\\Program Files (x86)\\GTG\\GoldSim 15\\GSPlayer.exe"
    elif ext_filter == 'gsp':
        return "C:\\models\\reservoir.gsp"
    elif ext_filter in ('xlsx', 'xls', 'excel'):
        return "C:\\models\\inputs.xlsx"
    return "C:\\models\\outputs.txt"


def choose_directory_native(title="Select Folder"):
    if platform.system() == 'Windows':
        ps_script = f"""
        $formsPath = [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms").Location
        Add-Type -ReferencedAssemblies $formsPath -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Windows.Forms;

public class ForegroundWindowWrapper : IWin32Window {{
    [DllImport("user32.dll")]
    private static extern IntPtr GetForegroundWindow();

    public IntPtr Handle {{
        get {{ return GetForegroundWindow(); }}
    }}
}}
"@

        $FolderBrowser = New-Object System.Windows.Forms.FolderBrowserDialog
        $FolderBrowser.Description = "{title}"
        $owner = New-Object ForegroundWindowWrapper
        $Show = $FolderBrowser.ShowDialog($owner)
        if ($Show -eq "OK") {{
            Write-Host $FolderBrowser.SelectedPath
        }}
        """
        try:
            res = subprocess.run(
                ["powershell", "-NoProfile", "-STA", "-Command", ps_script],
                capture_output=True,
                text=True,
                check=True,
                creationflags=SUBPROCESS_CREATIONFLAGS
            )
            return res.stdout.strip()
        except Exception as e:
            print(f"[Agent] PowerShell folder dialog failed: {e}")
    elif platform.system() == 'Darwin':
        mac_script = f'POSIX path of (choose folder with prompt "{title}")'
        try:
            res = subprocess.run(
                ["osascript", "-e", mac_script],
                capture_output=True,
                text=True,
                check=True
            )
            return res.stdout.strip()
        except Exception as e:
            print(f"[Agent] macOS folder dialog failed: {e}")
    else:
        import shutil
        if shutil.which('zenity'):
            try:
                res = subprocess.run(
                    ["zenity", "--file-selection", "--directory", f"--title={title}"],
                    capture_output=True,
                    text=True
                )
                return res.stdout.strip()
            except Exception as e:
                print(f"[Agent] Zenity folder dialog failed: {e}")
    print("[Agent] Folder dialog unavailable; returning empty path.")
    return ""


def is_absolute_path(p):
    if not p:
        return False
    if os.path.isabs(p):
        return True
    # Windows drive letter even when agent runs on Linux/WSL
    if len(p) >= 3 and p[1] == ':' and p[2] in ('/', '\\'):
        return True
    if p.startswith('\\\\') or p.startswith('//'):
        return True
    return False


def resolve_project_path(working_directory, file_path):
    """Resolve relative project paths against working_directory."""
    p = (file_path or '').strip()
    if not p:
        return ''
    if platform.system() != 'Windows':
        p = p.replace('\\', '/')
    if is_absolute_path(p):
        return os.path.normpath(p)
    root = (working_directory or '').strip()
    if not root:
        return os.path.normpath(p)
    if platform.system() != 'Windows':
        root = root.replace('\\', '/')
    rel = p[2:] if p.startswith('./') or p.startswith('.\\') else p
    return os.path.normpath(os.path.join(root, rel))


def _json_response(handler, status, payload):
    import json
    body = json.dumps(payload).encode('utf-8')
    handler.send_response(status)
    handler.send_header('Content-Type', 'application/json')
    handler.send_header('Content-Length', str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def resolve_web_data_write_path(payload):
    """Prefer writing web_data.json beside the .gsp so GSPy_ToGS can find it."""
    import json
    working_directory = (payload.get('workingDirectory') or '').strip()
    gs_model = (payload.get('gsModelPath') or '').strip()
    raw_path = (payload.get('path') or './data/web_data.json').strip()

    if gs_model:
        model_abs = resolve_project_path(working_directory, gs_model)
        if model_abs and os.path.isfile(model_abs):
            return os.path.join(os.path.dirname(model_abs), 'web_data.json')
        if model_abs:
            # Model path configured but file missing — still stage beside intended package
            parent = os.path.dirname(model_abs)
            if parent:
                return os.path.join(parent, 'web_data.json')

    return resolve_project_path(working_directory, raw_path) or raw_path


def handle_write_web_data(handler, payload):
    data = payload.get('data')
    if data is None or not isinstance(data, dict) or isinstance(data, list):
        _json_response(handler, 400, {'error': 'data must be a JSON object'})
        return

    abs_path = resolve_web_data_write_path(payload)
    if not abs_path:
        _json_response(handler, 400, {'error': 'path is required (or set gsModelPath)'})
        return

    parent = os.path.dirname(abs_path)
    if parent:
        os.makedirs(parent, exist_ok=True)

    import json
    with open(abs_path, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2)
    _safe_print(f'[Agent] Wrote web_data.json -> {abs_path}')
    _json_response(handler, 200, {'success': True, 'path': abs_path})


# GSPy bridge DLLs in this repo are linked against python314.dll (3.11 also supported by GSPy).
GSPY_PREFERRED_PYTHONS = (314, 311)


def _python_home_has_dll(home, tag):
    home = os.path.abspath(home) if home else ''
    if not home:
        return False
    return os.path.isfile(os.path.join(home, f'python{tag}.dll'))


def _detect_gspy_required_python_tag(pkg):
    """Return 314/311/... if GSPy_FromGS.dll imports pythonXXX.dll, else None."""
    dll_path = os.path.join(pkg or '', 'GSPy_FromGS.dll')
    if not os.path.isfile(dll_path):
        return None
    try:
        raw = open(dll_path, 'rb').read()
    except OSError:
        return None
    low = raw.lower()
    for tag in (314, 311, 313, 312, 310):
        if f'python{tag}.dll'.encode('ascii') in low:
            return tag
    return None


def _query_py_launcher_home(version):
    """Ask Windows `py -3.XX` where that interpreter lives."""
    if platform.system() != 'Windows':
        return ''
    try:
        res = subprocess.run(
            ['py', f'-3.{version % 100}', '-c', 'import sys; print(sys.base_prefix)'],
            capture_output=True,
            text=True,
            check=False,
            creationflags=SUBPROCESS_CREATIONFLAGS,
        )
        home = (res.stdout or '').strip().splitlines()
        home = home[-1].strip() if home else ''
        if home and os.path.isdir(home):
            return os.path.abspath(home)
    except Exception:
        pass
    return ''


def _candidate_python_homes_for_tag(tag):
    """Common install locations for a given python3XX tag (e.g. 314)."""
    ver_minor = tag % 100
    names = [
        f'Python{tag}',
        f'Python3{ver_minor}',
        f'python{tag}',
    ]
    roots = []
    local = os.environ.get('LOCALAPPDATA') or ''
    user = os.path.expanduser('~')
    for root in (
        os.path.join(local, 'Programs', 'Python') if local else '',
        user,
        r'C:\Program Files\Python',
        r'C:\Program Files (x86)\Python',
        r'C:\\',
    ):
        if not root:
            continue
        for name in names:
            roots.append(os.path.join(root, name))
    # Also try py launcher
    launched = _query_py_launcher_home(tag)
    if launched:
        roots.insert(0, launched)
    # Dedupe
    out = []
    for h in roots:
        h = os.path.abspath(h) if h else ''
        if h and h not in out:
            out.append(h)
    return out


def resolve_python_home(pkg=None):
    """
    Locate a Python install compatible with the GSPy DLLs.

    These IWRM GSPy bridges import python314.dll — using python/python312
    (even if it runs the helper) will fail with "Could not load the DLL".
    """
    required = _detect_gspy_required_python_tag(pkg) if pkg else None
    tags = []
    if required:
        tags.append(required)
    for t in GSPY_PREFERRED_PYTHONS:
        if t not in tags:
            tags.append(t)

    # 1) Running interpreter, only if it matches a required/preferred tag
    running = []
    for c in (
        getattr(sys, 'base_prefix', None) or '',
        getattr(sys, 'prefix', None) or '',
        os.path.dirname(os.path.abspath(sys.executable)),
    ):
        c = os.path.abspath(c) if c else ''
        if c and c not in running:
            running.append(c)
    for tag in tags:
        for home in running:
            if _python_home_has_dll(home, tag):
                return home

    # 2) Search known install locations / py launcher for required tags
    for tag in tags:
        for home in _candidate_python_homes_for_tag(tag):
            if _python_home_has_dll(home, tag):
                return home

    # 3) Last resort: running interpreter (may be wrong — caller should warn)
    return running[0] if running else ''


def _find_python_runtime_dll(python_home, pkg=None):
    if not python_home:
        return ''
    required = _detect_gspy_required_python_tag(pkg) if pkg else None
    order = []
    if required:
        order.append(required)
    order.extend([314, 311, 313, 312, 310])
    seen = set()
    for tag in order:
        if tag in seen:
            continue
        seen.add(tag)
        path = os.path.join(python_home, f'python{tag}.dll')
        if os.path.isfile(path):
            return path
    return ''


def unblock_windows_files(folder):
    """Clear Mark-of-the-Web so LoadLibrary can open DLLs from a downloaded zip."""
    if platform.system() != 'Windows' or not folder or not os.path.isdir(folder):
        return
    try:
        # Unblock-File clears Zone.Identifier ADS that Chrome/Edge attach to downloads.
        ps = (
            f'Get-ChildItem -LiteralPath "{folder}" -Recurse -File -ErrorAction SilentlyContinue '
            f'| Unblock-File -ErrorAction SilentlyContinue'
        )
        subprocess.run(
            ['powershell', '-NoProfile', '-Command', ps],
            check=False,
            capture_output=True,
            text=True,
            creationflags=SUBPROCESS_CREATIONFLAGS,
        )
        _safe_print(f'[Agent] Unblocked files in {folder}')
    except Exception as exc:
        _safe_print(f'[Agent] Unblock-File skipped: {exc}')


def stage_python_runtime_beside_gspy(pkg, python_home):
    """
    Copy the python3xx.dll that GSPy imports next to GSPy_*.dll.
    """
    import shutil
    src = _find_python_runtime_dll(python_home, pkg=pkg)
    if not src or not pkg:
        return
    required = _detect_gspy_required_python_tag(pkg)
    if required and os.path.basename(src).lower() != f'python{required}.dll':
        _safe_print(
            f'[Agent] WARNING: GSPy needs python{required}.dll but found {os.path.basename(src)}. '
            f'Start the helper with: py -3.{required % 100} .\\local_agent.py'
        )
    dest = os.path.join(pkg, os.path.basename(src))
    try:
        if os.path.isfile(dest) and os.path.getsize(dest) == os.path.getsize(src):
            return
        shutil.copy2(src, dest)
        _safe_print(f'[Agent] Staged {os.path.basename(src)} beside GSPy DLLs')
        # Remove mismatched runtimes left by a previous wrong-Python agent run
        if required:
            for tag in (312, 313, 310, 311, 314):
                if tag == required:
                    continue
                stale = os.path.join(pkg, f'python{tag}.dll')
                if os.path.isfile(stale):
                    try:
                        os.remove(stale)
                        _safe_print(f'[Agent] Removed mismatched {os.path.basename(stale)}')
                    except OSError:
                        pass
    except Exception as exc:
        _safe_print(f'[Agent] Could not stage Python runtime DLL: {exc}')


def ensure_gspy_python_path(pkg, python_home):
    """Rewrite GSPy_*.json python_path beside the model so GSPy embeds this Python."""
    if not pkg or not python_home or not os.path.isdir(pkg):
        return
    # GSPy configs expect Windows-style paths
    normalized = python_home.replace('/', '\\')
    for name in ('GSPy_FromGS.json', 'GSPy_ToGS.json', 'GSPy_PC.json'):
        path = os.path.join(pkg, name)
        if not os.path.isfile(path):
            continue
        try:
            with open(path, 'r', encoding='utf-8') as f:
                data = json.load(f)
            if not isinstance(data, dict):
                continue
            if str(data.get('python_path') or '').replace('/', '\\') == normalized:
                continue
            data['python_path'] = normalized
            with open(path, 'w', encoding='utf-8', newline='\n') as f:
                json.dump(data, f, indent=2)
                f.write('\n')
            _safe_print(f'[Agent] Updated {name} python_path -> {normalized}')
        except Exception as exc:
            _safe_print(f'[Agent] Could not update {name}: {exc}')


def gspy_launch_env(python_home):
    """Ensure GSPlayer can LoadLibrary(GSPy_*.dll) by finding python3xx.dll on PATH."""
    env = os.environ.copy()
    if not python_home:
        return env
    extras = [python_home, os.path.join(python_home, 'Scripts')]
    path_now = env.get('PATH', '')
    parts = [p for p in extras if p and os.path.isdir(p)]
    # Prepend so GoldSim resolves python3xx.dll next to this interpreter.
    env['PATH'] = os.pathsep.join(parts + ([path_now] if path_now else []))
    return env


def handle_gspy_solve(handler, model):
    """Run GoldSim Player for a GSPy-backed model and return chart_data.json."""
    import time

    settings = model.get('settings') or {}
    sc = settings.get('solverConfig') or model.get('solverConfig') or {}
    working_directory = (settings.get('workingDirectory') or '').strip()

    gs_player = (sc.get('gsPlayerPath') or '').strip().strip('"')
    gs_model = (sc.get('gsModelPath') or '').strip().strip('"')
    gs_start = (sc.get('gsStartTime') or '').strip()
    gs_end = (sc.get('gsEndTime') or '').strip()
    gs_duration = (sc.get('gsDuration') or '').strip()
    gs_time_mode = (sc.get('gsTimeMode') or 'calendar').strip().lower()
    gs_log = (sc.get('gsLogPath') or '').strip()

    if not gs_model:
        _json_response(handler, 400, {
            'error': 'GoldSim model path is not set. Open Project Settings > GoldSim Player setup wizard.',
        })
        return

    gs_model = resolve_project_path(working_directory, gs_model)
    if not os.path.isfile(gs_model):
        _json_response(handler, 400, {'error': f'GoldSim model not found: {gs_model}'})
        return

    if not gs_player or not os.path.isfile(gs_player):
        found = find_goldsim_exe(prefer_player=True)
        if found:
            gs_player = found
        else:
            _json_response(handler, 400, {
                'error': 'GoldSim Player executable not found. Set gsPlayerPath in Simulator Settings.',
            })
            return

    pkg = os.path.dirname(gs_model)
    web_beside = os.path.join(pkg, 'web_data.json')
    if not os.path.isfile(web_beside):
        # Fallback: copy from working dir relative path if present
        alt = resolve_project_path(working_directory, sc.get('webDataPath') or './data/web_data.json')
        if alt and os.path.isfile(alt) and os.path.abspath(alt) != os.path.abspath(web_beside):
            import shutil
            shutil.copy2(alt, web_beside)
            print(f'[Agent] Staged web_data.json -> {web_beside}')

    python_home = resolve_python_home(pkg)
    required_tag = _detect_gspy_required_python_tag(pkg) or 314
    unblock_windows_files(pkg)
    ensure_gspy_python_path(pkg, python_home)
    stage_python_runtime_beside_gspy(pkg, python_home)
    launch_env = gspy_launch_env(python_home)
    if python_home:
        _safe_print(f'[Agent] GSPy Python home: {python_home}')
    if not _python_home_has_dll(python_home, required_tag):
        msg = (
            f'Python 3.{required_tag % 100} is required to run this model '
            f'(GSPy needs python{required_tag}.dll). '
            'Stop the helper and from Command Prompt run: '
            f'py -3.{required_tag % 100} local_agent.py. '
            'Install from https://www.python.org/downloads/ if needed.'
        )
        _safe_print(f'[Agent] ERROR: {msg}')
        _json_response(handler, 400, {
            'error': msg,
            'log': '',
            'logPath': '',
            'sourceLog': '',
        })
        return
    staged = os.path.join(pkg, f'python{required_tag}.dll')
    if not os.path.isfile(staged):
        msg = (
            f'Could not place python{required_tag}.dll next to the GSPy DLLs. '
            f'Confirm Python 3.{required_tag % 100} is installed, then run: '
            f'py -3.{required_tag % 100} local_agent.py'
        )
        _safe_print(f'[Agent] ERROR: {msg}')
        _json_response(handler, 400, {'error': msg, 'log': '', 'logPath': '', 'sourceLog': ''})
        return

    run_started = time.time()
    model_arg = os.path.basename(gs_model)
    cmd = [gs_player, '-hl']
    if gs_start:
        cmd.extend(['-starttime', gs_start])
    if gs_time_mode == 'duration' and gs_duration:
        cmd.extend(['-duration', gs_duration])
    elif gs_end:
        cmd.extend(['-endtime', gs_end])
    cmd.extend(['-r', model_arg])

    print(f'[Agent] GSPy solve: {" ".join(cmd)} (cwd={pkg})')
    log_text = ''
    if platform.system() != 'Windows':
        time.sleep(0.5)
        print('[Agent] Non-Windows - skipping GSPlayer (expect existing chart_data.json)')
    else:
        res = subprocess.run(
            cmd,
            check=False,
            cwd=pkg,
            env=launch_env,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            creationflags=SUBPROCESS_CREATIONFLAGS,
        )
        if res.returncode != 0:
            print(f'[Agent] GSPlayer exit code {res.returncode}', file=sys.stderr)

    # Bundle B-SOAR results if applicable
    bsoar_filenames = [
        "Annual_Dose_Result.txt",
        "ReleaseRate_Comparison.txt",
        "ActivityConc_Biosphere.txt",
        "SNF_Unexposed_Mass.txt",
    ]
    has_bsoar_txt = any(
        os.path.isfile(os.path.join(pkg, fn)) or os.path.isfile(os.path.join(pkg, 'export_txt', fn))
        for fn in bsoar_filenames
    )
    if has_bsoar_txt:
        _safe_print('[Agent] Detected B-SOAR model results. Bundling text results into chart_data.json...')
        try:
            payload = {"datasets": {}, "vectors": {}}
            files_to_bundle = {
                "biosphere": "ActivityConc_Biosphere.txt",
                "snf_unexposed_mass": "SNF_Unexposed_Mass.txt",
                "annual_dose": "Annual_Dose_Result.txt",
                "wf_release_rate_snf": "WF_ReleaseRate_SNF.txt",
                "wf_cum_release_snf": "WF_CumRelease_SNF.txt",
                "release_rate_comparison": "ReleaseRate_Comparison.txt",
                "smox_unexposed_mass": "sMOX_Unexposed_Mass.txt",
                "wf_cum_release_smox": "WF_CumRelease_sMOX.txt",
                "wf_release_rate_smox": "WF_ReleaseRate_sMOX.txt",
                "hlwg_unexposed_mass": "HLWg_Unexposed_Mass.txt",
                "wf_cum_release_hlwg": "WF_CumRelease_HLWg.txt",
                "wf_release_rate_hlwg": "WF_ReleaseRate_HLWg.txt",
                "hlwc_unexposed_mass": "HLWc_Unexposed_Mass.txt",
                "wf_cum_release_hlwc": "WF_CumRelease_HLWc.txt",
                "wf_release_rate_hlwc": "WF_ReleaseRate_HLWc.txt",
                "g_fraction_wp_failed_gc": "G_Fraction_WP_Failed_GC.txt",
                "g_fraction_wp_failed_lc": "G_Fraction_WP_Failed_LC.txt",
                "g_fraction_wp_failed_alt": "G_Fraction_WP_Failed_Alterntiv.txt",
                "g_fraction_wps_failed": "G_Fraction_WPs_Failed.txt",
                "g_fractionwps_comp": "G_FractionWPs_Comp.txt",
                "gc_fractionbreached": "GC_FractionBreached.txt",
                "lc_fractionbreached": "LC_FractionBreached.txt",
                "g_wp_breachfraction_alt": "G_WP_BreachFraction_Alternativ.txt",
                "wp_breach_fraction": "WP_Breach_Fraction.txt",
                "g_wp_breach_comp": "G_WP_Breach_Comp.txt",
                "single_event_time": "Single_Event_Time.txt",
                "multiple_event_time": "Multiple_Event_Time.txt",
                "event_exceedance_freq": "Event_Exceedance_Freq.txt",
                "g_wastepackagesfailed": "G_WastePackagesFailed.txt",
                "conc_in_wp_water_snf": "Conc_in_WP_Water_SNF.txt",
                "conc_in_wp_water_smox": "Conc_in_WP_Water_sMOX.txt",
                "conc_in_wp_water_hlwg": "Conc_in_WP_Water_HLWg.txt",
                "conc_in_wp_water_hlwc": "Conc_in_WP_Water_HLWc.txt",
                "ppt_mass_in_wp_snf": "PPT_Mass_in_WP_SNF.txt",
                "ppt_mass_in_wp_smox": "PPT_Mass_in_WP_sMOX.txt",
                "ppt_mass_in_wp_hlwg": "PPT_Mass_in_WP_HLWg.txt",
                "ppt_mass_in_wp_hlwc": "PPT_Mass_in_WP_HLWc.txt",
                "wp_releaserate_snf_rn": "WP_ReleaseRate_SNF_RN.txt",
                "wp_releaserate_smox_rn": "WP_ReleaseRate_sMOX_RN.txt",
                "wp_releaserate_hlwg_rn": "WP_ReleaseRate_HLWg_RN.txt",
                "wp_releaserate_hlwc_rn": "WP_ReleaseRate_HLWc_RN.txt",
                "g_wp_releaserate_total_per_wf": "G_WP_ReleaseRate_Total_Per_WF.txt",
                "bf_releaserate_snf_rn": "BF_ReleaseRate_SNF_RN.txt",
                "bf_releaserate_smox_rn": "BF_ReleaseRate_sMOX_RN.txt",
                "bf_releaserate_hlwg_rn": "BF_ReleaseRate_HLWg_RN.txt",
                "bf_releaserate_hlwc_rn": "BF_ReleaseRate_HLWc_RN.txt",
                "g_bf_releaserate_total_per_wf": "G_BF_ReleaseRate_Total_Per_WF.txt",
                "nf_releaserate_snf_rn": "NF_ReleaseRate_SNF_RN.txt",
                "nf_releaserate_smox_rn": "NF_ReleaseRate_sMOX_RN.txt",
                "nf_releaserate_hlwg_rn": "NF_ReleaseRate_HLWg_RN.txt",
                "nf_releaserate_hlwc_rn": "NF_ReleaseRate_HLWc_RN.txt",
                "g_nf_releaserate_total_per_wf": "G_NF_ReleaseRate_Total_Per_WF.txt",
                "g_bf_efffractioncracked": "G_BF_EffFractionCracked.txt",
                "bf_flowrate_gr": "BF_FlowRate_Gr.txt",
                "nf_releasescomparison_snf": "NF_ReleasesComparison_SNF.txt",
                "nf_releasescomparison_smox": "NF_ReleasesComparison_sMOX.txt",
                "nf_releasescomparison_hlwg": "NF_ReleasesComparison_HLWg.txt",
                "nf_releasescomparison_hlwc": "NF_ReleasesComparison_HLWc.txt",
                "nf_releasescomparison_all": "NF_ReleasesComparison_All.txt",
                "bf_top_releaserate_snf_rn": "BF_top_ReleaseRate_SNF_RN.txt",
                "bf_top_releaserate_smox_rn": "BF_top_ReleaseRate_sMOX_RN.txt",
                "bf_top_releaserate_hlwg_rn": "BF_top_ReleaseRate_HLWg_RN.txt",
                "bf_top_releaserate_hlwc_rn": "BF_top_ReleaseRate_HLWc_RN.txt",
                "g_bf_ap_relrate_total_per_wf": "G_BF_AP_RelRate_Total_Per_WF.txt",
                "bf_releasescomparison_snf": "BF_ReleasesComparison_SNF.txt",
                "bf_releasescomparison_smox": "BF_ReleasesComparison_sMOX.txt",
                "bf_releasescomparison_hlwg": "BF_ReleasesComparison_HLWg.txt",
                "bf_releasescomparison_hlwc": "BF_ReleasesComparison_HLWc.txt",
                "nf_ap_releaserate_snf_rn": "NF_AP_ReleaseRate_SNF_RN.txt",
                "nf_ap_releaserate_smox_rn": "NF_AP_ReleaseRate_sMOX_RN.txt",
                "nf_ap_releaserate_hlwg_rn": "NF_AP_ReleaseRate_HLWg_RN.txt",
                "nf_ap_releaserate_hlwc_rn": "NF_AP_ReleaseRate_HLWc_RN.txt",
                "g_nf_ap_relrate_total_per_wf": "G_NF_AP_RelRate_Total_Per_WF.txt",
                "nf_ap_releasescomparison_snf": "NF_AP_ReleasesComparison_SNF.txt",
                "nf_ap_releasescomparison_smox": "NF_AP_ReleasesComparison_sMOX.txt",
                "nf_ap_releasescomparison_hlwg": "NF_AP_ReleasesComparison_HLWg.txt",
                "nf_ap_releasescomparison_hlwc": "NF_AP_ReleasesComparison_HLWc.txt",
                "g_ff_leg_1_mass": "G_FF_Leg_1_Mass.txt",
                "g_ff_leg_1_release": "G_FF_Leg_1_Release.txt",
                "g_ff_leg_2_mass": "G_FF_Leg_2_Mass.txt",
                "g_ff_leg_2_release": "G_FF_Leg_2_Release.txt",
                "g_ff_leg_3_mass": "G_FF_Leg_3_Mass.txt",
                "g_ff_leg_3_release": "G_FF_Leg_3_Release.txt",
                "g_ff_cumulativerelease": "G_FF_CumulativeRelease.txt",
                "g_ff_ap_release": "G_FF_AP_Release.txt",
                "g_ff_release_comparison": "G_FF_Release_Comparison.txt",
                "g_activityconc_biospherewater": "G_ActivityConc_BiosphereWater.txt",
                "annual_dose_result_b": "Annual_Dose_Result_b.txt",
                "g_annual_dose_rn": "G_Annual_Dose_RN.txt",
                "g_annual_dose_total_wt": "G_Annual_Dose_Total_wt.txt",
                "g_annual_dose_total_wt_comp": "G_Annual_Dose_Total_wt_Comp.txt",
                "annual_dose_rn_result_wt": "Annual_Dose_RN_Result_wt.txt",
                "g_annual_dose_wt_altern": "G_Annual_Dose_wt_Altern.txt"
            }
            for key, fname in files_to_bundle.items():
                candidates = [
                    os.path.join(pkg, fname),
                    os.path.join(pkg, 'export_txt', fname),
                ]
                for fpath in candidates:
                    if os.path.isfile(fpath):
                        with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
                            payload["datasets"][key] = f.read()
                        break
            
            gspy_vectors_candidates = [
                os.path.join(pkg, 'gspy_vectors.json'),
                os.path.join(pkg, 'export_txt', 'gspy_vectors.json'),
            ]
            for gv_path in gspy_vectors_candidates:
                if os.path.isfile(gv_path):
                    with open(gv_path, "r", encoding="utf-8") as f:
                        payload["vectors"] = json.load(f)
                    break

            chart_data_path = os.path.join(pkg, 'chart_data.json')
            with open(chart_data_path, 'w', encoding='utf-8') as f:
                json.dump(payload, f)
            _safe_print(f'[Agent] Successfully generated chart_data.json -> {chart_data_path}')
        except Exception as e:
            _safe_print(f'[Agent] ERROR bundling B-SOAR results: {e}')

    # Prefer log beside model
    log_candidates = []
    if gs_log:
        log_candidates.append(resolve_project_path(working_directory, gs_log))
    base, _ = os.path.splitext(gs_model)
    log_candidates.append(base + '.log')
    for lp in log_candidates:
        if lp and os.path.isfile(lp):
            try:
                with open(lp, 'r', encoding='utf-8', errors='ignore') as f:
                    log_text = f.read()
                gs_log = lp
                break
            except OSError:
                pass

    log_errors = []
    for line in (log_text or '').splitlines():
        low = line.lower()
        if 'fatal error' in low or (line.strip().startswith('Error') and 'Error Description' not in line):
            log_errors.append(line.strip())
        if 'could not load the dll' in low:
            log_errors.append(
                line.strip()
                + ' (usually missing python3xx.dll on PATH — restart the helper after updating local_agent.py)'
            )

    chart_candidates = [
        os.path.join(pkg, 'chart_data.json'),
        resolve_project_path(working_directory, sc.get('chartDataPath') or './data/chart_data.json'),
    ]
    chart_path = None
    for c in chart_candidates:
        if not c or not os.path.isfile(c):
            continue
        try:
            if os.path.getmtime(c) >= run_started - 2:
                chart_path = c
                break
        except OSError:
            continue
    if not chart_path:
        for c in chart_candidates:
            if c and os.path.isfile(c):
                chart_path = c
                break

    if log_errors and not chart_path:
        _json_response(handler, 500, {
            'error': log_errors[0] if log_errors else 'GoldSim run failed',
            'log': log_text,
            'logPath': gs_log or '',
            'sourceLog': gs_log or '',
            'run_status': {'ok': False, 'errors': log_errors, 'summary': log_errors[0] if log_errors else ''},
        })
        return

    if not chart_path:
        _json_response(handler, 500, {
            'error': 'No chart_data.json found after Player run. Confirm GSPy_FromGS writes beside the model package.',
            'log': log_text,
            'logPath': gs_log or '',
            'sourceLog': gs_log or '',
        })
        return

    try:
        with open(chart_path, 'r', encoding='utf-8') as f:
            chart_payload = json.load(f)
    except Exception as e:
        _json_response(handler, 500, {'error': f'Could not read chart_data.json: {e}'})
        return

    if chart_payload.get('error') or chart_payload.get('source') == 'stub':
        _json_response(handler, 500, {
            'error': chart_payload.get('error') or 'Stub results - GoldSim did not produce chart_data.json',
            'log': log_text,
            'logPath': gs_log or '',
            'sourceLog': gs_log or '',
            'run_status': chart_payload.get('run_status'),
        })
        return

    out = {
        'format': 'chart_data_json',
        **chart_payload,
        'log': log_text,
        'logPath': gs_log or '',
        'sourceLog': gs_log or '',
    }
    print(f'[Agent] GSPy solve OK - {chart_path}')
    _json_response(handler, 200, out)


def _windows_path_candidates(path_val: str) -> list[str]:
    """Return path variants to try (native Windows + WSL /mnt/c/...)."""
    raw = str(path_val or '').strip().strip('"')
    if not raw:
        return []
    out = [raw]
    normalized = os.path.normpath(raw.replace('/', '\\') if platform.system() == 'Windows' else raw)
    if normalized not in out:
        out.append(normalized)
    # WSL or Linux agent checking a Windows path from the browser
    if len(raw) >= 3 and raw[1] == ':' and raw[0].isalpha():
        drive = raw[0].lower()
        rest = raw[2:].replace('\\', '/').lstrip('/')
        mnt = f'/mnt/{drive}/{rest}'
        if mnt not in out:
            out.append(mnt)
    return out


def path_exists_any(path_val: str) -> tuple[bool, str]:
    """True if any path candidate exists. Returns (exists, resolved_path)."""
    for candidate in _windows_path_candidates(path_val):
        try:
            if os.path.isfile(candidate) or os.path.isdir(candidate) or os.path.exists(candidate):
                return True, candidate
        except OSError:
            continue
    return False, str(path_val or '').strip()


def find_goldsim_exe(prefer_player=False):
    """Locate GoldSim.exe or GSPlayer.exe on a typical Windows install.

    Full GoldSim installs put GSPlayer.exe beside GoldSim.exe under
    ``...\\GTG\\GoldSim 15\\``. Standalone Player installs use
    ``...\\GTG\\GoldSim Player 15\\``.
    """
    names = ('GSPlayer.exe', 'GoldSim.exe') if prefer_player else ('GoldSim.exe', 'GSPlayer.exe')
    exact_roots = [
        os.environ.get('GOLDSIM_HOME', ''),
        os.environ.get('GOLDSIM_PLAYER_HOME', ''),
        r'C:\Program Files (x86)\GTG\GoldSim 15',
        r'C:\Program Files (x86)\GTG\GoldSim Player 15',
        r'C:\Program Files\GTG\GoldSim 15',
        r'C:\Program Files\GTG\GoldSim Player 15',
        r'C:\Program Files (x86)\GTG\GoldSim 14',
        r'C:\Program Files (x86)\GTG\GoldSim Player 14',
    ]
    for root in exact_roots:
        if not root:
            continue
        for name in names:
            for candidate in _windows_path_candidates(os.path.join(root, name)):
                if os.path.isfile(candidate):
                    return candidate if platform.system() == 'Windows' else _to_windows_display_path(candidate)

    # Scan every product folder under GTG (covers renamed / future versions).
    gtg_roots = [
        r'C:\Program Files (x86)\GTG',
        r'C:\Program Files\GTG',
    ]
    for gtg in gtg_roots:
        for gtg_path in _windows_path_candidates(gtg):
            if not os.path.isdir(gtg_path):
                continue
            try:
                for entry in os.listdir(gtg_path):
                    for name in names:
                        candidate = os.path.join(gtg_path, entry, name)
                        if os.path.isfile(candidate):
                            return candidate if platform.system() == 'Windows' else _to_windows_display_path(candidate)
            except OSError:
                continue
    return ''


def _to_windows_display_path(path_val: str) -> str:
    """Prefer a Windows-looking path for UI when we resolved via /mnt/c/..."""
    p = str(path_val or '')
    if p.startswith('/mnt/') and len(p) > 6 and p[5].isalpha() and p[6] == '/':
        drive = p[5].upper()
        rest = p[7:].replace('/', '\\')
        return f'{drive}:\\{rest}'
    return p


def find_gsplayer_exe() -> str:
    """Always prefer GSPlayer.exe (full GoldSim install or standalone Player)."""
    found = find_goldsim_exe(prefer_player=True)
    if found and found.lower().endswith('gsplayer.exe'):
        return found
    # If find returned GoldSim.exe only, look beside it
    if found and found.lower().endswith('goldsim.exe'):
        sibling = found[:-len('GoldSim.exe')] + 'GSPlayer.exe'
        exists, resolved = path_exists_any(sibling)
        if exists:
            return resolved if platform.system() == 'Windows' else _to_windows_display_path(resolved)
    return found if (found and 'gsplayer' in found.lower()) else (found or '')


def open_local_path(target_path, *, with_exe=''):
    """Open a file with the OS association, or with an explicit EXE for GoldSim models."""
    if not target_path:
        raise FileNotFoundError('Empty path')
    # URL-decoded paths often use forward slashes
    if platform.system() == 'Windows':
        target_path = os.path.normpath(target_path.replace('/', '\\'))
    else:
        target_path = os.path.normpath(target_path)

    if not os.path.isfile(target_path) and not os.path.isdir(target_path):
        raise FileNotFoundError(f'Path does not exist: {target_path}')

    ext = os.path.splitext(target_path)[1].lower()
    is_goldsim_model = ext in ('.gsp', '.gsm', '.gso', '.gsz')
    is_player_package = ext == '.gsp'

    exe = (with_exe or '').strip().strip('"')
    if exe and is_goldsim_model:
        # Coerce mismatched EXE vs file type (UI may pass gsPlayerPath for either).
        if is_player_package and exe.lower().endswith('goldsim.exe'):
            candidate = exe[:-len('GoldSim.exe')] + 'GSPlayer.exe'
            if os.path.isfile(candidate):
                exe = candidate
        elif (not is_player_package) and exe.lower().endswith('gsplayer.exe'):
            candidate = exe[:-len('GSPlayer.exe')] + 'GoldSim.exe'
            if os.path.isfile(candidate):
                exe = candidate
    if not exe and is_goldsim_model:
        # .gsp → Player; editable .gsm/.gso/.gsz → full GoldSim when available
        exe = find_goldsim_exe(prefer_player=is_player_package)

    if exe:
        if not os.path.isfile(exe):
            raise FileNotFoundError(f'GoldSim executable not found: {exe}')
        print(f'[Agent] Opening with {exe}: {target_path}')
        if platform.system() == 'Windows':
            subprocess.Popen([exe, target_path], cwd=os.path.dirname(target_path) or None)
        else:
            # WSL agent: launch Windows EXE via interop if path is on /mnt/c
            subprocess.Popen([exe, target_path])
        return f'Opened with {os.path.basename(exe)}: {target_path}'

    print(f'[Agent] Opening via OS association: {target_path}')
    if platform.system() == 'Windows':
        os.startfile(target_path)  # noqa: S606 — intentional desktop open
    elif platform.system() == 'Darwin':
        subprocess.Popen(['open', target_path])
    else:
        subprocess.Popen(['xdg-open', target_path])
    return f'Opened via OS association: {target_path}'


def resolve_defined_name(wb, name):
    if not name:
        return None
    # 1. Check workbook level defined names
    if name in wb.defined_names:
        defn = wb.defined_names[name]
        try:
            dests = list(defn.destinations)
            if dests:
                sheet_name, cell_addr = dests[0]
                cell_addr = cell_addr.replace('$', '')
                return sheet_name, cell_addr
        except Exception as e:
            print(f"[Agent] Error resolving workbook defined name '{name}': {e}")
    
    # 2. Check sheet-scoped defined names
    for sheetname in wb.sheetnames:
        ws = wb[sheetname]
        if name in ws.defined_names:
            defn = ws.defined_names[name]
            try:
                dests = list(defn.destinations)
                if dests:
                    sheet_name, cell_addr = dests[0]
                    cell_addr = cell_addr.replace('$', '')
                    return sheet_name, cell_addr
            except Exception as e:
                print(f"[Agent] Error resolving sheet-scoped defined name '{name}' in sheet '{sheetname}': {e}")
    return None

def parse_goldsim_ts(filepath):
    """
    Parses a GoldSim time series export text file containing one or more
    variables with columns of Monte Carlo realizations.
    """
    if not os.path.exists(filepath):
        raise FileNotFoundError(f"File not found: {filepath}")

    with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
        lines = f.readlines()

    results = {}
    current_var = None
    current_unit = None
    current_desc = None
    timestamps = []
    realizations = []
    num_realizations = 0
    data_started = False
    delimiter = '\t'

    # Helper function to flush the current parsed variable data
    def flush_current_var():
        nonlocal current_var, current_unit, current_desc, realizations, timestamps
        if current_var and timestamps:
            results[current_var] = {
                "variable": current_var,
                "unit": current_unit,
                "description": current_desc,
                "timestamps": list(timestamps),
                "realizations": [list(r) for r in realizations]
            }
        # Reset variables for the next block
        timestamps = []
        realizations = []

    for line_idx, line in enumerate(lines):
        line = line.strip()
        if not line:
            continue

        # Check for Metadata lines
        if line.startswith('!'):
            if line.startswith('!Result:'):
                flush_current_var()
                current_var = line.split(':', 1)[1].strip()
                current_unit = None
                current_desc = None
                data_started = False
                num_realizations = 0
            elif line.startswith('!Unit:'):
                current_unit = line.split(':', 1)[1].strip()
            elif line.startswith('!Result Description:'):
                current_desc = line.split(':', 1)[1].strip()
            continue

        # Detect the header line
        if line.startswith('(') and ')' in line and not data_started:
            data_started = True
            delimiter = '\t' if '\t' in line else (',' if ',' in line else ' ')
            
            if delimiter == ' ':
                parts = [p.strip() for p in line.split() if p.strip()]
            else:
                parts = [p.strip() for p in line.split(delimiter) if p.strip()]
                
            num_realizations = len(parts) - 1
            realizations = [[] for _ in range(num_realizations)]
            continue

        # Parse data rows
        if data_started:
            if line.startswith('!'):
                continue
                
            if delimiter == ' ':
                parts = [p.strip() for p in line.split() if p.strip()]
            else:
                parts = [p.strip() for p in line.split(delimiter) if p.strip()]

            if len(parts) < 2:
                continue

            raw_time = parts[0].strip('"')
            time_val = None
            
            for fmt in ("%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M", "%m/%d/%Y"):
                try:
                    dt = datetime.strptime(raw_time, fmt)
                    time_val = dt.timestamp()
                    break
                except ValueError:
                    pass
            
            if time_val is None:
                try:
                    time_val = float(raw_time)
                except ValueError:
                    continue

            timestamps.append(time_val)

            for i in range(num_realizations):
                if i + 1 < len(parts):
                    try:
                        val = float(parts[i+1])
                    except ValueError:
                        val = 0.0
                    realizations[i].append(val)

    flush_current_var()
    return results

def parse_excel_ts(filepath):
    """
    Parses a standard Excel output spreadsheet into the unified TimeSeriesSchema.
    """
    if not os.path.exists(filepath):
        raise FileNotFoundError(f"File not found: {filepath}")
    import openpyxl
    wb = openpyxl.load_workbook(filepath, data_only=True)
    ws = wb.active
    rows = list(ws.iter_rows(values_only=True))
    if not rows or len(rows) <= 1:
        return {}

    headers = rows[0]
    timestamps = []
    series_data = {h: [] for h in headers[1:] if h}
    
    for r in rows[1:]:
        if r[0] is not None:
            if isinstance(r[0], datetime):
                timestamps.append(r[0].timestamp())
            else:
                try:
                    timestamps.append(float(r[0]))
                except ValueError:
                    timestamps.append(str(r[0]))
            
            for idx, val in enumerate(r[1:]):
                if idx + 1 < len(headers):
                    h = headers[idx + 1]
                    if h:
                        try:
                            v = float(val) if val is not None else 0.0
                        except (ValueError, TypeError):
                            v = 0.0
                        series_data[h].append(v)

    results = {}
    for name, vals in series_data.items():
        results[name] = {
            "variable": name,
            "unit": None,
            "description": None,
            "timestamps": timestamps,
            "realizations": [vals]
        }
    return results

class CompanionAgentHandler(BaseHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type')
        # Chrome Private Network Access (HTTPS site → localhost helper)
        self.send_header('Access-Control-Allow-Private-Network', 'true')
        super().end_headers()

    def do_OPTIONS(self):
        self.send_response(204)
        self.end_headers()

    def do_GET(self):
        parsed_url = urlparse(self.path)
        path_name = parsed_url.path
        query = parse_qs(parsed_url.query)

        if path_name in ('/', '/ping', '/health'):
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            payload = {
                'ok': True,
                'service': 'dynamicgui-local-agent',
                'port': PORT,
                # Where the user started the helper — usually the unzipped model package folder.
                'cwd': os.getcwd(),
                'agentPath': os.path.abspath(__file__),
                'pythonVersion': f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}',
                'pythonHome': resolve_python_home(os.getcwd()) or (getattr(sys, 'base_prefix', '') or ''),
                'gspyRequires': '3.14',
            }
            self.wfile.write(json.dumps(payload).encode('utf-8'))
            return

        if path_name == '/path-exists':
            raw = (query.get('path') or [''])[0]
            path_val = str(raw or '').strip()
            exists, resolved = path_exists_any(path_val)
            payload = json.dumps({
                'ok': True,
                'exists': exists,
                'path': path_val,
                'resolved': resolved if exists else path_val,
            }).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(payload)
            return

        if path_name == '/find-gsplayer':
            found = find_gsplayer_exe()
            payload = json.dumps({
                'ok': True,
                'found': bool(found),
                'path': found or '',
            }).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.end_headers()
            self.wfile.write(payload)
            return
        
        if path_name == '/browse-file':
            title_list = query.get('title')
            ext_list = query.get('ext')
            title = title_list[0] if title_list else "Select File"
            ext = ext_list[0] if ext_list else "all"
            
            if ext == 'exe':
                file_filter = "Executables (*.exe)|*.exe|All Files (*.*)|*.*"
            elif ext == 'gsp':
                file_filter = "GoldSim Player Models (*.gsp)|*.gsp|All Files (*.*)|*.*"
            elif ext in ('xlsx', 'xls', 'excel'):
                file_filter = "Excel Spreadsheets (*.xlsx;*.xls)|*.xlsx;*.xls|CSV Files (*.csv)|*.csv|All Files (*.*)|*.*"
            elif ext == 'csv':
                file_filter = "CSV Files (*.csv)|*.csv|All Files (*.*)|*.*"
            elif ext == 'txt':
                file_filter = "Text Files (*.txt)|*.txt|All Files (*.*)|*.*"
            else:
                file_filter = "All Files (*.*)|*.*"

            selected_path = choose_file_native(title, file_filter, ext)
            
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(selected_path.encode('utf-8'))
            return

        if path_name == '/browse-directory':
            title_list = query.get('title')
            title = title_list[0] if title_list else "Select Folder"
            selected_path = choose_directory_native(title)
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(selected_path.encode('utf-8'))
            return

        if path_name == '/get-results':
            target_path_list = query.get('path')
            if not target_path_list:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"Missing 'path' parameter")
                return

            working_list = query.get('workingDirectory') or query.get('cwd')
            working_directory = working_list[0] if working_list else ''
            target_path = resolve_project_path(working_directory, target_path_list[0])

            if not os.path.exists(target_path):
                self.send_response(404)
                self.end_headers()
                self.wfile.write(f"File not found: {target_path}".encode('utf-8'))
                return

            try:
                if target_path.endswith('.json'):
                    with open(target_path, 'r', encoding='utf-8') as f:
                        data = json.load(f)
                elif target_path.endswith('.xlsx') or target_path.endswith('.xls'):
                    data = parse_excel_ts(target_path)
                else:
                    data = parse_goldsim_ts(target_path)

                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps(data).encode('utf-8'))
            except Exception as e:
                import traceback
                traceback.print_exc()
                err_body = json.dumps({'error': f'Error parsing file: {e}'}).encode('utf-8')
                self.send_response(500)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Content-Length', str(len(err_body)))
                self.end_headers()
                self.wfile.write(err_body)
            return

        if path_name in ('/open-file', '/run-program'):
            target_path_list = query.get('path')
            if not target_path_list:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"Missing 'path' parameter")
                return

            working_list = query.get('workingDirectory') or query.get('cwd')
            working_directory = working_list[0] if working_list else ''
            target_path = resolve_project_path(working_directory, target_path_list[0])
            with_exe_list = query.get('with') or query.get('exe') or []
            with_exe = with_exe_list[0] if with_exe_list else ''
            
            # Check if we should block/wait for the subprocess to complete
            wait_list = query.get('wait') or query.get('wait_for_exit') or []
            wait_for_exit = len(wait_list) > 0 and wait_list[0].lower() in ('true', '1', 'yes')
            
            print(f"[Agent] Request to {path_name[1:]}: {target_path} (wait: {wait_for_exit})")

            try:
                if path_name == '/open-file':
                    msg = open_local_path(target_path, with_exe=with_exe)
                elif path_name == '/run-program':
                    # Parse command line without shell=True to avoid syntax errors with spaces/parentheses
                    import shlex
                    clean_path = target_path.strip()
                    if (clean_path.startswith('"') and clean_path.endswith('"')) or (clean_path.startswith("'") and clean_path.endswith("'")):
                        clean_path = clean_path[1:-1]
                    
                    if os.path.exists(clean_path) or ' ' not in clean_path:
                        p = subprocess.Popen([clean_path])
                        if wait_for_exit:
                            p.wait()
                            msg = f'Started and completed program: {clean_path}'
                        else:
                            msg = f'Started program: {clean_path}'
                    else:
                        try:
                            # Use posix=False to preserve Windows-style backslashes if any remain
                            args = shlex.split(target_path, posix=False)
                            args = [a.strip('"').strip("'") for a in args]
                            p = subprocess.Popen(args)
                            if wait_for_exit:
                                p.wait()
                                msg = f'Started and completed program: {" ".join(args)}'
                            else:
                                msg = f'Started program: {" ".join(args)}'
                        except Exception:
                            # Fallback to direct execution
                            p = subprocess.Popen([target_path])
                            if wait_for_exit:
                                p.wait()
                                msg = f'Started and completed program: {target_path}'
                            else:
                                msg = f'Started program: {target_path}'

                self.send_response(200)
                self.send_header('Content-Type', 'text/plain; charset=utf-8')
                self.end_headers()
                self.wfile.write(f"Success: {msg}".encode('utf-8'))
            except Exception as e:
                import traceback
                traceback.print_exc()
                self.send_response(500)
                self.end_headers()
                self.wfile.write(f"Error executing path: {str(e)}".encode('utf-8'))
        else:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b"Not Found")

    def do_POST(self):
        parsed_url = urlparse(self.path)
        path_name = parsed_url.path

        if path_name in ('/write-web-data', '/local-api/write-web-data'):
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            import json
            try:
                payload = json.loads(post_data.decode('utf-8'))
            except Exception as e:
                _json_response(self, 400, {'error': f'Invalid JSON: {e}'})
                return
            try:
                handle_write_web_data(self, payload)
            except Exception as e:
                import traceback
                traceback.print_exc()
                _json_response(self, 500, {'error': str(e)})
            return

        if path_name in ('/solve', '/local-api/solve'):
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            import json
            try:
                model = json.loads(post_data.decode('utf-8'))
            except Exception as e:
                _json_response(self, 400, {'error': f'Invalid JSON: {e}'})
                return
            try:
                handle_gspy_solve(self, model if isinstance(model, dict) else {})
            except Exception as e:
                import traceback
                traceback.print_exc()
                _json_response(self, 500, {'error': str(e)})
            return
        
        if path_name == '/run-solver':
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            
            import json
            try:
                payload = json.loads(post_data.decode('utf-8'))
            except Exception as e:
                import traceback
                print(f"[Agent] Invalid JSON:")
                traceback.print_exc()
                self.send_response(400)
                self.end_headers()
                self.wfile.write(f"Invalid JSON: {str(e)}".encode('utf-8'))
                return
            
            print(f"[Agent] Solver payload received: {payload}")
            
            # Start streaming SSE response!
            self.send_response(200)
            self.send_header('Content-Type', 'text/event-stream')
            self.send_header('Cache-Control', 'no-cache')
            self.send_header('Connection', 'keep-alive')
            self.end_headers()
            
            def send_log(status, message, data=None):
                payload_msg = {"status": status, "message": message}
                if data is not None:
                    payload_msg["data"] = data
                self.wfile.write(f"data: {json.dumps(payload_msg)}\n\n".encode('utf-8'))
                self.wfile.flush()
                
            send_log("info", "Connected to local companion agent.")
            
            try:
                gs_player_path = payload.get('gsPlayerPath', '').strip()
                gs_model_path = payload.get('gsModelPath', '').strip()
                excel_input_path = payload.get('excelInputPath', '').strip()
                excel_output_path = payload.get('excelOutputPath', '').strip()
                gs_start_time = payload.get('gsStartTime', '').strip()
                gs_end_time = payload.get('gsEndTime', '').strip()
                gs_time_mode = payload.get('gsTimeMode', 'calendar').strip().lower()
                gs_duration = payload.get('gsDuration', '').strip()
                inputs = payload.get('inputs', [])
                working_directory = (payload.get('workingDirectory') or '').strip()

                # Resolve relative project paths against working directory
                gs_model_path = resolve_project_path(working_directory, gs_model_path) if gs_model_path else gs_model_path
                excel_input_path = resolve_project_path(working_directory, excel_input_path) if excel_input_path else excel_input_path
                excel_output_path = resolve_project_path(working_directory, excel_output_path) if excel_output_path else excel_output_path
                # Player executable stays absolute / system install path — do not force under project root

                # Check for write lock on Excel files before starting to prevent Com/PowerShell hangs
                for path_label, path_val in [("input spreadsheet", excel_input_path), ("output spreadsheet", excel_output_path)]:
                    if path_val and os.path.exists(path_val):
                        try:
                            # Attempt to open in read/write mode to verify lock
                            with open(path_val, 'r+'):
                                pass
                        except IOError:
                            raise Exception(f"The {path_label} '{os.path.basename(path_val)}' is currently open or locked by Excel. Please save and close the file, then try running the solver again.")

                # Normalize path slashes if running on a non-Windows OS
                if platform.system() != 'Windows':
                    gs_player_path = gs_player_path.replace('\\', '/')
                    gs_model_path = gs_model_path.replace('\\', '/')
                    excel_input_path = excel_input_path.replace('\\', '/')
                    excel_output_path = excel_output_path.replace('\\', '/')

                # ── Step 1: Write inputs to Excel file using PowerShell or openpyxl ──
                if excel_input_path and inputs:
                    send_log("info", f"Writing {len(inputs)} inputs to Excel spreadsheet...")
                    if platform.system() == 'Windows':
                        print(f"[Agent] Opening Excel via PowerShell to write inputs: {excel_input_path}")
                        ps_lines = []
                        ps_lines.append('$excel = New-Object -ComObject Excel.Application')
                        ps_lines.append('$excel.Visible = $false')
                        ps_lines.append('$excel.DisplayAlerts = $false')
                        
                        abs_path = os.path.abspath(excel_input_path).replace('/', '\\')
                        ps_lines.append(f'$wb = $excel.Workbooks.Open("{abs_path}")')
                        
                        for item in inputs:
                            cell_mapping = item.get('cell', '').strip()
                            value = item.get('value', '')
                            if not cell_mapping:
                                continue
                            
                            # Clean/sanitize mapping to prevent injection
                            cell_mapping = cell_mapping.replace('"', '`"')
                            value = str(value).replace('"', '`"')
                            
                            ps_lines.append(f'try {{')
                            # Check named range first
                            ps_lines.append(f'    $range = $wb.Names.Item("{cell_mapping}").RefersToRange')
                            ps_lines.append(f'    $range.Value2 = "{value}"')
                            ps_lines.append(f'}} catch {{')
                            # Try absolute cell address
                            if '!' in cell_mapping:
                                parts = cell_mapping.split('!', 1)
                                sheet_name = parts[0]
                                cell_addr = parts[1]
                                ps_lines.append(f'    $sheet = $wb.Sheets.Item("{sheet_name}")')
                                ps_lines.append(f'    $sheet.Range("{cell_addr}").Value2 = "{value}"')
                            else:
                                ps_lines.append(f'    $sheet = $wb.ActiveSheet')
                                ps_lines.append(f'    $sheet.Range("{cell_mapping}").Value2 = "{value}"')
                            ps_lines.append(f'}}')
                        
                        ps_lines.append('$wb.Save()')
                        ps_lines.append('$wb.Close()')
                        ps_lines.append('$excel.Quit()')
                        ps_lines.append('[System.Runtime.Interopservices.Marshal]::ReleaseComObject($excel) | Out-Null')
                        
                        ps_script = "\n".join(ps_lines)
                        
                        # Execute PowerShell script
                        res = subprocess.run(
                            ["powershell", "-NoProfile", "-Command", ps_script],
                            capture_output=True,
                            text=True,
                            check=True,
                            creationflags=SUBPROCESS_CREATIONFLAGS
                        )
                        if res.returncode != 0:
                            raise Exception(f"PowerShell error: {res.stderr}")
                    else:
                        # Fallback for developer non-Windows OS
                        import openpyxl
                        print(f"[Agent] [Dev Fallback] Opening Excel file via openpyxl: {excel_input_path}")
                        wb = openpyxl.load_workbook(excel_input_path)
                        for item in inputs:
                            cell_mapping = item.get('cell', '').strip()
                            value = item.get('value', '')
                            if not cell_mapping:
                                continue
                            resolved = resolve_defined_name(wb, cell_mapping)
                            if resolved:
                                sheet_name, cell_addr = resolved
                                if ':' in cell_addr:
                                    cell_addr = cell_addr.split(':', 1)[0]
                            else:
                                if '!' in cell_mapping:
                                    sheet_name, cell_addr = cell_mapping.split('!', 1)
                                else:
                                    sheet_name = None
                                    cell_addr = cell_mapping
                            ws = wb[sheet_name] if sheet_name else wb.active
                            try:
                                if isinstance(value, str) and (value.replace('.', '', 1).isdigit() or (value.startswith('-') and value[1:].replace('.', '', 1).isdigit())):
                                    val_to_write = float(value) if '.' in value else int(value)
                                else:
                                    val_to_write = value
                            except Exception:
                                val_to_write = value
                            ws[cell_addr] = val_to_write
                        wb.save(excel_input_path)
                    send_log("info", "Excel inputs written and saved successfully.")

                # ── Step 2: Run GoldSim Player Headless ──
                if gs_model_path:
                    send_log("info", "Launching GoldSim Player headless simulation solver...")
                    cmd = [gs_player_path, "-hl"]
                    if gs_start_time:
                        cmd.append("-starttime")
                        cmd.append(gs_start_time)
                    if gs_time_mode == 'duration' and gs_duration:
                        cmd.append("-duration")
                        cmd.append(gs_duration)
                    elif gs_end_time:
                        cmd.append("-endtime")
                        cmd.append(gs_end_time)
                    cmd.append("-r")
                    cmd.append(gs_model_path)
                    
                    # For logging representation, wrap spaces in quotes without affecting cmd execution array
                    logged_cmd = []
                    for arg in cmd:
                        if ' ' in arg and not (arg.startswith('"') and arg.endswith('"')):
                            logged_cmd.append(f'"{arg}"')
                        else:
                            logged_cmd.append(arg)
                    cmd_str = ' '.join(logged_cmd)
                    print(f"[Agent] Launching GoldSim Player: {cmd_str}")
                    send_log("info", f"Command: {cmd_str}")
                    
                    # On non-Windows OS, we mock/emulate successful run
                    if platform.system() != 'Windows':
                        print(f"[Agent] Non-Windows OS detected. Emulating mock GoldSim run...")
                        import time
                        time.sleep(1.5)
                    else:
                        # Direct Execution
                        # We use subprocess.run to wait synchronously for completion
                        res = subprocess.run(
                            cmd,
                            check=False,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE,
                            creationflags=SUBPROCESS_CREATIONFLAGS
                        )
                        if res.returncode != 0:
                            error_details = ""
                            try:
                                log_path = gs_model_path.rsplit('.', 1)[0] + '.log'
                                if os.path.exists(log_path):
                                    with open(log_path, 'r', encoding='utf-8', errors='ignore') as log_f:
                                        log_lines = log_f.readlines()
                                    # Get last 40 lines containing errors or description details
                                    error_lines = []
                                    capture = False
                                    for line in log_lines[-40:]:
                                        if 'Error' in line or 'Fatal Error' in line or capture or line.startswith('Realization:') or line.startswith('Model Time:'):
                                            error_lines.append(line.strip())
                                            capture = True
                                    if error_lines:
                                        error_details = "\n".join(error_lines)
                            except Exception as log_err:
                                print(f"[Agent] Error reading GoldSim log: {log_err}")
                            
                            err_msg = f"GoldSim Player failed with exit code {res.returncode}."
                            if error_details:
                                err_msg += f"\n\nGoldSim Error Description:\n{error_details}"
                            raise Exception(err_msg)
                    send_log("info", "GoldSim Player simulation run complete.")

                # ── Step 3: Read output results and format for DynamicGUI ──
                results_payload = {
                    "last_updated": "",
                    "time_basis": "calendar",
                    "timestamps": [],
                    "series": []
                }
                
                # If the user specified an output spreadsheet/text path, we parse it
                if excel_output_path and os.path.exists(excel_output_path):
                    send_log("info", "Parsing results from output file...")
                    print(f"[Agent] Parsing solver outputs from: {excel_output_path}")
                    if excel_output_path.endswith('.xlsx') or excel_output_path.endswith('.xls'):
                        import openpyxl
                        wb_out = openpyxl.load_workbook(excel_output_path, data_only=True)
                        ws_out = wb_out.active
                        
                        rows = list(ws_out.iter_rows(values_only=True))
                        if len(rows) > 1:
                            headers = rows[0]
                            timestamps = []
                            series_data = {h: [] for h in headers[1:] if h}
                            
                            for r in rows[1:]:
                                if r[0] is not None:
                                    timestamps.append(str(r[0]))
                                    for idx, val in enumerate(r[1:]):
                                        if idx + 1 < len(headers):
                                            h = headers[idx + 1]
                                            if h:
                                                series_data[h].append(val if val is not None else 0.0)
                                            
                            results_payload["timestamps"] = timestamps
                            results_payload["series"] = [
                                {
                                    "name": name,
                                    "values": vals
                                }
                                for name, vals in series_data.items()
                            ]
                    else:
                        # Assume CSV/TXT tab-separated format (similar to GoldSim Monte Carlo export histories)
                        with open(excel_output_path, 'r', encoding='utf-8') as f:
                            lines = f.readlines()
                        
                        headers = []
                        timestamps = []
                        series_vals = []
                        data_started = False
                        delimiter = '\t'
                        
                        for line in lines:
                            line = line.strip()
                            if not line or line.startswith('!'):
                                continue
                            if line.startswith('(Date)') or line.startswith('(Time)'):
                                data_started = True
                                delimiter = '\t' if '\t' in line else (',' if ',' in line else ' ')
                                headers = [h.strip('"') for h in line.split(delimiter) if h]
                                series_vals = [[] for _ in range(len(headers) - 1)]
                                continue
                            
                            if data_started:
                                parts = [p.strip('"') for p in line.split(delimiter) if p]
                                if len(parts) >= 2:
                                    timestamps.append(parts[0])
                                    for idx, val in enumerate(parts[1:]):
                                        if idx < len(series_vals):
                                            try:
                                                series_vals[idx].append(float(val))
                                            except ValueError:
                                                series_vals[idx].append(val)
                                                
                        results_payload["timestamps"] = timestamps
                        results_payload["series"] = [
                            {
                                "name": headers[idx + 1] if (idx + 1) < len(headers) else f"Series {idx+1}",
                                "values": vals
                            }
                            for idx, vals in enumerate(series_vals)
                        ]
                    send_log("info", "Results parsed successfully.")
                
                # Check for output results
                if excel_output_path:
                    if not os.path.exists(excel_output_path) or not results_payload["timestamps"]:
                        raise Exception(f"Expected output file '{excel_output_path}' was not found or could not be parsed. Verify the GoldSim model runs correctly and exports results to this location.")
                else:
                    # Only fallback to mock if no output path is configured (e.g. showcase demo)
                    if not results_payload["timestamps"]:
                        send_log("info", "No output path configured. Generating default simulation showcase data...")
                        results_payload = {
                            "last_updated": "2026-07-10T00:00:00Z",
                            "time_basis": "calendar",
                            "timestamps": [f"Day {i}" for i in range(1, 31)],
                            "series": [
                                {
                                    "name": "Simulated Demand",
                                    "values": [100 + i * 2.5 + (i % 3) * 10 for i in range(1, 31)]
                                },
                                {
                                    "name": "Reservoir Level",
                                    "values": [80 - i * 1.2 + (i % 2) * 5 for i in range(1, 31)]
                                }
                            ]
                        }
                
                # Send complete status!
                send_log("success", "Simulation completed successfully.", results_payload)
            except Exception as e:
                import traceback
                print(f"[Agent] Solver execution failed:")
                traceback.print_exc()
                send_log("error", f"Solver execution failed: {str(e)}")

        elif path_name == '/select-cell':
            content_length = int(self.headers.get('Content-Length', 0))
            post_data = self.rfile.read(content_length)
            
            import json
            try:
                payload = json.loads(post_data.decode('utf-8'))
            except Exception as e:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(f"Invalid JSON: {str(e)}".encode('utf-8'))
                return
                
            excel_path = payload.get('excelPath', '').strip()
            working_directory = (payload.get('workingDirectory') or '').strip()
            if not excel_path:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"excelPath is required")
                return
            excel_path = resolve_project_path(working_directory, excel_path)
                
            if platform.system() != 'Windows':
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                mock_res = {
                    "success": True,
                    "sheet": "Sheet1",
                    "address": "B5",
                    "defined_name": "Storage_Capacity",
                    "mapping": "Storage_Capacity"
                }
                self.wfile.write(json.dumps(mock_res).encode('utf-8'))
                return
                
            try:
                # Resolve absolute path for Excel
                abs_path = os.path.abspath(excel_path).replace('/', '\\')
                
                # Construct PowerShell command script to select cell using native Excel COM
                ps_script = f"""
                $excel = $null
                $isNewInstance = $false
                try {{
                    $excel = [System.Runtime.InteropServices.Marshal]::GetActiveObject("Excel.Application")
                }} catch {{
                    $excel = New-Object -ComObject Excel.Application
                    $isNewInstance = $true
                }}
                $excel.Visible = $true
                $excel.DisplayAlerts = $false
                $abs_path = "{abs_path}"
                
                $wb = $null
                foreach ($opened_wb in $excel.Workbooks) {{
                    if ($opened_wb.FullName.ToLower() -eq $abs_path.ToLower()) {{
                        $wb = $opened_wb
                        break
                    }}
                }}
                if ($wb -eq $null) {{
                    $wb = $excel.Workbooks.Open($abs_path)
                }}
                $wb.Activate()
                
                # Bring Excel to foreground
                $wshell = New-Object -ComObject Wscript.Shell
                [void]$wshell.AppActivate("Microsoft Excel")
                
                try {{
                    $m = [System.Reflection.Missing]::Value
                    $selected_range = $excel.InputBox("Select a cell or named range in Excel for this input mapping:", "Dynamic-GUI Cell Selection", $m, $m, $m, $m, $m, 8)
                    if ($selected_range) {{
                        $sheet_name = $selected_range.Worksheet.Name
                        $cell_addr = $selected_range.Address($false, $false)
                        if ($cell_addr -match ":") {{
                            $cell_addr = ($cell_addr -split ":")[0]
                        }}
                        
                        $defined_name = ""
                        try {{
                            $defined_name = $selected_range.Name.Name
                            if ($defined_name -match "!") {{
                                $defined_name = ($defined_name -split "!")[1]
                            }}
                        }} catch {{}}
                        
                        $mapping = if ($defined_name) {{ $defined_name }} else {{ "$sheet_name!$cell_addr" }}
                        
                        Write-Output "SUCCESS|$sheet_name|$cell_addr|$defined_name|$mapping"
                    }} else {{
                        Write-Output "CANCELLED"
                    }}
                }} catch {{
                    $errStr = "$_"
                    $msg = if ($_.Exception) {{ $_.Exception.Message }} else {{ "" }}
                    if ($errStr -match "0x80010001" -or $errStr -match "RPC_E_CALL_REJECTED" -or $msg -match "0x80010001" -or $msg -match "RPC_E_CALL_REJECTED") {{
                        Write-Output "ERROR|Excel is currently busy or a cell is actively being edited. Please press Enter or Esc in Excel to exit cell edit mode, then try again."
                    }} else {{
                        Write-Output "ERROR|$_"
                    }}
                }} finally {{
                    if ($selected_range -ne $null) {{
                        [System.Runtime.InteropServices.Marshal]::ReleaseComObject($selected_range) | Out-Null
                    }}
                    if ($wb -ne $null) {{
                        if ($isNewInstance) {{
                            $wb.Close($false)
                        }}
                        [System.Runtime.InteropServices.Marshal]::ReleaseComObject($wb) | Out-Null
                    }}
                    if ($excel -ne $null) {{
                        if ($isNewInstance) {{
                            $excel.Quit()
                        }}
                        [System.Runtime.InteropServices.Marshal]::ReleaseComObject($excel) | Out-Null
                    }}
                    [System.GC]::Collect()
                    [System.GC]::WaitForPendingFinalizers()
                }}
                """
                
                res = subprocess.run(
                    ["powershell", "-NoProfile", "-Command", ps_script],
                    capture_output=True,
                    text=True,
                    check=True,
                    creationflags=SUBPROCESS_CREATIONFLAGS
                )
                
                out_str = res.stdout.strip()
                if out_str.startswith("SUCCESS|"):
                    parts = out_str.split('|')
                    res_data = {
                        "success": True,
                        "sheet": parts[1],
                        "address": parts[2],
                        "defined_name": parts[3],
                        "mapping": parts[4]
                    }
                elif out_str == "CANCELLED":
                    res_data = {
                        "success": False,
                        "error": "Cancelled"
                    }
                elif out_str.startswith("ERROR|"):
                    error_msg = out_str.split('|', 1)[1]
                    res_data = {
                        "success": False,
                        "error": f"Failed to connect or select cell: {error_msg}"
                    }
                else:
                    res_data = {
                        "success": False,
                        "error": f"Unexpected selector output: {out_str}"
                    }
                    
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps(res_data).encode('utf-8'))
            except Exception as e:
                import traceback
                print(f"[Agent] Error selecting Excel cell via PowerShell: {str(e)}")
                traceback.print_exc()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.end_headers()
                self.wfile.write(json.dumps({
                    "success": False,
                    "error": f"Failed to connect to Excel via PowerShell: {str(e)}."
                }).encode('utf-8'))
        else:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b"Not Found")

def maybe_reexec_with_python314():
    """
    If the user launched with plain `python` (often 3.12), relaunch under 3.14.
    Preferred launch is: py -3.14 local_agent.py
    """
    if platform.system() != 'Windows':
        return
    if os.environ.get('DYNAMICGUI_AGENT_NO_REEXEC') == '1':
        return
    if sys.version_info[:2] == (3, 14):
        return

    script = os.path.abspath(__file__)
    print(
        f"Helper was started with Python {sys.version_info.major}.{sys.version_info.minor}.\n"
        "GSPy DLLs in this package require Python 3.14 — relaunching with: py -3.14 ..."
    )
    probe = subprocess.run(
        ['py', '-3.14', '-c', 'import sys; assert sys.version_info[:2]==(3,14)'],
        capture_output=True,
        text=True,
        check=False,
        creationflags=SUBPROCESS_CREATIONFLAGS,
    )
    if probe.returncode != 0:
        print(
            "\nERROR: Python 3.14 was not found on this PC.\n"
            "Install 64-bit Python 3.14 from https://www.python.org/downloads/\n"
            "Then open Command Prompt in this folder and run:\n"
            "  py -3.14 local_agent.py\n"
        )
        try:
            input("Press Enter to exit...")
        except EOFError:
            pass
        sys.exit(1)

    env = os.environ.copy()
    env['DYNAMICGUI_AGENT_NO_REEXEC'] = '1'
    try:
        if sys.platform == 'win32':
            # Use subprocess.call so py.exe arguments with spaces are quoted properly
            code = subprocess.call(['py', '-3.14', script], env=env)
            sys.exit(code)
        else:
            os.execvpe('py', ['py', '-3.14', script], env)
    except OSError as exc:
        print(f"Failed to relaunch with Python 3.14: {exc}")
        sys.exit(1)


def run():
    maybe_reexec_with_python314()
    server_address = ('', PORT)
    httpd = ThreadingHTTPServer(server_address, CompanionAgentHandler)
    ver = f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}'
    home = getattr(sys, 'base_prefix', '') or os.path.dirname(os.path.abspath(sys.executable))
    print(f"Local Companion Agent running on port {PORT}...")
    print(f"Python {ver}  ({home})")
    if sys.version_info[:2] != (3, 14):
        print(
            "WARNING: This package's GSPy DLLs need Python 3.14 (python314.dll).\n"
            "         Stop and run: py -3.14 .\\local_agent.py"
        )
    print("Press Ctrl+C to stop.")
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nStopping Agent.")
        httpd.server_close()

if __name__ == '__main__':
    run()
