"""Flask service exposing uma-ase workflows for web clients."""

from __future__ import annotations

from html.parser import HTMLParser
from html import unescape
import base64
import ast
import json
import io
import math
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import uuid
import warnings
import zipfile
import time
from collections import Counter, deque
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime, timezone
from importlib import metadata as importlib_metadata
from importlib import resources
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from urllib.parse import parse_qs, quote, urljoin, urlparse
from xml.sax.saxutils import escape
import xml.etree.ElementTree as ET

import requests

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.backends.backend_pdf import PdfPages

try:
    from docx import Document

    DRIVER_DOCX_AVAILABLE = True
except ModuleNotFoundError:
    DRIVER_DOCX_AVAILABLE = False

try:
    from rdkit import Chem
    from rdkit.Chem import AllChem

    RDKIT_AVAILABLE = True
except ModuleNotFoundError:
    Chem = None
    AllChem = None
    RDKIT_AVAILABLE = False

try:
    from rdkit.Chem import Draw
except ModuleNotFoundError:
    Draw = None

try:
    from rdkit.Chem import rdDetermineBonds
except ModuleNotFoundError:
    rdDetermineBonds = None

try:
    from rdkit.Geometry import Point3D
except ModuleNotFoundError:
    Point3D = None

from flask import Flask, Response, abort, after_this_request, jsonify, request, send_file
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename

from ase.io import read, write, Trajectory
from ase import Atoms, units
from ase.geometry import cellpar_to_cell
from ase.md.langevin import Langevin
from ase.md.verlet import VelocityVerlet
from ase.md.nvtberendsen import NVTBerendsen
from ase.md.velocitydistribution import MaxwellBoltzmannDistribution, Stationary, ZeroRotation

try:  # Optional engines that may not be present in older ASE releases.
    from ase.md.nose_hoover_chain import NoseHooverChainNVT, IsotropicMTKNPT, MTKNPT
except ModuleNotFoundError:  # pragma: no cover - depends on ASE version
    NoseHooverChainNVT = None
    IsotropicMTKNPT = None
    MTKNPT = None

try:
    from ase.md.bussi import Bussi
except ModuleNotFoundError:  # pragma: no cover
    Bussi = None

try:
    from ase.md.andersen import Andersen
except ModuleNotFoundError:  # pragma: no cover
    Andersen = None

try:
    from ase.md.nptberendsen import NPTBerendsen
except ModuleNotFoundError:  # pragma: no cover
    NPTBerendsen = None

try:
    from ase.md.langevinbaoab import LangevinBAOAB
except ModuleNotFoundError:  # pragma: no cover
    LangevinBAOAB = None

try:
    from ase.md.melchionna import MelchionnaNPT
except ModuleNotFoundError:  # pragma: no cover
    MelchionnaNPT = None

from py_iochem.RESTAPIManager import CollectionHandler

from . import __version__
from .styled_rmsd_report import generate_report, _write_basic_docx as _write_basic_report_docx
from .cml_export import UmaLogParser, convert_log
from .utils import extract_xyz_metadata
from .workflows import (
    build_output_paths,
    select_device,
    TorchUnavailable,
    setup_calculated_atoms,
    configure_logging,
    log_timing,
    log_total_timing,
)

MD_ENGINE_LABELS = {
    "langevin": "Langevin (NVT)",
    "velocity_verlet": "VelocityVerlet (NVE)",
    "nvt_berendsen": "NVT Berendsen",
    "nose_hoover_chain": "Nosé-Hoover Chain (NVT)",
    "bussi": "Bussi (NVT)",
    "andersen": "Andersen (NVT)",
    "langevin_baoab_nvt": "Langevin-Hoover BAOAB (NVT)",
    "npt_berendsen": "NPT Berendsen",
    "isotropic_mtk_npt": "Isotropic MTK (NPT)",
    "mtk_npt": "Full MTK (NPT)",
    "langevin_baoab_npt": "Langevin-Hoover BAOAB (NPT)",
    "melchionna_npt": "Melchionna NPT",
}

README_BASENAME = "README.md"
LICENSE_BASENAME = "LICENSE"
_README_CONTENT_CACHE: Optional[str] = None
_SEMAPHORE_WARNING_REGEX = (
    r"resource_tracker: There appear to be \d+ leaked semaphore objects to clean up at shutdown"
)
_SEMAPHORE_WARNING_ENV = "ignore:.*leaked semaphore objects to clean up at shutdown:UserWarning"


def _install_known_warning_filters() -> None:
    """Suppress a known external multiprocessing shutdown warning."""
    warnings.filterwarnings("ignore", message=_SEMAPHORE_WARNING_REGEX, category=UserWarning)
    existing = os.environ.get("PYTHONWARNINGS", "").strip()
    if _SEMAPHORE_WARNING_ENV not in existing.split(","):
        os.environ["PYTHONWARNINGS"] = (
            f"{existing},{_SEMAPHORE_WARNING_ENV}" if existing else _SEMAPHORE_WARNING_ENV
        )


_install_known_warning_filters()


def _find_local_readme() -> Optional[Path]:
    current = Path(__file__).resolve()
    for parent in current.parents:
        candidate = parent / README_BASENAME
        if candidate.is_file():
            return candidate
    return None


def _find_local_license() -> Optional[Path]:
    current = Path(__file__).resolve()
    for parent in current.parents:
        candidate = parent / LICENSE_BASENAME
        if candidate.is_file():
            return candidate
    return None


def _load_readme_from_metadata() -> Optional[str]:
    package_name = "uma-ase"
    try:
        metadata = importlib_metadata.metadata(package_name)
    except importlib_metadata.PackageNotFoundError:
        return None
    description = metadata.get("Description")
    if description:
        return description
    return None


def _read_readme_text() -> str:
    global _README_CONTENT_CACHE
    if _README_CONTENT_CACHE is not None:
        return _README_CONTENT_CACHE
    readme_path = _find_local_readme()
    if readme_path is not None:
        _README_CONTENT_CACHE = readme_path.read_text(encoding="utf-8", errors="replace")
        return _README_CONTENT_CACHE
    metadata_text = _load_readme_from_metadata()
    if metadata_text:
        _README_CONTENT_CACHE = metadata_text
        return _README_CONTENT_CACHE
    raise FileNotFoundError("README.md content is unavailable.")


def _parse_float_field(
    form,
    field: str,
    default: Optional[float],
    *,
    label: str,
    min_value: Optional[float] = None,
    min_inclusive: bool = True,
    max_value: Optional[float] = None,
    max_inclusive: bool = True,
    required: bool = False,
) -> Optional[float]:
    raw = form.get(field)
    if raw is None or raw.strip() == "":
        if required:
            raise ValueError(f"{label} is required.")
        return float(default) if default is not None else None
    try:
        value = float(raw)
    except (TypeError, ValueError):
        raise ValueError(f"{label} must be a number.") from None
    if min_value is not None:
        if value < min_value or (not min_inclusive and value == min_value):
            comparator = "greater than" if not min_inclusive else "at least"
            raise ValueError(f"{label} must be {comparator} {min_value}.")
    if max_value is not None:
        if value > max_value or (not max_inclusive and value == max_value):
            comparator = "less than" if not max_inclusive else "at most"
            raise ValueError(f"{label} must be {comparator} {max_value}.")
    return value


def _parse_int_field(
    form,
    field: str,
    default: Optional[int],
    *,
    label: str,
    min_value: Optional[int] = None,
    min_inclusive: bool = True,
    required: bool = False,
) -> Optional[int]:
    raw = form.get(field)
    if raw is None or raw.strip() == "":
        if required:
            raise ValueError(f"{label} is required.")
        return int(default) if default is not None else None
    try:
        value = int(raw)
    except (TypeError, ValueError):
        raise ValueError(f"{label} must be an integer.") from None
    if min_value is not None:
        if value < min_value or (not min_inclusive and value == min_value):
            comparator = "greater than" if not min_inclusive else "at least"
            raise ValueError(f"{label} must be {comparator} {min_value}.")
    return value


def _normalize_iochem_handle(raw_handle: str) -> str:
    if raw_handle is None:
        raise ValueError("Handle is required.")
    handle = str(raw_handle).strip()
    if not handle:
        raise ValueError("Handle is required.")
    parsed = urlparse(handle)
    path = parsed.path or ""
    if parsed.netloc and not path:
        path = "/"
    target = path or handle
    lower_target = target.lower()
    if "handle/" in lower_target:
        idx = lower_target.index("handle/") + len("handle/")
        target = target[idx:]
    target = target.strip().strip("/").split("?", 1)[0].split("#", 1)[0]
    if not target:
        raise ValueError("Handle is required.")
    return target


def _build_iochem_source_url(handle: str, raw_handle: Optional[str] = None) -> str:
    base = None
    path_prefix = ""
    if raw_handle:
        parsed = urlparse(str(raw_handle).strip())
        if parsed.scheme and parsed.netloc:
            base = f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
            raw_path = (parsed.path or "").lower()
            if "/browse/handle/" in raw_path:
                path_prefix = "/browse"
    if not base:
        base = "https://www.iochem-bd.org"
    return f"{base}{path_prefix}/handle/{handle}"


def _resolve_iochem_rest_url(raw_handle: str) -> str:
    configured = str(app.config.get("IOCHEM_REST_URL") or IOCHEM_DEFAULT_REST_URL)
    handle_str = str(raw_handle or "").strip()
    parsed = urlparse(handle_str)
    if parsed.scheme and parsed.netloc:
        base = f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
        if base.endswith("/rest"):
            return base
        return f"{base}/rest"
    return configured


def _normalize_iochem_create_rest_url(raw_base: Optional[str]) -> str:
    base_value = str(raw_base or app.config.get("IOCHEM_CREATE_BASE") or "").strip()
    if not base_value:
        raise ValueError("ioChem-BD Create base URL is required.")
    if not re.match(r"^https?://", base_value, re.IGNORECASE):
        base_value = f"https://{base_value}"
    parsed = urlparse(base_value)
    if not parsed.scheme or not parsed.netloc:
        raise ValueError("ioChem-BD Create base URL is invalid.")
    host = parsed.hostname
    if host and not _is_allowed_iochem_host(host):
        raise ValueError("ioChem-BD host is not allowed.")

    base_root = f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
    path = (parsed.path or "").rstrip("/")
    if path.endswith("/create/rest"):
        rest_path = path
    elif path.endswith("/rest"):
        rest_path = path if "/create" in path else "/create/rest"
    elif path.endswith("/create"):
        rest_path = f"{path}/rest"
    elif path:
        rest_path = f"{path}/create/rest"
    else:
        rest_path = "/create/rest"
    return f"{base_root}{rest_path}"


def _get_iochem_rest_candidates(initial_url: str, raw_handle: Optional[str] = None) -> List[str]:
    candidates: List[str] = []
    if initial_url:
        candidates.append(initial_url.rstrip("/"))
    raw_value = str(raw_handle or "").strip()
    parsed_handle = urlparse(raw_value)
    if parsed_handle.scheme and parsed_handle.netloc:
        return candidates
    raw_fallbacks = app.config.get("IOCHEM_REST_FALLBACKS", "") or ""
    if isinstance(raw_fallbacks, str):
        for value in raw_fallbacks.split(","):
            trimmed = value.strip()
            if trimmed:
                candidates.append(trimmed.rstrip("/"))
    unique: List[str] = []
    for url in candidates:
        if url not in unique:
            unique.append(url)
    return unique


def _parse_iochem_verify(value: Optional[Any]) -> bool:
    if value is None:
        return bool(app.config.get("IOCHEM_VERIFY", True))
    if isinstance(value, bool):
        return value
    return str(value).strip().lower() not in ("0", "false", "no", "off")


def _format_iochem_error(response: requests.Response, context: str) -> str:
    status = response.status_code
    message = f"ioChem-BD returned HTTP {status} for {context}."
    try:
        payload = response.json()
        if isinstance(payload, dict):
            details = payload.get("message") or payload.get("error") or payload.get("title")
            if details:
                return f"{message} {details}"
    except ValueError:
        payload = None
    text = (response.text or "").strip()
    if text:
        excerpt = text if len(text) <= 300 else f"{text[:300]}..."
        return f"{message} {excerpt}"
    return message


def _normalize_pdbe_id(value: str) -> str:
    text = str(value or "").strip()
    if not text:
        raise ValueError("PDB ID is required.")
    if ":" in text:
        text = text.split(":", 1)[-1].strip()
    if not re.match(r"^[A-Za-z0-9]{4}$", text):
        raise ValueError("PDB ID must be 4 alphanumeric characters.")
    return text.lower()


def _fetch_pdbe_entry(pdb_id: str) -> Tuple[str, str]:
    candidates = [
        (f"https://www.ebi.ac.uk/pdbe/entry-files/download/{pdb_id}.pdb", f"{pdb_id}.pdb"),
        (f"https://www.ebi.ac.uk/pdbe/entry-files/download/{pdb_id}.cif", f"{pdb_id}.cif"),
        (f"https://www.ebi.ac.uk/pdbe/entry-files/{pdb_id}.pdb", f"{pdb_id}.pdb"),
        (f"https://www.ebi.ac.uk/pdbe/entry-files/pdb{pdb_id}.ent", f"{pdb_id}.pdb"),
    ]
    headers = {"User-Agent": "uma-ase/1.0"}
    errors = []
    for url, filename in candidates:
        try:
            response = requests.get(url, timeout=30, headers=headers)
        except requests.RequestException as exc:
            errors.append(f"{url}: {exc}")
            continue
        if response.status_code >= 400:
            errors.append(f"{url}: HTTP {response.status_code}")
            continue
        text = response.text.strip()
        if not text:
            errors.append(f"{url}: empty response")
            continue
        return filename, response.text
    error_text = "; ".join(errors) if errors else "PDBe returned no data."
    raise ValueError(f"Unable to fetch PDBe entry {pdb_id}. {error_text}")


def _safe_int_from_string(value: Optional[str], default: Optional[int] = None) -> Optional[int]:
    if value is None:
        return default
    text = str(value).strip()
    if not text:
        return default
    try:
        return int(round(float(text)))
    except (TypeError, ValueError):
        return default


def _find_scalar_value(node: ET.Element, dict_refs: Iterable[str]) -> Optional[str]:
    if node is None:
        return None
    for dict_ref in dict_refs:
        scalar = node.find(f".//cml:scalar[@dictRef='{dict_ref}']", CML_NAMESPACE)
        if scalar is not None:
            text = scalar.text.strip() if scalar.text else ""
            if text:
                return text
    return None


def _cml_local_name(tag: str) -> str:
    return tag.split("}", 1)[-1] if "}" in tag else tag


def _normalize_cml_key(raw: Optional[str]) -> str:
    if raw is None:
        return ""
    return re.sub(r"[^a-z0-9]", "", str(raw).lower())


def _iter_cml_metadata_elements(node: ET.Element):
    if node is None:
        return
    stack = [node]
    blocked = {"atomarray", "bondarray"}
    while stack:
        current = stack.pop()
        yield current
        children = list(current)
        for child in reversed(children):
            if _normalize_cml_key(_cml_local_name(child.tag)) in blocked:
                continue
            stack.append(child)


def _extract_element_numeric_text(element: ET.Element) -> Optional[str]:
    if element is None:
        return None
    text = (element.text or "").strip()
    if _parse_float_list(text):
        return text
    for child in list(element):
        child_text = (child.text or "").strip()
        if _parse_float_list(child_text):
            return child_text
    return None


def _find_cml_metadata_value(node: ET.Element, aliases: Iterable[str]) -> Optional[str]:
    alias_set = {_normalize_cml_key(alias) for alias in aliases if alias}
    if not alias_set or node is None:
        return None

    def key_matches(raw_key: Optional[str]) -> bool:
        normalized = _normalize_cml_key(raw_key)
        if not normalized:
            return False
        return any(
            normalized == alias
            or normalized.endswith(alias)
            or alias in normalized
            for alias in alias_set
        )

    for element in _iter_cml_metadata_elements(node):
        if element is not node:
            tag_name = _normalize_cml_key(_cml_local_name(element.tag))
            if tag_name in {"atom", "bond"}:
                continue

        for raw_key, raw_val in element.attrib.items():
            if key_matches(raw_key):
                return raw_val

        descriptor_fields = (
            element.get("dictRef"),
            element.get("title"),
            element.get("id"),
            element.get("name"),
            element.get("builtin"),
            element.get("convention"),
        )
        if any(key_matches(field) for field in descriptor_fields):
            value = _extract_element_numeric_text(element)
            if value is not None:
                return value
    return None


def _find_cml_metadata_scalar_value(
    node: ET.Element,
    aliases: Iterable[str],
    *,
    prefer_last: bool = False,
) -> Optional[str]:
    alias_set = {_normalize_cml_key(alias) for alias in aliases if alias}
    if not alias_set or node is None:
        return None

    def key_matches(raw_key: Optional[str]) -> bool:
        normalized = _normalize_cml_key(raw_key)
        if not normalized:
            return False
        return any(
            normalized == alias
            or normalized.endswith(alias)
            or alias in normalized
            for alias in alias_set
        )

    matches: List[str] = []
    for element in _iter_cml_metadata_elements(node):
        tag_name = _normalize_cml_key(_cml_local_name(element.tag))
        if tag_name in {"atom", "bond", "array", "matrix"}:
            continue

        for raw_key, raw_val in element.attrib.items():
            if key_matches(raw_key):
                values = _parse_float_list(raw_val)
                if len(values) == 1:
                    matches.append(raw_val)

        descriptor_fields = (
            element.get("dictRef"),
            element.get("title"),
            element.get("id"),
            element.get("name"),
            element.get("builtin"),
            element.get("convention"),
        )
        if any(key_matches(field) for field in descriptor_fields):
            text = (element.text or "").strip()
            values = _parse_float_list(text)
            if len(values) == 1:
                matches.append(text)

    if not matches:
        return None
    return matches[-1] if prefer_last else matches[0]


def _fetch_iochem_handle_metadata(handle: str, rest_url: str, *, verify: bool = True) -> Dict[str, Any]:
    normalized_base = rest_url.rstrip("/")
    url = f"{normalized_base}/handle/{handle}"
    try:
        resp = requests.get(url, headers={"Accept": "application/json"}, timeout=30, verify=verify)
    except requests.exceptions.SSLError as exc:
        raise ValueError(
            "TLS verification failed when contacting ioChem-BD. "
            "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
        ) from exc
    except requests.RequestException as exc:
        raise ValueError("Unable to contact ioChem-BD REST endpoint.") from exc
    if resp.status_code >= 400:
        raise ValueError(f"ioChem-BD returned HTTP {resp.status_code} for handle lookup.")
    try:
        return resp.json()
    except ValueError as exc:
        raise ValueError("ioChem-BD returned an invalid response for the requested handle.") from exc


def _download_iochem_item_cml(rest_url: str, item_identifier: str, *, verify: bool = True) -> str:
    base_url = rest_url.rstrip("/")
    bitstreams_url = f"{base_url}/items/{item_identifier}/bitstreams"
    try:
        resp = requests.get(bitstreams_url, headers={"Accept": "application/json"}, timeout=30, verify=verify)
    except requests.exceptions.SSLError as exc:
        raise ValueError(
            "TLS verification failed when contacting ioChem-BD. "
            "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
        ) from exc
    except requests.RequestException as exc:
        raise ValueError("Unable to list files for the requested ioChem-BD calculation.") from exc
    if resp.status_code >= 400:
        raise ValueError(f"Unable to list bitstreams for the requested calculation (HTTP {resp.status_code}).")
    try:
        entries = resp.json()
    except ValueError as exc:
        raise ValueError("ioChem-BD returned invalid bitstream metadata.") from exc
    if not isinstance(entries, list) or not entries:
        raise ValueError("The ioChem-BD handle has no files attached.")

    def is_output(entry: Dict[str, Any]) -> bool:
        name = (entry.get("name") or "").strip().lower()
        if name == "output.cml":
            return True
        format_name = entry.get("format") or entry.get("formatDescription")
        if isinstance(format_name, dict):
            format_name = format_name.get("description") or format_name.get("shortDescription")
        format_text = (format_name or "").strip().lower()
        return "output.cml" in format_text

    def score_entry(entry: Dict[str, Any]) -> Tuple[int, int, str]:
        name = (entry.get("name") or "").strip().lower()
        format_name = entry.get("format") or entry.get("formatDescription")
        if isinstance(format_name, dict):
            format_name = format_name.get("description") or format_name.get("shortDescription")
        format_text = (format_name or "").strip().lower()
        return (
            0 if name == "output.cml" or "output.cml" in format_text else 1,
            0 if ".cml" in name or "cml" in format_text else 1,
            name or format_text or "",
        )

    last_invalid_error: Optional[ValueError] = None
    for output_entry in sorted(entries, key=score_entry):
        bitstream_id = output_entry.get("uuid") or output_entry.get("id")
        if not bitstream_id:
            continue
        retrieve_url = f"{base_url}/bitstreams/{bitstream_id}/retrieve"
        download_headers = {"Accept": "application/octet-stream"}
        try:
            resp = requests.get(retrieve_url, headers=download_headers, timeout=60, verify=verify)
        except requests.exceptions.SSLError as exc:
            raise ValueError(
                "TLS verification failed when contacting ioChem-BD. "
                "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
            ) from exc
        except requests.RequestException as exc:
            raise ValueError("Unable to download the ioChem-BD output file.") from exc
        if resp.status_code >= 400:
            continue
        text = resp.text.strip()
        if not text:
            continue
        try:
            _locate_cml_molecule(resp.text)
            return resp.text
        except ValueError as exc:
            last_invalid_error = exc
            continue

    if last_invalid_error is not None:
        raise last_invalid_error
    raise ValueError("Unable to determine a valid CML bitstream for the requested calculation.")


def _locate_cml_molecule_from_root(root: ET.Element, cml_text: Optional[str] = None) -> ET.Element:
    molecules = root.findall(".//cml:molecule", CML_NAMESPACE)
    target_molecule = None
    for molecule in molecules:
        atom_array = molecule.find("cml:atomArray", CML_NAMESPACE)
        if atom_array is None:
            continue
        atoms = atom_array.findall("cml:atom", CML_NAMESPACE)
        if atoms and all(atom.get("x3") is not None for atom in atoms):
            target_molecule = molecule
    if target_molecule is None and molecules:
        target_molecule = molecules[-1]
    if target_molecule is None:
        raise ValueError("Unable to locate a molecule entry in the ioChem-BD file.")
    return target_molecule


def _locate_cml_molecule(cml_text: str) -> ET.Element:
    try:
        root = ET.fromstring(cml_text.encode("utf-8"))
    except ET.ParseError as exc:
        snippet = " ".join((cml_text or "").strip().split())[:240]
        if snippet:
            raise ValueError(f"Downloaded CML content is invalid. Payload starts with: {snippet}") from exc
        raise ValueError("Downloaded CML content is invalid.") from exc
    return _locate_cml_molecule_from_root(root, cml_text)


def _extract_charge_spin_from_cml(cml_text: str) -> Tuple[int, int]:
    root = ET.fromstring(cml_text.encode("utf-8"))
    target_molecule = _locate_cml_molecule_from_root(root, cml_text)
    parent_map = {child: parent for parent in root.iter() for child in parent}

    metadata_scope = target_molecule
    ancestor = target_molecule
    while ancestor is not None:
        parent = parent_map.get(ancestor)
        if parent is None:
            break
        if _normalize_cml_key(_cml_local_name(parent.tag)) == "module":
            metadata_scope = parent
        ancestor = parent

    charge = _safe_int_from_string(target_molecule.get("formalCharge"), default=None)
    if charge is None:
        scalar_charge = _find_cml_metadata_scalar_value(
            metadata_scope,
            (
                "formalCharge",
                "molecularCharge",
                "totalCharge",
                "netCharge",
                "charge",
                "x:formalCharge",
                "g:charge",
                "a:charge",
                "cc:charge",
            ),
            prefer_last=True,
        )
        charge = _safe_int_from_string(scalar_charge, default=0)
    spin = _safe_int_from_string(target_molecule.get("spinMultiplicity"), default=None)
    if spin is None or spin <= 0:
        scalar_spin = _find_cml_metadata_scalar_value(
            metadata_scope,
            (
                "spinMultiplicity",
                "multiplicity",
                "spin",
                "spinState",
                "totalSpinMultiplicity",
                "x:multiplicity",
                "g:multiplicity",
                "a:multiplicity",
                "a:spin",
            ),
            prefer_last=True,
        )
        spin = _safe_int_from_string(scalar_spin, default=1) or 1
    if spin is None or spin <= 0:
        spin_polarization = _find_cml_metadata_scalar_value(
            metadata_scope,
            (
                "spinPolarization",
                "spinpolarization",
                "a:spinPolarization",
            ),
            prefer_last=True,
        )
        spin_pol_value = _safe_int_from_string(spin_polarization, default=None)
        if spin_pol_value is not None:
            spin = abs(spin_pol_value) + 1
    if spin <= 0:
        spin = 1
    return charge or 0, spin


def _parse_float_list(raw_text: Optional[str]) -> List[float]:
    if not raw_text:
        return []
    values: List[float] = []
    for token in re.findall(r"[-+]?\d*\.?\d+(?:[Ee][-+]?\d+)?", raw_text):
        with suppress(ValueError):
            values.append(float(token))
    return values


def _extract_cml_cell(target_molecule: ET.Element, cml_text: str) -> Optional[List[float]]:
    try:
        root = ET.fromstring(cml_text.encode("utf-8"))
    except ET.ParseError:
        return None

    def local_name(tag: str) -> str:
        return tag.split("}", 1)[-1] if "}" in tag else tag

    def iter_local(element: ET.Element, name: str) -> List[ET.Element]:
        return [child for child in element.iter() if local_name(child.tag) == name]

    parent_map = {child: parent for parent in root.iter() for child in parent}

    def parse_lattice_vectors(element: ET.Element) -> Optional[List[float]]:
        vectors: List[List[float]] = []
        for vector in iter_local(element, "latticeVector"):
            values = _parse_float_list(vector.text)
            if len(values) >= 3:
                vectors.append(values[:3])
        if len(vectors) == 3:
            flat = [value for vec in vectors for value in vec]
            if len(flat) == 9:
                return flat
        return None

    def parse_cell_parameters(element: ET.Element) -> Optional[List[float]]:
        for cell_param in iter_local(element, "cellParameter"):
            values = _parse_float_list(cell_param.text)
            if len(values) >= 6:
                return values[:6]
        for array in iter_local(element, "array"):
            key = array.get("dictRef") or array.get("title") or array.get("id") or ""
            key_norm = re.sub(r"[^a-z]", "", key.lower())
            if "cell" not in key_norm and "lattice" not in key_norm:
                continue
            values = _parse_float_list(array.text)
            if len(values) >= 9:
                return values[:9]
            if len(values) >= 6:
                return values[:6]
        return None

    def parse_matrix(element: ET.Element) -> Optional[List[float]]:
        for matrix in iter_local(element, "matrix"):
            values = _parse_float_list(matrix.text)
            if len(values) >= 9:
                return values[:9]
        return None

    def parse_scalars(element: ET.Element) -> Optional[List[float]]:
        params: Dict[str, float] = {}
        for scalar in iter_local(element, "scalar"):
            key = scalar.get("dictRef") or scalar.get("title") or scalar.get("id") or ""
            key_norm = re.sub(r"[^a-z]", "", key.lower())
            values = _parse_float_list(scalar.text)
            if not values:
                continue
            value = values[0]
            if key_norm in ("a", "cella", "celllengtha", "lengtha", "latticea"):
                params["a"] = value
            elif key_norm in ("b", "cellb", "celllengthb", "lengthb", "latticeb"):
                params["b"] = value
            elif key_norm in ("c", "cellc", "celllengthc", "lengthc", "latticec"):
                params["c"] = value
            elif key_norm in ("alpha", "cellalpha", "cellanglealpha", "anglealpha", "latticealpha"):
                params["alpha"] = value
            elif key_norm in ("beta", "cellbeta", "cellanglebeta", "anglebeta", "latticebeta"):
                params["beta"] = value
            elif key_norm in ("gamma", "cellgamma", "cellanglegamma", "anglegamma", "latticegamma"):
                params["gamma"] = value

        if all(key in params for key in ("a", "b", "c", "alpha", "beta", "gamma")):
            return [params["a"], params["b"], params["c"], params["alpha"], params["beta"], params["gamma"]]
        return None

    def parse_attributes(element: ET.Element) -> Optional[List[float]]:
        params: Dict[str, float] = {}
        for raw_key, raw_val in element.attrib.items():
            key_norm = re.sub(r"[^a-z]", "", local_name(raw_key).lower())
            values = _parse_float_list(raw_val)
            if not values:
                continue
            value = values[0]
            if key_norm in ("a", "cella", "celllengtha", "lengtha", "latticea"):
                params["a"] = value
            elif key_norm in ("b", "cellb", "celllengthb", "lengthb", "latticeb"):
                params["b"] = value
            elif key_norm in ("c", "cellc", "celllengthc", "lengthc", "latticec"):
                params["c"] = value
            elif key_norm in ("alpha", "cellalpha", "cellanglealpha", "anglealpha", "latticealpha"):
                params["alpha"] = value
            elif key_norm in ("beta", "cellbeta", "cellanglebeta", "anglebeta", "latticebeta"):
                params["beta"] = value
            elif key_norm in ("gamma", "cellgamma", "cellanglegamma", "anglegamma", "latticegamma"):
                params["gamma"] = value
        if all(key in params for key in ("a", "b", "c", "alpha", "beta", "gamma")):
            return [params["a"], params["b"], params["c"], params["alpha"], params["beta"], params["gamma"]]
        for raw_key, raw_val in element.attrib.items():
            key_norm = re.sub(r"[^a-z]", "", local_name(raw_key).lower())
            if "lattice" in key_norm or "cell" in key_norm:
                values = _parse_float_list(raw_val)
                if len(values) >= 9:
                    return values[:9]
                if len(values) >= 6:
                    return values[:6]
        return None

    candidates: List[ET.Element] = [target_molecule]

    crystal_candidates: List[ET.Element] = []
    crystal_candidates.extend(iter_local(target_molecule, "crystal"))

    ancestor = target_molecule
    while ancestor is not None:
        parent = parent_map.get(ancestor)
        if parent is None:
            break
        for child in list(parent):
            if local_name(child.tag) == "crystal":
                crystal_candidates.append(child)
        ancestor = parent

    crystals = iter_local(root, "crystal")
    if len(crystals) == 1:
        crystal_candidates.append(crystals[0])

    candidates.extend(crystal_candidates)

    seen_ids: set[int] = set()
    ordered_candidates: List[ET.Element] = []
    for element in candidates:
        marker = id(element)
        if marker in seen_ids:
            continue
        seen_ids.add(marker)
        ordered_candidates.append(element)

    for element in ordered_candidates:
        if element is None:
            continue
        cell = parse_attributes(element)
        if cell:
            return cell
        if local_name(element.tag) != "crystal":
            continue
        cell = parse_lattice_vectors(element)
        if cell:
            return cell
        cell = parse_cell_parameters(element)
        if cell:
            return cell
        cell = parse_matrix(element)
        if cell:
            return cell
        cell = parse_scalars(element)
        if cell:
            return cell

    return None


def _atoms_from_cml_text(cml_text: str):
    target_molecule = _locate_cml_molecule(cml_text)
    atom_array = target_molecule.find("cml:atomArray", CML_NAMESPACE)
    if atom_array is None:
        raise ValueError("The ioChem-BD output lacks a valid atomic coordinate array.")
    symbols = []
    positions = []
    for atom in atom_array.findall("cml:atom", CML_NAMESPACE):
        symbol = atom.get("elementType")
        x3 = atom.get("x3")
        y3 = atom.get("y3")
        z3 = atom.get("z3")
        if not symbol or x3 is None or y3 is None or z3 is None:
            continue
        try:
            positions.append([float(x3), float(y3), float(z3)])
            symbols.append(symbol)
        except ValueError:
            continue
    if not symbols or len(symbols) != len(positions):
        raise ValueError("Unable to extract atomic coordinates from ioChem-BD output.")
    atoms = Atoms(symbols=symbols, positions=positions)
    cell_data = _extract_cml_cell(target_molecule, cml_text)
    if cell_data:
        if len(cell_data) == 6:
            atoms.set_cell(cellpar_to_cell(cell_data))
        elif len(cell_data) == 9:
            atoms.set_cell([cell_data[0:3], cell_data[3:6], cell_data[6:9]])
        atoms.set_pbc(True)
    return atoms


def _atoms_to_xyz(atoms, source_url: str, charge: int, spin: int) -> str:
    symbols = atoms.get_chemical_symbols()
    positions = atoms.get_positions()
    if len(symbols) != len(positions):
        raise ValueError("Geometry is inconsistent.")
    comment_parts: List[str] = [f"charge={charge}", f"spin={spin}"]
    if source_url:
        comment_parts.append(f'source="{source_url}"')
    comment = " ".join(comment_parts) or "ioChem-BD geometry"
    lines = [str(len(symbols)), comment]
    for symbol, (x, y, z) in zip(symbols, positions):
        lines.append(f"{symbol:<3} {x: .10f} {y: .10f} {z: .10f}")
    return "\n".join(lines) + "\n"


def _atoms_to_cif_text(atoms: Atoms) -> str:
    with tempfile.NamedTemporaryFile(suffix=".cif", delete=False) as handle:
        temp_path = Path(handle.name)
    try:
        write(str(temp_path), atoms, format="cif")
        return temp_path.read_text(encoding="utf-8")
    finally:
        with suppress(OSError):
            temp_path.unlink()


def _extract_cell_parameters_from_atoms(atoms: Atoms) -> Optional[List[float]]:
    if hasattr(atoms, "pbc") and atoms.pbc is not None and atoms.pbc.any():
        try:
            return list(atoms.get_cell().cellpar())
        except Exception:
            return None
    return None


def _extract_pbc_flags_from_atoms(atoms: Atoms) -> Optional[List[bool]]:
    if hasattr(atoms, "pbc") and atoms.pbc is not None:
        try:
            return atoms.pbc.tolist()
        except Exception:
            return None
    return None


def _lattice_comment_from_atoms(atoms: Atoms) -> Optional[str]:
    if not hasattr(atoms, "pbc") or atoms.pbc is None or not atoms.pbc.any():
        return None
    try:
        cell = atoms.get_cell()
        matrix = cell.array if hasattr(cell, "array") else cell
        values = []
        for vector in matrix:
            values.extend(float(component) for component in vector)
        pbc_tokens = ["T" if bool(flag) else "F" for flag in atoms.pbc.tolist()]
    except Exception:
        return None
    lattice = " ".join(f"{value:.10f}" for value in values)
    return f'Lattice="{lattice}" pbc="{" ".join(pbc_tokens)}"'


def _viewer_geometry_from_payload(filename: str, payload: bytes) -> Tuple[str, str, Optional[List[float]], Optional[List[bool]]]:
    safe_name = secure_filename(filename) or "geometry.xyz"
    suffix = Path(safe_name).suffix.lower()

    if _is_cml_filename_or_content(safe_name, payload):
        cml_text = payload.decode("utf-8", errors="replace")
        atoms = _atoms_from_cml_text(cml_text)
        cell_parameters = _extract_cell_parameters_from_atoms(atoms)
        pbc_flags = _extract_pbc_flags_from_atoms(atoms)
        if cell_parameters:
            stem = Path(safe_name).stem or "geometry"
            return f"{stem}.cif", _atoms_to_cif_text(atoms), cell_parameters, pbc_flags
        stem = Path(safe_name).stem or "geometry"
        return f"{stem}.cml", cml_text, cell_parameters, pbc_flags

    if suffix in {".cif", ".xyz", ".pdb", ".mol", ".mol2", ".sdf"}:
        try:
            text = payload.decode("utf-8", errors="replace")
        except Exception:
            text = ""
        if text:
            atoms = None
            with suppress(Exception):
                atoms = read(io.StringIO(text), format=suffix.lstrip(".") if suffix != ".sdf" else "mol")
            if atoms is not None:
                return safe_name, text, _extract_cell_parameters_from_atoms(atoms), _extract_pbc_flags_from_atoms(atoms)
            return safe_name, text, None, None

    return safe_name, payload.decode("utf-8", errors="replace"), None, None


def _is_cml_filename_or_content(filename: str, payload: bytes) -> bool:
    suffix = Path(filename).suffix.lower()
    if suffix == ".cml":
        return True
    prefix = payload[:512].decode("utf-8", errors="ignore").lower()
    return "<cml" in prefix or "<molecule" in prefix


def _save_geometry_upload_for_ase(
    storage,
    target_dir: Path,
    *,
    charge: int = 0,
    spin: int = 1,
    prefer_periodic_format: bool = False,
) -> Tuple[Path, str]:
    filename = secure_filename(storage.filename) or "input.xyz"
    payload = storage.read()
    if not payload:
        raise ValueError("Uploaded geometry file is empty.")
    if _is_cml_filename_or_content(filename, payload):
        cml_text = payload.decode("utf-8", errors="replace")
        atoms = _atoms_from_cml_text(cml_text)
        stem = Path(filename).stem or "input"
        if prefer_periodic_format and hasattr(atoms, "pbc") and atoms.pbc is not None and atoms.pbc.any():
            converted_name = f"{stem}.cif"
            input_path = target_dir / converted_name
            write(str(input_path), atoms, format="cif")
        else:
            converted_name = f"{stem}.xyz"
            input_path = target_dir / converted_name
            write(str(input_path), atoms, format="xyz", comment=f"charge={charge} spin={spin}")
        return input_path, converted_name

    input_path = target_dir / filename
    input_path.write_bytes(payload)
    return input_path, filename


def _normalize_xyz_comment(comment: Optional[str]) -> str:
    if not comment:
        return ""
    cleaned = re.sub(r"\s+", " ", str(comment)).strip()
    return cleaned[:180]


def _xyz_with_charge_spin(
    atom_lines: List[str],
    comment: Optional[str] = None,
    charge: int = 0,
    spin: int = 1,
) -> str:
    comment_parts = [str(charge), str(spin)]
    cleaned_comment = _normalize_xyz_comment(comment)
    if cleaned_comment:
        comment_parts.append(cleaned_comment)
    header = " ".join(comment_parts)
    return "\n".join([str(len(atom_lines)), header, *atom_lines]) + "\n"


def _normalize_xyz_text(xyz_text: str, comment: Optional[str], *, charge: int = 0, spin: int = 1) -> str:
    lines = xyz_text.splitlines()
    if len(lines) < 3:
        raise ValueError("The XYZ output from the external service is incomplete.")
    try:
        atom_count = int(lines[0].strip())
    except ValueError as exc:
        raise ValueError("Unable to determine the number of atoms from the XYZ output.") from exc
    atom_lines = lines[2 : 2 + atom_count]
    if len(atom_lines) < atom_count:
        raise ValueError("The XYZ output from the external service is incomplete.")
    return _xyz_with_charge_spin(atom_lines, comment=comment, charge=charge, spin=spin)


def _sdf_to_xyz(sdf_text: str, comment: Optional[str], *, charge: int = 0, spin: int = 1) -> str:
    lines = sdf_text.splitlines()
    counts_idx = None
    for idx, line in enumerate(lines[:40]):
        if "V2000" in line or "V3000" in line:
            counts_idx = idx
            break
    if counts_idx is None or counts_idx + 1 >= len(lines):
        raise ValueError("Unable to locate the atom block in the SDF output.")
    counts_line = lines[counts_idx]
    if "V3000" in counts_line:
        atom_lines = []
        atom_block_started = False
        for line in lines[counts_idx + 1 :]:
            if "BEGIN ATOM" in line:
                atom_block_started = True
                continue
            if "END ATOM" in line:
                break
            if not atom_block_started:
                continue
            tokens = line.strip().split()
            if len(tokens) < 7 or tokens[0] != "M" or tokens[1] != "V30":
                continue
            symbol = tokens[3]
            try:
                x_val = float(tokens[4])
                y_val = float(tokens[5])
                z_val = float(tokens[6])
            except ValueError as exc:
                raise ValueError("Invalid coordinate value in the SDF V3000 output.") from exc
            atom_lines.append(f"{symbol:<3} {x_val: .10f} {y_val: .10f} {z_val: .10f}")
        if not atom_lines:
            raise ValueError("Unable to parse atoms from the SDF V3000 output.")
        return _xyz_with_charge_spin(atom_lines, comment=comment, charge=charge, spin=spin)
    try:
        atom_count = int(counts_line[0:3])
    except ValueError as exc:
        parts = counts_line.split()
        if not parts:
            raise ValueError("Unable to parse the SDF atom count.") from exc
        atom_count = int(parts[0])
    atom_start = counts_idx + 1
    atom_lines = lines[atom_start : atom_start + atom_count]
    if len(atom_lines) < atom_count:
        raise ValueError("The SDF output is missing atom coordinates.")
    xyz_lines = []
    for line in atom_lines:
        if len(line) >= 34:
            x_raw = line[0:10]
            y_raw = line[10:20]
            z_raw = line[20:30]
            symbol = line[31:34].strip()
        else:
            parts = line.split()
            if len(parts) < 4:
                raise ValueError("Unable to parse a coordinate line from the SDF output.")
            x_raw, y_raw, z_raw, symbol = parts[0:4]
        try:
            x_val = float(x_raw)
            y_val = float(y_raw)
            z_val = float(z_raw)
        except ValueError as exc:
            raise ValueError("Invalid coordinate value in the SDF output.") from exc
        if not symbol:
            raise ValueError("Missing element symbol in the SDF output.")
        xyz_lines.append(f"{symbol:<3} {x_val: .10f} {y_val: .10f} {z_val: .10f}")
    return _xyz_with_charge_spin(xyz_lines, comment=comment, charge=charge, spin=spin)


def _fetch_pubchem_xyz(namespace: str, identifier: str) -> Optional[str]:
    url = f"{PUBCHEM_BASE_URL}/{namespace}/{quote(identifier, safe='')}/XYZ"
    attempt_urls = (f"{url}?record_type=3d", f"{url}?record_type=2d", url)
    last_error = None
    for target in attempt_urls:
        try:
            response = requests.get(target, timeout=60, headers={"User-Agent": "uma-ase/1.0"})
        except requests.RequestException as exc:
            raise ValueError(f"Unable to contact PubChem ({exc}).") from exc
        if response.status_code in (400, 404):
            last_error = f"PubChem returned HTTP {response.status_code}."
            continue
        if response.status_code >= 400:
            raise ValueError(f"PubChem responded with HTTP {response.status_code}.")
        text = response.text.strip()
        if text:
            return text
    return None


def _fetch_pubchem_sdf(namespace: str, identifier: str) -> Optional[str]:
    url = f"{PUBCHEM_BASE_URL}/{namespace}/{quote(identifier, safe='')}/SDF"
    attempt_urls = (f"{url}?record_type=3d", f"{url}?record_type=2d", url)
    last_error = None
    for target in attempt_urls:
        try:
            response = requests.get(target, timeout=60, headers={"User-Agent": "uma-ase/1.0"})
        except requests.RequestException as exc:
            raise ValueError(f"Unable to contact PubChem ({exc}).") from exc
        if response.status_code in (400, 404):
            last_error = f"PubChem returned HTTP {response.status_code}."
            continue
        if response.status_code >= 400:
            raise ValueError(f"PubChem responded with HTTP {response.status_code}.")
        text = response.text.strip()
        if not text:
            last_error = "PubChem returned an empty SDF payload."
            continue
        return text

    if namespace in ("smiles", "inchi"):
        for record_type in ("3d", "2d", None):
            params = {"record_type": record_type} if record_type else None
            try:
                response = requests.post(
                    f"{PUBCHEM_BASE_URL}/{namespace}/SDF",
                    data={namespace: identifier},
                    params=params,
                    timeout=60,
                    headers={"User-Agent": "uma-ase/1.0"},
                )
            except requests.RequestException as exc:
                raise ValueError(f"Unable to contact PubChem ({exc}).") from exc
            if response.status_code in (400, 404):
                last_error = f"PubChem returned HTTP {response.status_code}."
                continue
            if response.status_code >= 400:
                raise ValueError(f"PubChem responded with HTTP {response.status_code}.")
            text = response.text.strip()
            if not text:
                last_error = "PubChem returned an empty SDF payload."
                continue
            return text

    return None


def _fetch_pubchem_smiles(cid: str) -> str:
    url = f"{PUBCHEM_BASE_URL}/cid/{quote(cid, safe='')}/property/IsomericSMILES,CanonicalSMILES/JSON"
    try:
        response = requests.get(url, timeout=30, headers={"User-Agent": "uma-ase/1.0"})
    except requests.RequestException as exc:
        raise ValueError(f"Unable to contact PubChem for SMILES ({exc}).") from exc
    if response.status_code >= 400:
        raise ValueError(f"PubChem responded with HTTP {response.status_code} while requesting SMILES.")
    try:
        payload = response.json()
    except ValueError as exc:
        raise ValueError("PubChem returned invalid JSON for the SMILES request.") from exc
    props = payload.get("PropertyTable", {}).get("Properties", [])
    if not props:
        raise ValueError("PubChem returned no SMILES properties for this CID.")
    smiles = props[0].get("IsomericSMILES") or props[0].get("CanonicalSMILES")
    if not smiles:
        raise ValueError("PubChem did not provide a SMILES string for this CID.")
    return smiles


def _fetch_pubchem_cids_for_name(name: str) -> List[str]:
    url = f"{PUBCHEM_BASE_URL}/name/{quote(name, safe='')}/cids/JSON"
    try:
        response = requests.get(url, timeout=30, headers={"User-Agent": "uma-ase/1.0"})
    except requests.RequestException as exc:
        raise ValueError(f"Unable to contact PubChem for name lookup ({exc}).") from exc
    if response.status_code in (400, 404):
        return []
    if response.status_code >= 400:
        raise ValueError(f"PubChem responded with HTTP {response.status_code} while resolving the name.")
    try:
        payload = response.json()
    except ValueError as exc:
        raise ValueError("PubChem returned invalid JSON for the name lookup.") from exc
    return [str(cid) for cid in payload.get("IdentifierList", {}).get("CID", []) if cid is not None]


def _fetch_pubchem_cids_for_smiles(smiles: str) -> List[str]:
    url = f"{PUBCHEM_BASE_URL}/smiles/{quote(smiles, safe='')}/cids/JSON"
    try:
        response = requests.get(url, timeout=30, headers={"User-Agent": "uma-ase/1.0"})
    except requests.RequestException as exc:
        raise ValueError(f"Unable to contact PubChem for SMILES lookup ({exc}).") from exc
    if response.status_code in (400, 404):
        return []
    if response.status_code >= 400:
        raise ValueError(f"PubChem responded with HTTP {response.status_code} while resolving the SMILES.")
    try:
        payload = response.json()
    except ValueError as exc:
        raise ValueError("PubChem returned invalid JSON for the SMILES lookup.") from exc
    return [str(cid) for cid in payload.get("IdentifierList", {}).get("CID", []) if cid is not None]


def _fetch_pubchem_iupac_name(cid: str) -> Optional[str]:
    url = f"{PUBCHEM_BASE_URL}/cid/{quote(cid, safe='')}/property/IUPACName/JSON"
    try:
        response = requests.get(url, timeout=30, headers={"User-Agent": "uma-ase/1.0"})
    except requests.RequestException as exc:
        raise ValueError(f"Unable to contact PubChem for IUPAC name ({exc}).") from exc
    if response.status_code in (400, 404):
        return None
    if response.status_code >= 400:
        raise ValueError(f"PubChem responded with HTTP {response.status_code} while requesting IUPAC name.")
    try:
        payload = response.json()
    except ValueError as exc:
        raise ValueError("PubChem returned invalid JSON for the IUPAC name request.") from exc
    props = payload.get("PropertyTable", {}).get("Properties", [])
    if not props:
        return None
    name = props[0].get("IUPACName")
    return str(name).strip() if name else None


def _fetch_pubchem_iupac_from_smiles(smiles: str) -> Optional[str]:
    url = f"{PUBCHEM_BASE_URL}/smiles/{quote(smiles, safe='')}/property/IUPACName/JSON"
    try:
        response = requests.get(url, timeout=30, headers={"User-Agent": "uma-ase/1.0"})
    except requests.RequestException as exc:
        raise ValueError(f"Unable to contact PubChem for IUPAC name ({exc}).") from exc
    if response.status_code in (400, 404):
        return None
    if response.status_code >= 400:
        raise ValueError(f"PubChem responded with HTTP {response.status_code} while requesting IUPAC name.")
    try:
        payload = response.json()
    except ValueError as exc:
        raise ValueError("PubChem returned invalid JSON for the IUPAC name request.") from exc
    props = payload.get("PropertyTable", {}).get("Properties", [])
    if not props:
        return None
    name = props[0].get("IUPACName")
    return str(name).strip() if name else None


def _fetch_pubchem_2d_png(cid: str) -> Optional[str]:
    url = f"{PUBCHEM_BASE_URL}/cid/{quote(cid, safe='')}/PNG"
    try:
        response = requests.get(
            url,
            params={"image_size": "300x300"},
            timeout=30,
            headers={"User-Agent": "uma-ase/1.0", "Accept": "image/png"},
        )
    except requests.RequestException as exc:
        raise ValueError(f"Unable to contact PubChem for the 2D structure ({exc}).") from exc
    if response.status_code in (400, 404):
        return None
    if response.status_code >= 400:
        raise ValueError(f"PubChem responded with HTTP {response.status_code} while requesting the 2D structure.")
    if not response.content:
        return None
    encoded = base64.b64encode(response.content).decode("ascii")
    return f"data:image/png;base64,{encoded}"


def _extract_pubchem_smiles_from_record(record: Dict[str, Any]) -> Optional[str]:
    if not record:
        return None
    compounds = record.get("PC_Compounds") or []
    if not compounds:
        return None
    preferred = None
    fallback = None
    for compound in compounds:
        for prop in compound.get("props", []) or []:
            urn = prop.get("urn") or {}
            if urn.get("label") != "SMILES":
                continue
            value = prop.get("value") or {}
            sval = value.get("sval")
            if not sval:
                continue
            name = urn.get("name") or ""
            if name.lower() == "isomeric":
                return sval
            if name.lower() == "canonical" and preferred is None:
                preferred = sval
            if fallback is None:
                fallback = sval
    return preferred or fallback


def _fetch_pubchem_record_smiles(cid: str) -> Optional[str]:
    base_url = f"{PUBCHEM_BASE_URL}/cid/{quote(cid, safe='')}/record/JSON"
    for record_type in ("3d", "2d", None):
        params = {"record_type": record_type} if record_type else None
        try:
            response = requests.get(base_url, params=params, timeout=30, headers={"User-Agent": "uma-ase/1.0"})
        except requests.RequestException as exc:
            raise ValueError(f"Unable to contact PubChem for record JSON ({exc}).") from exc
        if response.status_code in (400, 404):
            continue
        if response.status_code >= 400:
            raise ValueError(f"PubChem responded with HTTP {response.status_code} while requesting record JSON.")
        try:
            payload = response.json()
        except ValueError as exc:
            raise ValueError("PubChem returned invalid JSON for the record request.") from exc
        smiles = _extract_pubchem_smiles_from_record(payload)
        if smiles:
            return smiles
    return None


def _fetch_pubchem_conformers_sdf(cid: str) -> Optional[str]:
    cid_value = quote(cid, safe="")
    attempt_urls = [
        f"{PUBCHEM_BASE_URL}/cid/{cid_value}/conformers/SDF",
        f"{PUBCHEM_PUG_ROOT}/conformer/cid/{cid_value}/SDF",
        f"{PUBCHEM_PUG_ROOT}/conformer/cid/{cid_value}/record/SDF",
        f"{PUBCHEM_BASE_URL}/cid/{cid_value}/record/SDF?record_type=3d&conformer_type=3d",
        f"{PUBCHEM_BASE_URL}/cid/{cid_value}/record/SDF?record_type=3d",
        f"{PUBCHEM_BASE_URL}/cid/{cid_value}/SDF?record_type=3d",
    ]
    for target in attempt_urls:
        try:
            response = requests.get(target, timeout=60, headers={"User-Agent": "uma-ase/1.0"})
        except requests.RequestException as exc:
            raise ValueError(f"Unable to contact PubChem for conformers ({exc}).") from exc
        if _pubchem_debug_enabled():
            app.logger.warning(
                "PubChem conformer fetch CID %s: %s -> HTTP %s",
                cid,
                target,
                response.status_code,
            )
        if response.status_code in (400, 404):
            continue
        if response.status_code >= 400:
            raise ValueError(f"PubChem responded with HTTP {response.status_code} while requesting conformers.")
        text = response.text.strip()
        if _pubchem_debug_enabled():
            app.logger.warning("PubChem conformer fetch CID %s: %s bytes", cid, len(text.encode("utf-8")))
        if text:
            return text
    return None


def _pubchem_debug_enabled() -> bool:
    return str(os.environ.get("UMA_ASE_PUBCHEM_DEBUG", "")).strip().lower() in {"1", "true", "yes", "on"}


def _get_max_conformers() -> int:
    try:
        value = int(app.config.get("UMA_MAX_CONFORMERS", 2000))
    except (TypeError, ValueError):
        value = 2000
    return max(1, value)


def _get_rdkit_max_bond_atoms() -> int:
    try:
        value = int(app.config.get("UMA_RDKIT_MAX_BOND_ATOMS", 200))
    except (TypeError, ValueError):
        value = 200
    return max(1, value)


def _get_rdkit_max_2d_atoms() -> int:
    try:
        value = int(app.config.get("UMA_RDKIT_MAX_2D_ATOMS", 250))
    except (TypeError, ValueError):
        value = 250
    return max(1, value)


def _split_sdf_records(sdf_text: str) -> List[str]:
    records = []
    for chunk in sdf_text.split("$$$$"):
        record = chunk.strip()
        if record:
            records.append(record)
    return records


def _extract_pubchem_energy_from_sdf(record: str) -> Optional[float]:
    energy_keys = {
        "PUBCHEM_MMFF94_ENERGY",
        "PUBCHEM_UFF_ENERGY",
        "MMFF94_ENERGY",
        "UFF_ENERGY",
        "ENERGY",
    }
    lines = record.splitlines()
    for idx, line in enumerate(lines):
        stripped = line.strip()
        if not stripped.startswith(">"):
            continue
        key = stripped.lstrip(">").strip()
        if key.startswith("<") and key.endswith(">"):
            key = key[1:-1].strip()
        if key not in energy_keys:
            continue
        for value in lines[idx + 1 :]:
            if value.strip() == "":
                break
            try:
                return float(value.strip())
            except ValueError:
                break
    return None


def _extract_sdf_property(record: str, keys: Tuple[str, ...]) -> Optional[str]:
    lines = record.splitlines()
    for idx, line in enumerate(lines):
        stripped = line.strip()
        if not stripped.startswith(">"):
            continue
        key = stripped.lstrip(">").strip()
        if key.startswith("<") and key.endswith(">"):
            key = key[1:-1].strip()
        if key not in keys:
            continue
        for value in lines[idx + 1 :]:
            if value.strip() == "":
                break
            return value.strip()
    return None


def _extract_pubchem_smiles_from_sdf(sdf_text: str) -> Optional[str]:
    if not sdf_text:
        return None
    keys = (
        "PUBCHEM_OPENEYE_CAN_SMILES",
        "PUBCHEM_CANONICAL_SMILES",
        "PUBCHEM_ISOMERIC_SMILES",
        "CanonicalSMILES",
        "IsomericSMILES",
        "SMILES",
    )
    for record in _split_sdf_records(sdf_text):
        value = _extract_sdf_property(record, keys)
        if value:
            return value
    return None


def _extract_pubchem_energy(mol: "Chem.Mol") -> Optional[float]:
    if mol is None:
        return None
    energy_keys = (
        "PUBCHEM_MMFF94_ENERGY",
        "PUBCHEM_UFF_ENERGY",
        "MMFF94_ENERGY",
        "UFF_ENERGY",
        "ENERGY",
    )
    for key in energy_keys:
        if mol.HasProp(key):
            raw = mol.GetProp(key)
            try:
                return float(raw)
            except (TypeError, ValueError):
                continue
    return None


def _mol_to_xyz_block(mol: "Chem.Mol", conf: "Chem.Conformer", comment: str) -> str:
    atom_lines = []
    for atom in mol.GetAtoms():
        pos = conf.GetAtomPosition(atom.GetIdx())
        atom_lines.append(f"{atom.GetSymbol():<3} {pos.x: .10f} {pos.y: .10f} {pos.z: .10f}")
    return _xyz_with_charge_spin(atom_lines, comment=comment, charge=0, spin=1)


def _generate_rdkit_conformers(
    query: str,
    comment: str,
    *,
    num_confs: int = 10,
    namespace: str = "smiles",
) -> List[Dict[str, Any]]:
    if not RDKIT_AVAILABLE:
        return []
    mol = None
    if namespace == "smiles":
        mol = Chem.MolFromSmiles(query) if Chem else None
    elif namespace == "inchi":
        mol = Chem.MolFromInchi(query) if Chem else None
    else:
        raise ValueError("RDKit conformer generation requires SMILES or InChI input.")
    if mol is None:
        raise ValueError(f"Unable to parse {namespace.upper()} input.")
    mol = Chem.AddHs(mol)
    params = AllChem.ETKDGv3()
    params.randomSeed = 0xC0FFEE
    conf_ids = list(AllChem.EmbedMultipleConfs(mol, numConfs=num_confs, params=params))
    if not conf_ids:
        raise ValueError("RDKit could not generate conformers.")
    energies: Dict[int, float] = {}
    with suppress(Exception):
        results = AllChem.UFFOptimizeMoleculeConfs(mol, numThreads=0)
        for conf_id, result in zip(conf_ids, results):
            if isinstance(result, tuple) and len(result) > 1:
                try:
                    energies[int(conf_id)] = float(result[1])
                except (TypeError, ValueError):
                    continue
    for conf_id in conf_ids:
        if int(conf_id) not in energies:
            energy = _calc_uff_energy(mol, int(conf_id))
            if energy is not None:
                energies[int(conf_id)] = energy
    conformers: List[Dict[str, Any]] = []
    for idx, conf_id in enumerate(conf_ids, start=1):
        conf = mol.GetConformer(int(conf_id))
        xyz_text = _mol_to_xyz_block(mol, conf, f"{comment} conformer={idx}")
        conformers.append(
            {
                "isomer_id": "1",
                "conf_id": str(idx),
                "title": f"Conformer {idx}",
                "energy_uff": energies.get(int(conf_id)),
                "content": xyz_text,
                "format": "xyz",
            }
        )
    return conformers


def _parse_pubchem_conformers_sdf(
    sdf_text: str,
    *,
    cid: str,
    iupac_name: Optional[str],
    comment: str,
) -> List[Dict[str, Any]]:
    conformers: List[Dict[str, Any]] = []
    records = _split_sdf_records(sdf_text)
    if not records:
        return conformers
    if RDKIT_AVAILABLE:
        supplier = Chem.ForwardSDMolSupplier(io.BytesIO(sdf_text.encode("utf-8")), removeHs=False)
        base_mol = None
        for idx, mol in enumerate(supplier, start=1):
            if mol is None:
                continue
            try:
                conf = mol.GetConformer()
            except ValueError:
                continue
            if base_mol is None:
                base_mol = Chem.Mol(mol)
                base_mol.RemoveAllConformers()
            if base_mol is not None:
                base_mol.AddConformer(conf, assignId=True)
            energy = _extract_pubchem_energy(mol)
            title = mol.GetProp("_Name") if mol.HasProp("_Name") else None
            if not title:
                title = f"Conformer {idx}"
            xyz_text = _mol_to_xyz_block(mol, conf, f"{comment} conformer={idx}")
            conformers.append(
                {
                    "isomer_id": "1",
                    "conf_id": str(idx),
                    "title": title,
                    "energy_uff": energy,
                    "content": xyz_text,
                    "format": "xyz",
                }
            )
        return conformers

    for idx, record in enumerate(records, start=1):
        try:
            xyz_text = _sdf_to_xyz(record, comment=f"{comment} conformer={idx}")
        except Exception:
            continue
        energy = _extract_pubchem_energy_from_sdf(record)
        title = record.splitlines()[0].strip() if record.splitlines() else ""
        if not title:
            title = f"Conformer {idx}"
        conformers.append(
            {
                "isomer_id": "1",
                "conf_id": str(idx),
                "title": title,
                "energy_uff": energy,
                "content": xyz_text,
                "format": "xyz",
            }
        )
    return conformers


def _fetch_cactus_sdf(identifier: str) -> str:
    errors = []
    for base_url in _get_resolver_bases():
        url = f"{base_url}/{quote(identifier, safe='')}/file?format=sdf&get3d=true"
        try:
            response = requests.get(url, timeout=60, headers={"User-Agent": "uma-ase/1.0"})
        except requests.RequestException as exc:
            errors.append(f"{base_url}: {exc}")
            continue
        if response.status_code >= 400:
            errors.append(f"{base_url}: HTTP {response.status_code}")
            continue
        text = response.text.strip()
        if not text:
            errors.append(f"{base_url}: empty response")
            continue
        return text
    if errors:
        raise ValueError(
            "Unable to contact the structure resolver (" + "; ".join(errors) + "). "
            "Set UMA_ASE_RESOLVER_URLS to override."
        )
    raise ValueError("Unable to contact the structure resolver.")


def _resolve_pubchem_geometry(
    namespace: str,
    identifier: str,
    comment: Optional[str],
    *,
    charge: int = 0,
    spin: int = 1,
    allow_cactus: bool = False,
) -> str:
    xyz_text = _fetch_pubchem_xyz(namespace, identifier)
    if xyz_text:
        return _normalize_xyz_text(xyz_text, comment, charge=charge, spin=spin)
    sdf_text = _fetch_pubchem_sdf(namespace, identifier)
    if sdf_text:
        return _sdf_to_xyz(sdf_text, comment, charge=charge, spin=spin)
    if not allow_cactus:
        raise ValueError("PubChem returned no usable coordinate record.")
    cactus_sdf = _fetch_cactus_sdf(identifier)
    return _sdf_to_xyz(cactus_sdf, comment, charge=charge, spin=spin)


def _infer_identifier_namespace(raw_value: str) -> Tuple[str, str]:
    value = raw_value.strip()
    if value.lower().startswith("inchi="):
        return "inchi", "InChI"
    if re.fullmatch(r"[A-Z]{14}-[A-Z]{10}-[A-Z]", value):
        return "inchikey", "InChIKey"
    return "smiles", "SMILES"


def _rdkit_xyz_from_query(
    query: str,
    namespace: str,
    comment: str,
    *,
    charge: int = 0,
    spin: int = 1,
) -> Optional[str]:
    if not RDKIT_AVAILABLE:
        return None
    mol = None
    if namespace == "smiles":
        mol = Chem.MolFromSmiles(query) if Chem else None
    elif namespace == "inchi":
        mol = Chem.MolFromInchi(query) if Chem else None
    if mol is None:
        raise ValueError(f"Unable to parse {namespace.upper()} input.")
    mol = Chem.AddHs(mol)
    params = AllChem.ETKDGv3()
    params.randomSeed = 0xC0FFEE
    embed_status = AllChem.EmbedMolecule(mol, params)
    if embed_status != 0:
        params.useRandomCoords = True
        embed_status = AllChem.EmbedMolecule(mol, params)
    if embed_status != 0:
        raise ValueError("RDKit could not generate a 3D conformer.")
    with suppress(Exception):
        AllChem.UFFOptimizeMolecule(mol, maxIters=200)
    conf = mol.GetConformer()
    atom_lines = []
    for atom in mol.GetAtoms():
        pos = conf.GetAtomPosition(atom.GetIdx())
        atom_lines.append(f"{atom.GetSymbol():<3} {pos.x: .10f} {pos.y: .10f} {pos.z: .10f}")
    return _xyz_with_charge_spin(atom_lines, comment=comment, charge=charge, spin=spin)


def _rdkit_mol_from_query(query: str, namespace: str) -> "Chem.Mol":
    if namespace == "smiles":
        mol = Chem.MolFromSmiles(query) if Chem else None
    elif namespace == "inchi":
        mol = Chem.MolFromInchi(query) if Chem else None
    else:
        mol = None
    if mol is None:
        raise ValueError(f"Unable to parse {namespace.upper()} input.")
    return mol


def _rdkit_2d_png_from_mol(mol: "Chem.Mol", *, size: Tuple[int, int] = (300, 300)) -> Optional[str]:
    if not mol or not Draw:
        return None
    with suppress(Exception):
        mol_copy = Chem.Mol(mol)
        with suppress(Exception):
            AllChem.Compute2DCoords(mol_copy)
        img = Draw.MolToImage(mol_copy, size=size)
        if not img:
            return None
        buffer = io.BytesIO()
        img.save(buffer, format="PNG")
        encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
        return f"data:image/png;base64,{encoded}"
    return None


def _collect_rdkit_diagnostics() -> Dict[str, Any]:
    diagnostics: Dict[str, Any] = {
        "rdkit_available": bool(RDKIT_AVAILABLE),
        "draw_available": bool(Draw is not None),
        "rdDetermineBonds_available": bool(rdDetermineBonds is not None),
        "point3d_available": bool(Point3D is not None),
        "test_smiles": "c1ccccc1",
    }
    if not RDKIT_AVAILABLE:
        diagnostics["test_2d_png"] = {"ok": False, "message": "RDKit core imports are unavailable."}
        return diagnostics

    try:
        diagnostics["rdkit_version"] = getattr(Chem, "rdBase", None).rdkitVersion  # type: ignore[union-attr]
    except Exception:
        diagnostics["rdkit_version"] = None

    test_png: Dict[str, Any] = {"ok": False}
    try:
        mol = Chem.MolFromSmiles("c1ccccc1")
        if mol is None:
            raise ValueError("Unable to build a test molecule from SMILES.")
        with suppress(Exception):
            AllChem.Compute2DCoords(mol)
        if Draw is None:
            raise RuntimeError("rdkit.Chem.Draw is unavailable.")
        img = Draw.MolToImage(mol, size=(250, 250))
        if img is None:
            raise RuntimeError("Draw.MolToImage returned no image.")
        buffer = io.BytesIO()
        img.save(buffer, format="PNG")
        payload = buffer.getvalue()
        if not payload:
            raise RuntimeError("PNG rendering produced an empty payload.")
        test_png.update(
            {
                "ok": True,
                "bytes": len(payload),
                "message": "2D drawing test succeeded.",
            }
        )
    except Exception as exc:
        test_png["message"] = str(exc)
    diagnostics["test_2d_png"] = test_png
    return diagnostics


def _looks_like_xyz(text: str) -> bool:
    lines = text.splitlines()
    if not lines:
        return False
    try:
        return int(lines[0].strip()) > 0
    except ValueError:
        return False


def _mol_from_xyz_text(text: str) -> "Chem.Mol":
    if not Chem or not Point3D:
        raise ValueError("RDKit XYZ parsing is unavailable.")
    lines = text.strip().splitlines()
    if len(lines) < 3:
        raise ValueError("XYZ data is incomplete.")
    try:
        atom_count = int(lines[0].strip())
    except ValueError as exc:
        raise ValueError("XYZ header is invalid.") from exc
    if atom_count <= 0 or len(lines) < atom_count + 2:
        raise ValueError("XYZ data is incomplete.")
    atom_lines = lines[2 : 2 + atom_count]
    mol = Chem.RWMol()
    conf = Chem.Conformer(atom_count)
    for idx, line in enumerate(atom_lines):
        parts = line.split()
        if len(parts) < 4:
            raise ValueError("XYZ atom line is invalid.")
        symbol = parts[0]
        try:
            atom = Chem.Atom(symbol)
        except Exception as exc:  # pragma: no cover - depends on RDKit parsing
            raise ValueError(f"Unknown element symbol: {symbol}") from exc
        mol.AddAtom(atom)
        try:
            x, y, z = (float(parts[1]), float(parts[2]), float(parts[3]))
        except ValueError as exc:
            raise ValueError("XYZ atom coordinates are invalid.") from exc
        conf.SetAtomPosition(idx, Point3D(x, y, z))
    mol.AddConformer(conf, assignId=True)
    return mol.GetMol()


def _mol_from_cml_text(text: str, charge: int) -> "Chem.Mol":
    atoms = _atoms_from_cml_text(text)
    atom_lines = []
    for symbol, (x, y, z) in zip(atoms.get_chemical_symbols(), atoms.get_positions()):
        atom_lines.append(f"{symbol:<3} {x: .10f} {y: .10f} {z: .10f}")
    xyz_text = _xyz_with_charge_spin(atom_lines, comment="Imported from CML", charge=charge, spin=1)
    return _mol_from_xyz_text(xyz_text)


def _mol_from_structure_content(content: str, filename: str, charge: int) -> "Chem.Mol":
    if not content:
        raise ValueError("No structure content provided.")
    text = content.strip()
    ext = Path(filename).suffix.lower()
    mol = None
    if ext in {".sdf", ".mol", ".mol2"}:
        mol = Chem.MolFromMolBlock(text, sanitize=False, removeHs=False)
        if mol is None and "$$$$" in text:
            supplier = Chem.ForwardSDMolSupplier(io.StringIO(text), sanitize=False, removeHs=False)
            mol = next((entry for entry in supplier if entry), None)
    elif ext in {".pdb", ".ent"}:
        mol = Chem.MolFromPDBBlock(text, sanitize=False, removeHs=False)
    elif ext == ".cml" or "<cml" in text.lower():
        mol = _mol_from_cml_text(text, charge)
    elif ext == ".xyz" or _looks_like_xyz(text):
        xyz_reader = getattr(Chem, "MolFromXYZBlock", None)
        if xyz_reader:
            mol = xyz_reader(text)
        if mol is None:
            mol = _mol_from_xyz_text(text)
    if mol is None and "$$$$" in text:
        supplier = Chem.ForwardSDMolSupplier(io.StringIO(text), sanitize=False, removeHs=False)
        mol = next((entry for entry in supplier if entry), None)
    if mol is None:
        raise ValueError("Unable to parse the structure format for RDKit.")
    if rdDetermineBonds and mol.GetNumBonds() == 0:
        max_atoms = _get_rdkit_max_bond_atoms()
        if mol.GetNumAtoms() > max_atoms:
            with suppress(Exception):
                mol.SetProp("_uma_skip_bond_inference", "1")
        else:
            with suppress(Exception):
                rdDetermineBonds.DetermineBonds(mol, charge=charge)
    with suppress(Exception):
        Chem.SanitizeMol(mol)
    return mol


def _infer_bonding_warning(mol: "Chem.Mol") -> Optional[str]:
    if not mol:
        return None
    atom_count = mol.GetNumAtoms()
    metal_count = 0
    for atom in mol.GetAtoms():
        atomic_num = atom.GetAtomicNum()
        if 21 <= atomic_num <= 30 or 39 <= atomic_num <= 48 or 57 <= atomic_num <= 80 or 89 <= atomic_num <= 112:
            metal_count += 1
    if atom_count >= 150 or metal_count >= 5:
        return "Bond inference may be unreliable for large or metal-rich structures."
    if mol.HasProp("_uma_skip_bond_inference"):
        return (
            "Bond inference skipped for large structures "
            f"(>{_get_rdkit_max_bond_atoms()} atoms)."
        )
    if mol.GetNumBonds() == 0:
        return "No bonds were detected; conformer generation may be unreliable."
    return None


def _calc_uff_energy(mol: "Chem.Mol", conf_id: int) -> Optional[float]:
    if not RDKIT_AVAILABLE or mol is None:
        return None
    try:
        ff = AllChem.UFFGetMoleculeForceField(mol, confId=conf_id)
    except Exception:
        return None
    try:
        return float(ff.CalcEnergy())
    except Exception:
        return None


def _generate_rdkit_conformers_from_mol(
    mol: "Chem.Mol",
    comment: str,
    *,
    num_confs: int = 10,
) -> List[Dict[str, Any]]:
    if not RDKIT_AVAILABLE:
        return []
    mol = Chem.AddHs(mol, addCoords=True)
    target_total = max(1, num_confs)
    existing_ids = [conf.GetId() for conf in mol.GetConformers()]
    if not existing_ids:
        params = AllChem.ETKDGv3()
        params.randomSeed = 0xC0FFEE
        embed_status = AllChem.EmbedMolecule(mol, params)
        if embed_status != 0:
            params.useRandomCoords = True
            embed_status = AllChem.EmbedMolecule(mol, params)
        if embed_status != 0:
            raise ValueError("RDKit could not embed the initial structure.")
        existing_ids = [mol.GetConformer().GetId()]
    new_target = max(0, target_total - len(existing_ids))
    new_ids: List[int] = []
    if new_target > 0:
        params = AllChem.ETKDGv3()
        params.randomSeed = 0xC0FFEE
        params.clearConfs = False
        new_ids = list(AllChem.EmbedMultipleConfs(mol, numConfs=new_target, params=params))
    ordered_ids = existing_ids + [cid for cid in new_ids if cid not in existing_ids]
    ordered_ids = ordered_ids[:target_total]
    energies: Dict[int, float] = {}
    with suppress(Exception):
        results = AllChem.UFFOptimizeMoleculeConfs(mol, confIds=ordered_ids, numThreads=0)
        for conf_id, result in zip(ordered_ids, results):
            if isinstance(result, tuple) and len(result) > 1:
                try:
                    energies[int(conf_id)] = float(result[1])
                except (TypeError, ValueError):
                    continue
    for conf_id in ordered_ids:
        if int(conf_id) not in energies:
            energy = _calc_uff_energy(mol, int(conf_id))
            if energy is not None:
                energies[int(conf_id)] = energy
    conformers: List[Dict[str, Any]] = []
    for idx, conf_id in enumerate(ordered_ids, start=1):
        conf = mol.GetConformer(int(conf_id))
        xyz_text = _mol_to_xyz_block(mol, conf, f"{comment} conformer={idx}")
        conformers.append(
            {
                "isomer_id": "1",
                "conf_id": str(idx),
                "title": f"Conformer {idx}",
                "energy_uff": energies.get(int(conf_id)),
                "content": xyz_text,
                "format": "xyz",
            }
        )
    return conformers


def _download_iochem_cml(handler: CollectionHandler, item_id: str) -> str:
    try:
        file_url = handler.query_target_file_id(item_id, "output.cml")
    except KeyError as exc:
        raise ValueError("The provided handle does not expose an output.cml file.") from exc
    try:
        response = requests.get(
            file_url,
            headers=handler.headers.get("GET"),
            verify=getattr(handler, "verify", True),
            timeout=60,
        )
    except requests.exceptions.SSLError as exc:
        raise ValueError(
            "TLS verification failed when contacting ioChem-BD. "
            "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
        ) from exc
    except requests.RequestException as exc:
        raise ValueError("Unable to download the ioChem-BD output file.") from exc
    if response.status_code >= 400:
        raise ValueError(f"Unable to download output.cml (HTTP {response.status_code}).")
    text = response.text.strip()
    if not text:
        raise ValueError("ioChem-BD returned an empty output.cml file.")
    return response.text


def _extract_iochem_cml_links(html_text: str) -> List[str]:
    if not html_text:
        return []
    text = unescape(html_text)
    links: List[str] = []
    direct_pattern = re.compile(r'href=["\']([^"\']+?\.cml[^"\']*)["\']', re.IGNORECASE)
    links.extend(direct_pattern.findall(text))

    anchor_pattern = re.compile(
        r"<a\b(?P<attrs>[^>]*)href=[\"'](?P<href>[^\"']+)[\"'](?P<tail>[^>]*)>(?P<label>.*?)</a>",
        re.IGNORECASE | re.DOTALL,
    )
    for match in anchor_pattern.finditer(text):
        href = (match.group("href") or "").strip()
        if not href:
            continue
        attrs = " ".join(
            part for part in (
                match.group("attrs") or "",
                match.group("tail") or "",
                match.group("label") or "",
            ) if part
        ).lower()
        href_lower = href.lower()
        if ".cml" in href_lower:
            links.append(href)
            continue
        if ("/bitstream/" in href_lower or "/retrieve" in href_lower) and (
            "output.cml" in attrs or ".cml" in attrs or "cml" in attrs
        ):
            links.append(href)

    quoted_bitstreams = re.compile(r'["\']([^"\']*(?:/bitstream/|/retrieve)[^"\']*)["\']', re.IGNORECASE)
    for match in quoted_bitstreams.finditer(text):
        candidate = (match.group(1) or "").strip()
        if not candidate:
            continue
        start = max(0, match.start() - 160)
        end = min(len(text), match.end() + 160)
        context = text[start:end].lower()
        if "output.cml" in context or ".cml" in context or "cml" in context:
            links.append(candidate)

    seen = set()
    unique = []
    for link in links:
        if link in seen:
            continue
        seen.add(link)
        unique.append(link)
    return unique


def _looks_like_html_document(text: str) -> bool:
    prefix = (text or "").lstrip()[:512].lower()
    return prefix.startswith("<!doctype html") or prefix.startswith("<html") or "<html" in prefix


def _score_iochem_download_link(link: str) -> Tuple[int, int, int, str]:
    lowered = link.lower()
    return (
        0 if "output.cml" in lowered else 1,
        0 if "/bitstream/" in lowered or "/retrieve" in lowered or "download" in lowered else 1,
        0 if lowered.endswith(".cml") else 1,
        lowered,
    )


def _download_iochem_cml_from_page_links(
    page_url: str,
    page_text: str,
    *,
    verify: bool = True,
    depth: int = 0,
    visited: Optional[set[str]] = None,
) -> str:
    if visited is None:
        visited = set()
    if depth > 2:
        raise ValueError("Unable to locate a downloadable CML file from the ioChem-BD page.")
    links = _extract_iochem_cml_links(page_text or "")
    if not links:
        raise ValueError("Unable to locate a CML file link on the ioChem-BD handle page.")
    last_error: Optional[ValueError] = None
    for link in sorted(links, key=_score_iochem_download_link):
        download_url = urljoin(page_url, link)
        if download_url in visited:
            continue
        visited.add(download_url)
        try:
            response = requests.get(
                download_url,
                headers={"Accept": "application/octet-stream,text/xml,application/xml,text/plain;q=0.9,*/*;q=0.8"},
                timeout=60,
                verify=verify,
            )
        except requests.exceptions.SSLError as exc:
            raise ValueError(
                "TLS verification failed when contacting ioChem-BD. "
                "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
            ) from exc
        except requests.RequestException:
            continue
        if response.status_code >= 400:
            continue
        payload = response.text or ""
        text = payload.strip()
        if not text:
            continue
        if _looks_like_html_document(text):
            nested_links = _extract_iochem_cml_links(text)
            if nested_links:
                try:
                    return _download_iochem_cml_from_page_links(
                        str(response.url or download_url),
                        text,
                        verify=verify,
                        depth=depth + 1,
                        visited=visited,
                    )
                except ValueError as exc:
                    last_error = exc
                    continue
            last_error = ValueError("Downloaded page resolved to HTML instead of a CML file.")
            continue
        try:
            _locate_cml_molecule(payload)
            return payload
        except ValueError as exc:
            last_error = exc
            continue
    if last_error is not None:
        raise last_error
    raise ValueError("Unable to download a CML file from the ioChem-BD handle page.")


def _download_iochem_cml_from_handle_page(handle_url: str, *, verify: bool = True) -> str:
    try:
        resp = requests.get(
            handle_url,
            headers={"Accept": "text/html"},
            timeout=30,
            verify=verify,
        )
    except requests.exceptions.SSLError as exc:
        raise ValueError(
            "TLS verification failed when contacting ioChem-BD. "
            "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
        ) from exc
    except requests.RequestException as exc:
        raise ValueError("Unable to load the ioChem-BD handle page.") from exc
    if resp.status_code >= 400:
        raise ValueError(f"Unable to load the ioChem-BD handle page (HTTP {resp.status_code}).")
    return _download_iochem_cml_from_page_links(str(resp.url or handle_url), resp.text or "", verify=verify)


def _download_iochem_geometry(raw_handle: str, *, verify_ssl: Optional[bool] = None) -> IoChemGeometry:
    handle = _normalize_iochem_handle(raw_handle)
    if verify_ssl is None:
        verify_ssl = bool(app.config.get("IOCHEM_VERIFY", True))
    cml_text = _download_iochem_cml_text(str(raw_handle), verify_ssl=verify_ssl)
    charge, spin = _extract_charge_spin_from_cml(cml_text)
    atoms = _atoms_from_cml_text(cml_text)
    atoms.info["charge"] = charge
    atoms.info["spin"] = spin
    source_url = _build_iochem_source_url(handle, raw_handle=raw_handle)
    filename = f"ioChem_{handle.replace('/', '_')}.cml"
    xyz_content = _atoms_to_xyz(atoms, source_url, charge, spin)
    viewer_filename, viewer_content, viewer_cell_parameters, viewer_pbc = _viewer_geometry_from_payload(
        filename,
        cml_text.encode("utf-8"),
    )
    formula = atoms.get_chemical_formula()
    num_atoms = len(atoms)
    element_counts = dict(Counter(atoms.get_chemical_symbols()))
    return IoChemGeometry(
        handle=handle,
        source_url=source_url,
        filename=filename,
        xyz_content=xyz_content,
        cml_content=cml_text,
        charge=charge,
        spin=spin,
        formula=formula,
        num_atoms=num_atoms,
        element_counts=element_counts,
        cell_parameters=viewer_cell_parameters,
        pbc=viewer_pbc,
        viewer_filename=viewer_filename,
        viewer_content=viewer_content,
    )


def _download_iochem_cml_text(raw_handle: str, *, verify_ssl: Optional[bool] = None) -> str:
    handle = _normalize_iochem_handle(raw_handle)
    rest_url = _resolve_iochem_rest_url(raw_handle)
    if verify_ssl is None:
        verify_ssl = bool(app.config.get("IOCHEM_VERIFY", True))
    handle_metadata = None
    rest_candidates = _get_iochem_rest_candidates(rest_url, raw_handle)
    last_exc: Optional[Exception] = None
    for candidate in rest_candidates:
        try:
            handle_metadata = _fetch_iochem_handle_metadata(handle, candidate, verify=verify_ssl)
            rest_url = candidate
            break
        except Exception as exc:
            last_exc = exc
            continue
    cml_text = None
    if handle_metadata is None:
        source_url = _build_iochem_source_url(handle, raw_handle=raw_handle)
        try:
            cml_text = _download_iochem_cml_from_handle_page(source_url, verify=verify_ssl)
        except Exception as exc:
            raise ValueError(f"Unable to query ioChem-BD REST API ({last_exc}). {exc}") from exc

    is_collection = isinstance(handle_metadata, dict) and ("numberItems" in handle_metadata)
    if cml_text is None:
        if is_collection:
            try:
                handler = CollectionHandler(rest_url, handle, token="", service="browse", verify=verify_ssl)
                items = handler.get_items()
            except Exception as exc:  # pragma: no cover
                raise ValueError(f"Unable to query ioChem-BD REST API ({exc}).") from exc
            if not items:
                raise ValueError(f"No calculations found for handle '{handle}'.")
            target = items[0]
            item_id = target.get(handler.idField)
            if not item_id:
                raise ValueError("Unable to determine the calculation identifier for this handle.")
            cml_text = _download_iochem_cml(handler, item_id)
        else:
            item_identifier = handle_metadata.get("uuid") or handle_metadata.get("id")
            if not item_identifier:
                raise ValueError("The ioChem-BD handle response is missing an identifier for the calculation.")
            cml_text = _download_iochem_item_cml(rest_url, str(item_identifier), verify=verify_ssl)

    if not cml_text:
        raise ValueError("Unable to download the ioChem-BD CML content.")
    return cml_text


def _parse_bool_field(form, field: str, default: bool = False) -> bool:
    raw = form.get(field)
    if raw is None:
        return default
    return raw.strip().lower() in ("1", "true", "yes", "on")


def _missing_engine_builder(engine_key: str, module_hint: str):
    def _builder(*_):
        raise RuntimeError(
            f"The '{engine_key}' MD engine requires {module_hint}, but it is not available in the installed ASE. "
            "Update ASE or choose a different engine."
        )

    return _builder


def _build_md_builder_params(md_options: Dict[str, Any]) -> SimpleNamespace:
    temperature = md_options.get("temperature", 300.0)
    timestep = md_options.get("timestep_fs", 0.5) * units.fs
    friction_coeff = max(0.0, md_options.get("friction", 0.0)) / units.fs
    relax_fs = max(1e-12, md_options.get("relax_fs", 100.0))
    relax_time = relax_fs * units.fs
    pressure_bar = md_options.get("pressure_bar", 1.0)
    compressibility_bar = md_options.get("compressibility_bar", 0.0)
    compressibility_au = compressibility_bar / units.bar if compressibility_bar and compressibility_bar > 0 else None
    barostat_relax = max(1e-12, md_options.get("barostat_relax_fs", 1000.0)) * units.fs
    nose_tdamp = max(1e-12, md_options.get("nose_tdamp_fs", relax_fs)) * units.fs
    bussi_taut = max(1e-12, md_options.get("bussi_taut_fs", relax_fs)) * units.fs
    baoab_ttau = max(1e-12, md_options.get("baoab_ttau_fs", 50.0)) * units.fs
    baoab_ptau = max(1e-12, md_options.get("baoab_ptau_fs", 1000.0)) * units.fs
    melchionna_ttime = max(0.0, md_options.get("melchionna_ttime_fs", 25.0)) * units.fs
    melchionna_ptime = max(0.0, md_options.get("melchionna_ptime_fs", 75.0)) * units.fs
    return SimpleNamespace(
        temperature=temperature,
        timestep=timestep,
        friction=friction_coeff,
        relax=relax_time,
        pressure_bar=pressure_bar,
        pressure_au=pressure_bar * units.bar,
        compressibility_au=compressibility_au,
        barostat_relax=barostat_relax,
        nose_tdamp=nose_tdamp,
        nose_tchain=int(md_options.get("nose_tchain", 3)),
        nose_tloop=int(md_options.get("nose_tloop", 1)),
        bussi_taut=bussi_taut,
        andersen_prob=float(md_options.get("andersen_prob", 0.0)),
        mtk_tdamp=max(1e-12, md_options.get("mtk_tdamp_fs", 100.0)) * units.fs,
        mtk_pdamp=max(1e-12, md_options.get("mtk_pdamp_fs", 1000.0)) * units.fs,
        mtk_tchain=int(md_options.get("mtk_tchain", 3)),
        mtk_pchain=int(md_options.get("mtk_pchain", 3)),
        mtk_tloop=int(md_options.get("mtk_tloop", 1)),
        mtk_ploop=int(md_options.get("mtk_ploop", 1)),
        baoab_ttau=baoab_ttau,
        baoab_ptau=baoab_ptau,
        baoab_pmass=md_options.get("baoab_pmass"),
        baoab_pmass_factor=md_options.get("baoab_pmass_factor", 1.0),
        baoab_hydrostatic=bool(md_options.get("baoab_hydrostatic")),
        melchionna_ttime=melchionna_ttime if melchionna_ttime > 0 else None,
        melchionna_ptime=melchionna_ptime if melchionna_ptime > 0 else None,
        melchionna_bulk=md_options.get("melchionna_bulk_gpa", 0.0) * units.GPa,
        melchionna_mask=md_options.get("melchionna_mask"),
    )


MD_ENGINE_BUILDERS: Dict[str, Callable[[Any, SimpleNamespace], Any]] = {
    "langevin": lambda atoms, p: Langevin(
        atoms,
        p.timestep,
        temperature_K=p.temperature,
        friction=p.friction,
    ),
    "velocity_verlet": lambda atoms, p: VelocityVerlet(atoms, p.timestep),
    "nvt_berendsen": lambda atoms, p: NVTBerendsen(
        atoms,
        p.timestep,
        temperature_K=p.temperature,
        taut=p.relax,
    ),
}

MD_ENGINE_BUILDERS["bussi"] = (
    (lambda atoms, p: Bussi(atoms, p.timestep, temperature_K=p.temperature, taut=p.bussi_taut))
    if Bussi is not None
    else _missing_engine_builder("bussi", "ase.md.bussi.Bussi")
)

MD_ENGINE_BUILDERS["andersen"] = (
    (lambda atoms, p: Andersen(atoms, p.timestep, temperature_K=p.temperature, andersen_prob=p.andersen_prob))
    if Andersen is not None
    else _missing_engine_builder("andersen", "ase.md.andersen.Andersen")
)

MD_ENGINE_BUILDERS["npt_berendsen"] = (
    (
        lambda atoms, p: NPTBerendsen(
            atoms,
            p.timestep,
            temperature_K=p.temperature,
            pressure_au=p.pressure_au,
            taut=p.relax,
            taup=p.barostat_relax,
            compressibility_au=p.compressibility_au,
        )
    )
    if NPTBerendsen is not None
    else _missing_engine_builder("npt_berendsen", "ase.md.nptberendsen.NPTBerendsen")
)

MD_ENGINE_BUILDERS["nose_hoover_chain"] = (
    (
        lambda atoms, p: NoseHooverChainNVT(
            atoms,
            p.timestep,
            temperature_K=p.temperature,
            tdamp=p.nose_tdamp,
            tchain=p.nose_tchain,
            tloop=p.nose_tloop,
        )
    )
    if NoseHooverChainNVT is not None
    else _missing_engine_builder("nose_hoover_chain", "ase.md.nose_hoover_chain.NoseHooverChainNVT")
)

MD_ENGINE_BUILDERS["isotropic_mtk_npt"] = (
    (
        lambda atoms, p: IsotropicMTKNPT(
            atoms,
            p.timestep,
            temperature_K=p.temperature,
            pressure_au=p.pressure_au,
            tdamp=p.mtk_tdamp,
            pdamp=p.mtk_pdamp,
            tchain=p.mtk_tchain,
            pchain=p.mtk_pchain,
            tloop=p.mtk_tloop,
            ploop=p.mtk_ploop,
        )
    )
    if IsotropicMTKNPT is not None
    else _missing_engine_builder("isotropic_mtk_npt", "ase.md.nose_hoover_chain.IsotropicMTKNPT")
)

MD_ENGINE_BUILDERS["mtk_npt"] = (
    (
        lambda atoms, p: MTKNPT(
            atoms,
            p.timestep,
            temperature_K=p.temperature,
            pressure_au=p.pressure_au,
            tdamp=p.mtk_tdamp,
            pdamp=p.mtk_pdamp,
            tchain=p.mtk_tchain,
            pchain=p.mtk_pchain,
            tloop=p.mtk_tloop,
            ploop=p.mtk_ploop,
        )
    )
    if MTKNPT is not None
    else _missing_engine_builder("mtk_npt", "ase.md.nose_hoover_chain.MTKNPT")
)

MD_ENGINE_BUILDERS["langevin_baoab_nvt"] = (
    (
        lambda atoms, p: LangevinBAOAB(
            atoms,
            p.timestep,
            temperature_K=p.temperature,
            T_tau=p.baoab_ttau,
        )
    )
    if LangevinBAOAB is not None
    else _missing_engine_builder("langevin_baoab_nvt", "ase.md.langevinbaoab.LangevinBAOAB")
)

MD_ENGINE_BUILDERS["langevin_baoab_npt"] = (
    (
        lambda atoms, p: LangevinBAOAB(
            atoms,
            p.timestep,
            temperature_K=p.temperature,
            externalstress=-p.pressure_au,
            hydrostatic=p.baoab_hydrostatic,
            T_tau=p.baoab_ttau,
            P_tau=p.baoab_ptau,
            P_mass=p.baoab_pmass if p.baoab_pmass else None,
            P_mass_factor=p.baoab_pmass_factor,
        )
    )
    if LangevinBAOAB is not None
    else _missing_engine_builder("langevin_baoab_npt", "ase.md.langevinbaoab.LangevinBAOAB")
)

MD_ENGINE_BUILDERS["melchionna_npt"] = (
    (
        lambda atoms, p: MelchionnaNPT(
            atoms,
            p.timestep,
            temperature_K=p.temperature,
            externalstress=-p.pressure_au,
            ttime=p.melchionna_ttime,
            pfactor=(p.melchionna_ptime**2 * (p.melchionna_bulk or 0.0)) if p.melchionna_ptime else None,
            mask=p.melchionna_mask,
        )
    )
    if MelchionnaNPT is not None
    else _missing_engine_builder("melchionna_npt", "ase.md.melchionna.MelchionnaNPT")
)

BYTE_SIZE_SUFFIXES = {
    "": 1,
    "b": 1,
    "k": 1000,
    "kb": 1000,
    "m": 1000**2,
    "mb": 1000**2,
    "g": 1000**3,
    "gb": 1000**3,
    "t": 1000**4,
    "tb": 1000**4,
    "ki": 1024,
    "kib": 1024,
    "mi": 1024**2,
    "mib": 1024**2,
    "gi": 1024**3,
    "gib": 1024**3,
    "ti": 1024**4,
    "tib": 1024**4,
}

PROTECTED_JOB_LIMIT_ENV_ALIASES = {
    "UMA_ASE_JOBS_RETENTION_DAYS": ("jobs_retention_days", "retention_days"),
    "UMA_ASE_JOBS_MAX_COUNT": ("jobs_max_count", "max_job_count", "max_count"),
    "UMA_ASE_JOBS_MAX_BYTES": ("jobs_max_bytes", "max_disk_bytes", "max_bytes"),
}


def _parse_byte_limit(value: Any) -> Optional[int]:
    if value is None or isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value if value > 0 else None
    if isinstance(value, float):
        if not math.isfinite(value):
            return None
        parsed_float = int(value)
        return parsed_float if parsed_float > 0 else None
    text = str(value).strip()
    if not text:
        return None
    match = re.fullmatch(r"(?i)(\d+(?:\.\d+)?)\s*([kmgt]i?b?|b)?", text)
    if not match:
        return None
    number = float(match.group(1))
    suffix = (match.group(2) or "").strip().lower()
    multiplier = BYTE_SIZE_SUFFIXES.get(suffix)
    if multiplier is None:
        return None
    parsed = int(number * multiplier)
    return parsed if parsed > 0 else None


def _read_single_value_file(path_value: str, *, label: str) -> str:
    path = Path(path_value).expanduser()
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise RuntimeError(f"Unable to read {label} from '{path}': {exc}") from exc
    value = text.strip()
    if not value:
        raise RuntimeError(f"{label} file '{path}' is empty.")
    return value


def _load_protected_job_limits() -> Dict[str, str]:
    config_path = str(os.environ.get("UMA_ASE_PROTECTED_LIMITS_FILE", "") or "").strip()
    if not config_path:
        return {}
    path = Path(config_path).expanduser()
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except OSError as exc:
        raise RuntimeError(f"Unable to read protected job limits file '{path}': {exc}") from exc
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"Protected job limits file '{path}' is not valid JSON: {exc}") from exc
    if not isinstance(payload, dict):
        raise RuntimeError(f"Protected job limits file '{path}' must contain a JSON object.")

    resolved: Dict[str, str] = {}
    for env_name, aliases in PROTECTED_JOB_LIMIT_ENV_ALIASES.items():
        for key in (env_name, *aliases):
            if key not in payload:
                continue
            raw_value = payload.get(key)
            if raw_value is None:
                continue
            value = str(raw_value).strip()
            if value:
                resolved[env_name] = value
                break
    return resolved


def _resolve_job_limit_setting(env_name: str, default: str) -> str:
    protected = _load_protected_job_limits()
    protected_value = protected.get(env_name)
    if protected_value:
        return protected_value

    file_env_name = f"{env_name}_FILE"
    file_value = str(os.environ.get(file_env_name, "") or "").strip()
    if file_value:
        return _read_single_value_file(file_value, label=file_env_name)

    return str(os.environ.get(env_name, default) or default).strip()


def _parse_required_positive_int(value: str, *, label: str) -> int:
    try:
        parsed = int(str(value).strip())
    except (TypeError, ValueError) as exc:
        raise RuntimeError(f"{label} must be a positive integer.") from exc
    if parsed <= 0:
        raise RuntimeError(f"{label} must be a positive integer.")
    return parsed


STATIC_HTML = "UMA-ASE.html"
app = Flask(__name__)
IOCHEM_DEFAULT_REST_URL = os.environ.get("UMA_ASE_IOCHEM_REST_URL", "https://www.iochem-bd.org/rest")
IOCHEM_CREATE_DEFAULT_BASE = os.environ.get("UMA_ASE_IOCHEM_CREATE_BASE", "https://iochem-bd.iciq.es")
IOCHEM_DEFAULT_VERIFY = os.environ.get("UMA_ASE_IOCHEM_VERIFY", "1").strip().lower() not in (
    "0",
    "false",
    "no",
    "off",
)
IOCHEM_DEFAULT_REST_FALLBACKS = os.environ.get(
    "UMA_ASE_IOCHEM_REST_FALLBACKS",
    "https://iochem-bd.bsc.es/rest,https://iochem-bd.iciq.es/rest",
)
UMA_JOBS_RETENTION_DAYS_DEFAULT = _parse_required_positive_int(
    _resolve_job_limit_setting("UMA_ASE_JOBS_RETENTION_DAYS", "14"),
    label="UMA_ASE_JOBS_RETENTION_DAYS",
)
UMA_JOBS_MAX_COUNT_DEFAULT = _parse_required_positive_int(
    _resolve_job_limit_setting("UMA_ASE_JOBS_MAX_COUNT", "100"),
    label="UMA_ASE_JOBS_MAX_COUNT",
)
UMA_JOBS_MAX_BYTES_RAW = _resolve_job_limit_setting(
    "UMA_ASE_JOBS_MAX_BYTES",
    "1GB",
)
UMA_JOBS_MAX_BYTES_DEFAULT = _parse_byte_limit(UMA_JOBS_MAX_BYTES_RAW)
if UMA_JOBS_MAX_BYTES_DEFAULT is None:
    raise RuntimeError("UMA_ASE_JOBS_MAX_BYTES must be a positive byte size value.")

app.config.setdefault("IOCHEM_REST_URL", IOCHEM_DEFAULT_REST_URL)
app.config.setdefault("IOCHEM_CREATE_BASE", IOCHEM_CREATE_DEFAULT_BASE)
app.config.setdefault("IOCHEM_VERIFY", IOCHEM_DEFAULT_VERIFY)
app.config.setdefault("IOCHEM_REST_FALLBACKS", IOCHEM_DEFAULT_REST_FALLBACKS)
app.config.setdefault("UMA_RESULTS_DIR", Path.home() / ".uma_ase" / "results")
app.config.setdefault(
    "UMA_MAX_CONFORMERS",
    int(os.environ.get("UMA_ASE_MAX_CONFORMERS", "2000") or "2000"),
)
app.config.setdefault(
    "UMA_RDKIT_MAX_BOND_ATOMS",
    int(os.environ.get("UMA_ASE_RDKIT_MAX_BOND_ATOMS", "200") or "200"),
)
app.config.setdefault(
    "UMA_RDKIT_MAX_2D_ATOMS",
    int(os.environ.get("UMA_ASE_RDKIT_MAX_2D_ATOMS", "250") or "250"),
)
app.config.setdefault("UMA_JOBS_RETENTION_DAYS", UMA_JOBS_RETENTION_DAYS_DEFAULT)
app.config.setdefault("UMA_JOBS_MAX_COUNT", UMA_JOBS_MAX_COUNT_DEFAULT)
app.config.setdefault("UMA_JOBS_MAX_BYTES", UMA_JOBS_MAX_BYTES_DEFAULT)
ANALYZE_REPORT_ROOT = Path(app.config["UMA_RESULTS_DIR"]) / "analyze_reports"
ANALYZE_REPORT_ROOT.mkdir(parents=True, exist_ok=True)

CML_NAMESPACE = {"cml": "http://www.xml-cml.org/schema"}
LOG_STATUS_MAX_BYTES = 200_000
IOCHEM_PROXY_HOSTS = {
    "iochem-bd.iciq.es",
    "iochem-bd.bsc.es",
    "www.iochem-bd.org",
    "iochem-bd.org",
}
IOCHEM_PROXY_PLACEHOLDER_HOST = "your.server.here"
IOCHEM_PROXY_DEFAULT_ORIGIN = "https://iochem-bd.iciq.es"
IOCHEM_PROXY_TTL_SECONDS = 1800
IOCHEM_PROXY_MAX_SESSIONS = 50
IOCHEM_PROXY_SESSIONS: Dict[str, Tuple[requests.Session, float]] = {}
IOCHEM_PROXY_LOCK = threading.Lock()
JOB_STORE_LOCK = threading.Lock()
USE_SCHEMA_VERSION = "uma-ase.use/v1"
USE_METHOD_NAME = "MLIP-UMA"


def _normalize_use_run_types(value: Any) -> List[str]:
    if isinstance(value, str):
        items = value.split()
    elif isinstance(value, (list, tuple)):
        items = value
    else:
        items = []
    return [str(item).strip().lower() for item in items if str(item).strip()]


def _missing_required_use_keys(job_kind: str, params: Dict[str, Any]) -> List[str]:
    missing: List[str] = []

    def require(keys: Iterable[str]) -> None:
        for key in keys:
            if key not in params or params.get(key) is None:
                missing.append(key)

    if job_kind == "md":
        require([
            "run_type",
            "run_type_requested",
            "chg",
            "spin",
            "checkpoint",
            "mlff_task",
            "method",
            "cpu_only",
            "device",
            "md_engine",
            "md_steps",
            "md_timestep_fs",
            "md_traj_interval",
            "md_log_interval",
        ])
        engine = str(params.get("md_engine") or "langevin").lower()
        use_input_velocities = bool(params.get("md_use_input_velocities"))
        if engine != "velocity_verlet" or not use_input_velocities:
            require(["md_temperature"])
        if engine == "langevin":
            require(["md_friction"])
        if engine in {"nvt_berendsen", "npt_berendsen"}:
            require(["md_relax_fs"])
        if engine in {"npt_berendsen", "isotropic_mtk_npt", "mtk_npt", "langevin_baoab_npt", "melchionna_npt"}:
            require(["md_pressure_bar"])
        if engine == "npt_berendsen":
            require(["md_barostat_relax_fs", "md_compressibility_bar"])
        if engine == "nose_hoover_chain":
            require(["md_nose_tdamp_fs", "md_nose_tchain", "md_nose_tloop"])
        if engine == "bussi":
            require(["md_bussi_taut_fs"])
        if engine == "andersen":
            require(["md_andersen_prob"])
        if engine in {"isotropic_mtk_npt", "mtk_npt"}:
            require([
                "md_mtk_tdamp_fs",
                "md_mtk_pdamp_fs",
                "md_mtk_tchain",
                "md_mtk_pchain",
                "md_mtk_tloop",
                "md_mtk_ploop",
            ])
        if engine in {"langevin_baoab_nvt", "langevin_baoab_npt"}:
            require(["md_baoab_ttau_fs"])
            if engine == "langevin_baoab_npt":
                require([
                    "md_baoab_ptau_fs",
                    "md_baoab_pmass_factor",
                    "md_baoab_hydrostatic",
                ])
        if engine == "melchionna_npt":
            require([
                "md_melchionna_ttime_fs",
                "md_melchionna_ptime_fs",
                "md_melchionna_bulk_gpa",
            ])
        if bool(params.get("md_use_pbc")):
            require([
                "md_cell_a",
                "md_cell_b",
                "md_cell_c",
                "md_cell_alpha",
                "md_cell_beta",
                "md_cell_gamma",
            ])
        if use_input_velocities:
            require(["md_velocities"])
        return missing

    require([
        "run_type",
        "run_type_requested",
        "chg",
        "spin",
        "checkpoint",
        "mlff_task",
        "method",
        "cpu_only",
        "device",
    ])
    run_types = _normalize_use_run_types(params.get("run_type_requested") or params.get("run_type"))
    if any(item in {"geoopt", "ts"} for item in run_types):
        require(["optimizer", "grad", "iter"])
    if any(item in {"freqs", "ts"} for item in run_types):
        require(["temp", "press"])
    if "ts" in run_types:
        require([
            "ts_displacement",
            "ts_dimer_separation",
            "ts_trial_step",
            "ts_max_step",
            "ts_max_rot",
        ])
    return missing
PUBCHEM_BASE_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound"
PUBCHEM_PUG_ROOT = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
CACTUS_BASE_URL = "https://cactus.nci.nih.gov/chemical/structure"
HTML_ATTR_ESCAPE_MAP = {'"': "&quot;"}


def _get_resolver_bases() -> Tuple[str, ...]:
    raw = os.environ.get("UMA_ASE_RESOLVER_URLS", "").strip()
    if not raw:
        return (CACTUS_BASE_URL,)
    bases: List[str] = []
    for entry in raw.split(","):
        base = entry.strip()
        if not base:
            continue
        if not re.match(r"^https?://", base, re.IGNORECASE):
            base = f"https://{base}"
        bases.append(base.rstrip("/"))
    return tuple(bases) if bases else (CACTUS_BASE_URL,)


@dataclass
class IoChemGeometry:
    handle: str
    source_url: str
    filename: str
    xyz_content: str
    cml_content: str
    charge: int
    spin: int
    formula: str
    num_atoms: int
    element_counts: Dict[str, int]
    cell_parameters: Optional[List[float]] = None
    pbc: Optional[List[bool]] = None
    viewer_filename: Optional[str] = None
    viewer_content: Optional[str] = None


@dataclass
class JobRecord:
    job_id: str
    job_dir: Path
    charge: int
    spin: int
    grad: float
    iterations: int
    run_types: List[str]
    status: str = "running"
    message: Optional[str] = None
    job_name: Optional[str] = None
    job_source: Optional[str] = None
    log_path: Optional[Path] = None
    traj_path: Optional[Path] = None
    opt_path: Optional[Path] = None
    freq_archive_path: Optional[Path] = None
    freq_folder: Optional[Path] = None
    cml_path: Optional[Path] = None
    log_url: Optional[str] = None
    traj_url: Optional[str] = None
    opt_url: Optional[str] = None
    freq_archive_url: Optional[str] = None
    cml_url: Optional[str] = None
    traj_visual_path: Optional[Path] = None
    relative_path: Optional[Path] = None
    job_kind: str = "workflow"
    md_options: Optional[Dict[str, Any]] = None
    md_multi_xyz: Optional[Path] = None
    md_multi_xyz_url: Optional[str] = None
    cancel_event: Optional[threading.Event] = None
    worker: Optional[threading.Thread] = None
    process: Optional[subprocess.Popen] = None
    client_id: Optional[str] = None
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    queued_at: Optional[datetime] = None
    started_at: Optional[datetime] = None
    finished_at: Optional[datetime] = None
    deleted: bool = False
    input_filename: Optional[str] = None


@dataclass
class QueuedJob:
    record: JobRecord
    func: Callable[..., None]
    args: Tuple[Any, ...]
    kwargs: Dict[str, Any]


JOBS: Dict[str, JobRecord] = {}
JOB_LOCK = threading.Lock()
JOB_QUEUE = deque()
JOB_QUEUE_COND = threading.Condition()
JOB_QUEUE_WORKER: Optional[threading.Thread] = None


class JobCancelled(Exception):
    """Raised internally when a running job is cancelled by the user."""


def _get_job(job_id: str) -> JobRecord:
    with JOB_LOCK:
        record = JOBS.get(job_id)
    if record is None:
        abort(404)
    return record


def _coerce_float(raw: Optional[str], fallback: Optional[float] = None) -> Optional[float]:
    if raw is None:
        return fallback
    try:
        return float(raw)
    except (TypeError, ValueError):
        return fallback


def _build_use_payload(
    *,
    input_path: Path,
    filename: str,
    job_kind: str,
    params: Dict[str, Any],
) -> Dict[str, Any]:
    missing_keys = _missing_required_use_keys(job_kind, params)
    if missing_keys:
        missing_text = ", ".join(sorted(set(missing_keys)))
        raise ValueError(f"Incomplete .use payload for {job_kind}: missing {missing_text}")
    try:
        content = input_path.read_text(encoding="utf-8")
    except OSError:
        content = input_path.read_text(encoding="utf-8", errors="replace")
    payload = {
        "schema": USE_SCHEMA_VERSION,
        "generated_at": _format_time(_utcnow()),
        "generated_by": {
            "package": "uma-ase",
            "version": __version__,
        },
        "job_kind": job_kind,
        "input": {
            "filename": filename,
            "format": input_path.suffix.lstrip(".") or "xyz",
            "path": str(input_path),
            "content": content,
        },
        "params": {key: value for key, value in params.items() if value is not None},
    }
    return payload


def _write_use_file(job_dir: Path, filename: str, payload: Dict[str, Any]) -> Path:
    use_path = job_dir / f"{Path(filename).stem}.use"
    use_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
    return use_path


def _build_workflow_use_params(
    *,
    run_types: List[str],
    charge: int,
    spin: int,
    optimizer: str,
    grad: float,
    iterations: int,
    temperature: Optional[float],
    pressure: Optional[float],
    mlff_checkpoint: str,
    mlff_task: str,
    ts_displacement: float,
    ts_dimer_separation: float,
    ts_trial_step: float,
    ts_max_step: float,
    ts_max_rot: int,
) -> Dict[str, Any]:
    normalized = [item.lower() for item in run_types]
    includes_geoopt = "geoopt" in normalized
    includes_freqs = "freqs" in normalized
    includes_ts = "ts" in normalized
    params: Dict[str, Any] = {
        "run_type": normalized,
        "run_type_requested": normalized,
        "chg": charge,
        "spin": spin,
        "checkpoint": mlff_checkpoint,
        "mlff_task": mlff_task,
        "method": USE_METHOD_NAME,
        "cpu_only": False,
        "device": "auto",
    }
    if includes_geoopt or includes_ts:
        params.update(
            {
                "optimizer": optimizer,
                "grad": grad,
                "iter": iterations,
            }
        )
    if includes_freqs or includes_ts:
        params.update(
            {
                "temp": temperature,
                "press": pressure,
            }
        )
    if includes_ts:
        params.update(
            {
                "ts_displacement": ts_displacement,
                "ts_dimer_separation": ts_dimer_separation,
                "ts_trial_step": ts_trial_step,
                "ts_max_step": ts_max_step,
                "ts_max_rot": ts_max_rot,
            }
        )
    return params


def _build_md_use_params(
    *,
    md_options: Dict[str, Any],
    charge: int,
    spin: int,
) -> Dict[str, Any]:
    engine = (md_options.get("engine") or "langevin").lower()
    use_pbc = bool(md_options.get("use_pbc"))
    cell_parameters = md_options.get("cell_parameters") or []
    use_input_velocities = bool(md_options.get("use_input_velocities"))
    input_velocities = md_options.get("input_velocities")
    checkpoint = md_options.get("mlff_checkpoint")
    params: Dict[str, Any] = {
        "run_type": ["md"],
        "run_type_requested": ["md"],
        "chg": charge,
        "spin": spin,
        "checkpoint": checkpoint,
        "mlff_task": md_options.get("mlff_task"),
        "method": USE_METHOD_NAME,
        "cpu_only": False,
        "device": "auto",
        "md_engine": engine,
        "md_steps": md_options.get("steps"),
        "md_timestep_fs": md_options.get("timestep_fs"),
        "md_traj_interval": md_options.get("traj_interval"),
        "md_log_interval": md_options.get("log_interval"),
    }
    if engine != "velocity_verlet" or not use_input_velocities:
        params["md_temperature"] = md_options.get("temperature")
    if engine == "langevin":
        params["md_friction"] = md_options.get("friction")
    if engine in {"nvt_berendsen", "npt_berendsen"}:
        params["md_relax_fs"] = md_options.get("relax_fs")
    if engine in {"npt_berendsen", "isotropic_mtk_npt", "mtk_npt", "langevin_baoab_npt", "melchionna_npt"}:
        params["md_pressure_bar"] = md_options.get("pressure_bar")
    if engine == "npt_berendsen":
        params["md_barostat_relax_fs"] = md_options.get("barostat_relax_fs")
        params["md_compressibility_bar"] = md_options.get("compressibility_bar")
    if engine == "nose_hoover_chain":
        params["md_nose_tdamp_fs"] = md_options.get("nose_tdamp_fs")
        params["md_nose_tchain"] = md_options.get("nose_tchain")
        params["md_nose_tloop"] = md_options.get("nose_tloop")
    if engine == "bussi":
        params["md_bussi_taut_fs"] = md_options.get("bussi_taut_fs")
    if engine == "andersen":
        params["md_andersen_prob"] = md_options.get("andersen_prob")
    if engine in {"isotropic_mtk_npt", "mtk_npt"}:
        params["md_mtk_tdamp_fs"] = md_options.get("mtk_tdamp_fs")
        params["md_mtk_pdamp_fs"] = md_options.get("mtk_pdamp_fs")
        params["md_mtk_tchain"] = md_options.get("mtk_tchain")
        params["md_mtk_pchain"] = md_options.get("mtk_pchain")
        params["md_mtk_tloop"] = md_options.get("mtk_tloop")
        params["md_mtk_ploop"] = md_options.get("mtk_ploop")
    if engine in {"langevin_baoab_nvt", "langevin_baoab_npt"}:
        params["md_baoab_ttau_fs"] = md_options.get("baoab_ttau_fs")
        if engine == "langevin_baoab_npt":
            params["md_baoab_ptau_fs"] = md_options.get("baoab_ptau_fs")
            params["md_baoab_pmass"] = md_options.get("baoab_pmass")
            params["md_baoab_pmass_factor"] = md_options.get("baoab_pmass_factor")
            params["md_baoab_hydrostatic"] = md_options.get("baoab_hydrostatic")
    if engine == "melchionna_npt":
        params["md_melchionna_ttime_fs"] = md_options.get("melchionna_ttime_fs")
        params["md_melchionna_ptime_fs"] = md_options.get("melchionna_ptime_fs")
        params["md_melchionna_bulk_gpa"] = md_options.get("melchionna_bulk_gpa")
        params["md_melchionna_mask"] = md_options.get("melchionna_mask")
    if use_pbc and len(cell_parameters) == 6:
        params["md_use_pbc"] = True
        params["md_cell_a"] = cell_parameters[0]
        params["md_cell_b"] = cell_parameters[1]
        params["md_cell_c"] = cell_parameters[2]
        params["md_cell_alpha"] = cell_parameters[3]
        params["md_cell_beta"] = cell_parameters[4]
        params["md_cell_gamma"] = cell_parameters[5]
    if use_input_velocities:
        params["md_use_input_velocities"] = True
        params["md_velocities"] = input_velocities
    return params


def _ensure_queue_worker() -> None:
    global JOB_QUEUE_WORKER
    if JOB_QUEUE_WORKER and JOB_QUEUE_WORKER.is_alive():
        return
    worker = threading.Thread(target=_queue_worker, name="uma-ase-job-queue", daemon=True)
    JOB_QUEUE_WORKER = worker
    worker.start()


def _queue_worker() -> None:
    while True:
        with JOB_QUEUE_COND:
            while not JOB_QUEUE:
                JOB_QUEUE_COND.wait()
            queued = JOB_QUEUE.popleft()
        record = queued.record
        cancel_event = record.cancel_event
        if cancel_event is not None and cancel_event.is_set():
            with JOB_LOCK:
                record.status = "cancelled"
                record.message = "Job cancelled by user."
                record.finished_at = _utcnow()
            continue
        with JOB_LOCK:
            record.status = "running"
            record.started_at = _utcnow()
            record.message = None
        _persist_job(record)
        try:
            queued.func(*queued.args, **queued.kwargs)
        finally:
            with JOB_LOCK:
                record.finished_at = _utcnow()
            _persist_job(record)
            _enforce_job_limits()


def _enqueue_job(record: JobRecord, func: Callable[..., None], *args: Any, **kwargs: Any) -> int:
    with JOB_LOCK:
        record.status = "queued"
        record.queued_at = _utcnow()
        record.started_at = None
        record.finished_at = None
        record.message = None
    _persist_job(record)
    queued = QueuedJob(record=record, func=func, args=args, kwargs=kwargs)
    with JOB_QUEUE_COND:
        JOB_QUEUE.append(queued)
        position = len(JOB_QUEUE)
        JOB_QUEUE_COND.notify()
    return position


def _queue_positions() -> Dict[str, int]:
    with JOB_QUEUE_COND:
        return {queued.record.job_id: idx + 1 for idx, queued in enumerate(JOB_QUEUE)}


def _remove_from_queue(job_id: str) -> bool:
    with JOB_QUEUE_COND:
        for queued in list(JOB_QUEUE):
            if queued.record.job_id == job_id:
                JOB_QUEUE.remove(queued)
                return True
    return False


def _get_client_id() -> Optional[str]:
    header_value = request.headers.get("X-UMA-Client")
    form_value = request.form.get("client_id") if request.form else None
    args_value = request.args.get("client_id") if request.args else None
    client_id = (header_value or form_value or args_value or "").strip()
    return client_id or None


def _format_time(value: Optional[datetime]) -> Optional[str]:
    if value is None:
        return None
    if value.tzinfo is None:
        value = value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


def _ensure_utc_datetime(value: Optional[datetime]) -> Optional[datetime]:
    if value is None:
        return None
    if value.tzinfo is None:
        return value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)


def _job_created_sort_key(record: JobRecord, fallback: Optional[datetime] = None) -> float:
    created_at = _ensure_utc_datetime(record.created_at) or _ensure_utc_datetime(fallback) or _utcnow()
    return created_at.timestamp()


def _job_elapsed_seconds(record: JobRecord) -> Optional[int]:
    started_at = _ensure_utc_datetime(record.started_at)
    if started_at is None:
        return None
    end_time = _ensure_utc_datetime(record.finished_at) or _utcnow()
    return int((end_time - started_at).total_seconds())


def _job_queue_seconds(record: JobRecord) -> Optional[int]:
    queued_at = _ensure_utc_datetime(record.queued_at)
    if queued_at is None:
        return None
    return int((_utcnow() - queued_at).total_seconds())


def _parse_job_limit(value: Any) -> Optional[int]:
    try:
        parsed = int(value)
    except (TypeError, ValueError):
        return None
    if parsed <= 0:
        return None
    return parsed


def _parse_job_byte_limit(value: Any) -> Optional[int]:
    return _parse_byte_limit(value)


def _job_store_path() -> Path:
    return Path(app.config["UMA_RESULTS_DIR"]) / "jobs.jsonl"


def _parse_time(value: Optional[str]) -> Optional[datetime]:
    if not value:
        return None
    raw = value.strip()
    if raw.endswith("Z"):
        raw = raw[:-1] + "+00:00"
    try:
        parsed = datetime.fromisoformat(raw)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    return parsed


def _job_to_dict(record: JobRecord) -> Dict[str, Any]:
    return {
        "job_id": record.job_id,
        "client_id": record.client_id,
        "job_name": record.job_name,
        "job_source": record.job_source,
        "job_kind": record.job_kind,
        "run_types": record.run_types,
        "status": record.status,
        "message": record.message,
        "created_at": _format_time(record.created_at),
        "queued_at": _format_time(record.queued_at),
        "started_at": _format_time(record.started_at),
        "finished_at": _format_time(record.finished_at),
        "charge": record.charge,
        "spin": record.spin,
        "grad": record.grad,
        "iterations": record.iterations,
        "relative_path": str(record.relative_path) if record.relative_path else None,
        "job_dir": str(record.job_dir) if record.job_dir else None,
        "input_filename": record.input_filename,
        "log_path": str(record.log_path) if record.log_path else None,
        "traj_path": str(record.traj_path) if record.traj_path else None,
        "opt_path": str(record.opt_path) if record.opt_path else None,
        "freq_archive_path": str(record.freq_archive_path) if record.freq_archive_path else None,
        "freq_folder": str(record.freq_folder) if record.freq_folder else None,
        "cml_path": str(record.cml_path) if record.cml_path else None,
        "traj_visual_path": str(record.traj_visual_path) if record.traj_visual_path else None,
        "md_multi_xyz": str(record.md_multi_xyz) if record.md_multi_xyz else None,
        "deleted": record.deleted,
        "updated_at": _format_time(_utcnow()),
    }


def _job_from_dict(payload: Dict[str, Any]) -> Optional[JobRecord]:
    job_id = payload.get("job_id")
    job_dir_raw = payload.get("job_dir")
    if not job_id:
        return None
    if job_dir_raw:
        job_dir = Path(str(job_dir_raw))
    else:
        job_dir = Path(app.config["UMA_RESULTS_DIR"]) / str(job_id)
    try:
        charge_val = int(payload.get("charge", 0))
    except (TypeError, ValueError):
        charge_val = 0
    try:
        spin_val = int(payload.get("spin", 1))
    except (TypeError, ValueError):
        spin_val = 1
    try:
        grad_val = float(payload.get("grad", 0.0))
    except (TypeError, ValueError):
        grad_val = 0.0
    try:
        iter_val = int(payload.get("iterations", 0))
    except (TypeError, ValueError):
        iter_val = 0
    run_types = payload.get("run_types") or []
    if not isinstance(run_types, list):
        run_types = [str(run_types)]
    record = JobRecord(
        job_id=str(job_id),
        job_dir=job_dir,
        charge=charge_val,
        spin=spin_val,
        grad=grad_val,
        iterations=iter_val,
        run_types=[str(item) for item in run_types],
        status=str(payload.get("status") or "unknown"),
        message=payload.get("message"),
        job_name=payload.get("job_name"),
        job_source=payload.get("job_source"),
        job_kind=str(payload.get("job_kind") or "workflow"),
        client_id=payload.get("client_id"),
        created_at=_parse_time(payload.get("created_at")) or _utcnow(),
        queued_at=_parse_time(payload.get("queued_at")),
        started_at=_parse_time(payload.get("started_at")),
        finished_at=_parse_time(payload.get("finished_at")),
        deleted=bool(payload.get("deleted", False)),
    )
    record.created_at = _ensure_utc_datetime(record.created_at) or _utcnow()
    record.queued_at = _ensure_utc_datetime(record.queued_at)
    record.started_at = _ensure_utc_datetime(record.started_at)
    record.finished_at = _ensure_utc_datetime(record.finished_at)
    relative_path = payload.get("relative_path")
    if relative_path:
        record.relative_path = Path(str(relative_path))
    log_path = payload.get("log_path")
    if log_path:
        record.log_path = Path(str(log_path))
    input_filename = payload.get("input_filename")
    if input_filename:
        record.input_filename = str(input_filename)
    traj_path = payload.get("traj_path")
    if traj_path:
        record.traj_path = Path(str(traj_path))
    opt_path = payload.get("opt_path")
    if opt_path:
        record.opt_path = Path(str(opt_path))
    freq_archive_path = payload.get("freq_archive_path")
    if freq_archive_path:
        record.freq_archive_path = Path(str(freq_archive_path))
    freq_folder = payload.get("freq_folder")
    if freq_folder:
        record.freq_folder = Path(str(freq_folder))
    cml_path = payload.get("cml_path")
    if cml_path:
        record.cml_path = Path(str(cml_path))
    traj_visual_path = payload.get("traj_visual_path")
    if traj_visual_path:
        record.traj_visual_path = Path(str(traj_visual_path))
    md_multi_xyz = payload.get("md_multi_xyz")
    if md_multi_xyz:
        record.md_multi_xyz = Path(str(md_multi_xyz))
    return record


def _persist_job(record: JobRecord) -> None:
    store_path = _job_store_path()
    store_path.parent.mkdir(parents=True, exist_ok=True)
    data = _job_to_dict(record)
    line = json.dumps(data, ensure_ascii=True)
    try:
        with JOB_STORE_LOCK:
            with store_path.open("a", encoding="utf-8") as handle:
                handle.write(line + "\n")
    except OSError as exc:
        app.logger.warning("Unable to persist job %s: %s", record.job_id, exc)


def _job_dir_size(job_dir: Path) -> int:
    total = 0
    try:
        for root, _, files in os.walk(job_dir):
            for filename in files:
                try:
                    total += (Path(root) / filename).stat().st_size
                except OSError:
                    continue
    except OSError:
        return total
    return total


def _purge_job(record: JobRecord, reason: str) -> None:
    if record.status in {"running", "queued"}:
        return
    job_dir = record.job_dir
    if job_dir and job_dir.exists():
        shutil.rmtree(job_dir, ignore_errors=True)
    record.deleted = True
    record.status = "deleted"
    record.message = reason
    record.finished_at = record.finished_at or _utcnow()
    _persist_job(record)
    with JOB_LOCK:
        JOBS.pop(record.job_id, None)


def _enforce_job_limits() -> None:
    retention_days = _parse_job_limit(app.config.get("UMA_JOBS_RETENTION_DAYS"))
    max_count = _parse_job_limit(app.config.get("UMA_JOBS_MAX_COUNT"))
    max_bytes = _parse_job_byte_limit(app.config.get("UMA_JOBS_MAX_BYTES"))
    now = _utcnow()
    with JOB_LOCK:
        records = [record for record in JOBS.values() if not record.deleted]
    records.sort(key=lambda item: _job_created_sort_key(item, now))

    expired = []
    compact_needed = False
    if retention_days:
        cutoff = now.timestamp() - (retention_days * 24 * 3600)
        for record in records:
            if record.created_at and record.created_at.timestamp() < cutoff:
                expired.append(record)
    for record in expired:
        _purge_job(record, "Purged by retention policy.")
        compact_needed = True

    if max_count:
        with JOB_LOCK:
            remaining = [record for record in JOBS.values() if not record.deleted]
        remaining.sort(key=lambda item: _job_created_sort_key(item, now))
        if len(remaining) > max_count:
            for record in remaining[: len(remaining) - max_count]:
                _purge_job(record, "Purged to enforce job count limit.")
                compact_needed = True

    if max_bytes:
        with JOB_LOCK:
            remaining = [record for record in JOBS.values() if not record.deleted]
        remaining.sort(key=lambda item: _job_created_sort_key(item, now))
        total_bytes = sum(_job_dir_size(record.job_dir) for record in remaining if record.job_dir)
        while remaining and total_bytes > max_bytes:
            record = remaining.pop(0)
            total_bytes -= _job_dir_size(record.job_dir)
            _purge_job(record, "Purged to enforce storage size limit.")
            compact_needed = True

    if compact_needed:
        store_path = _job_store_path()
        store_path.parent.mkdir(parents=True, exist_ok=True)
        with JOB_LOCK:
            records = [record for record in JOBS.values() if not record.deleted]
        records.sort(key=lambda item: _job_created_sort_key(item, now))
        with JOB_STORE_LOCK:
            with store_path.open("w", encoding="utf-8") as handle:
                for record in records:
                    handle.write(json.dumps(_job_to_dict(record), ensure_ascii=True) + "\n")


def _load_job_store() -> None:
    store_path = _job_store_path()
    if not store_path.exists():
        return
    latest: Dict[str, Dict[str, Any]] = {}
    try:
        with store_path.open("r", encoding="utf-8") as handle:
            for line in handle:
                stripped = line.strip()
                if not stripped:
                    continue
                try:
                    payload = json.loads(stripped)
                except json.JSONDecodeError:
                    continue
                job_id = payload.get("job_id")
                if not job_id:
                    continue
                latest[str(job_id)] = payload
    except OSError:
        return

    interrupted = []
    for payload in latest.values():
        record = _job_from_dict(payload)
        if record is None:
            continue
        if record.deleted:
            continue
        if record.status in {"running", "queued"}:
            record.status = "interrupted"
            record.message = "Server restarted before completion."
            record.finished_at = _utcnow()
            interrupted.append(record)
        with JOB_LOCK:
            JOBS[record.job_id] = record
    for record in interrupted:
        _persist_job(record)
    _enforce_job_limits()

    with JOB_LOCK:
        records = [record for record in JOBS.values() if not record.deleted]
    records.sort(key=lambda item: item.created_at or _utcnow())
    with JOB_STORE_LOCK:
        with store_path.open("w", encoding="utf-8") as handle:
            for record in records:
                handle.write(json.dumps(_job_to_dict(record), ensure_ascii=True) + "\n")


def _build_cli_args(
    input_path: Path,
    run_types: Iterable[str],
    charge: str,
    spin: str,
    optimizer: str,
    grad: str,
    iterations: str,
    temperature: str,
    pressure: str,
    mlff_checkpoint: str | None,
    mlff_task: str | None,
    ts_displacement: str | None = None,
    ts_dimer_separation: str | None = None,
    ts_trial_step: str | None = None,
    ts_max_step: str | None = None,
    ts_max_rot: str | None = None,
) -> List[str]:
    args: List[str] = [
        "-input",
        str(input_path),
        "-chg",
        charge,
        "-spin",
        spin,
        "-optimizer",
        optimizer,
        "-grad",
        grad,
        "-iter",
        iterations,
        "-temp",
        temperature,
        "-press",
        pressure,
    ]
    if run_types:
        args.extend(["-run-type", *run_types])
    if mlff_checkpoint:
        args.extend(["-mlff-chk", mlff_checkpoint])
    if mlff_task:
        args.extend(["-mlff-task", mlff_task])
    ts_requested = any(rt == "ts" for rt in run_types or [])
    if ts_requested:
        if ts_displacement:
            args.extend(["--ts-displacement", ts_displacement])
        if ts_dimer_separation:
            args.extend(["--ts-dimer-separation", ts_dimer_separation])
        if ts_trial_step:
            args.extend(["--ts-trial-step", ts_trial_step])
        if ts_max_step:
            args.extend(["--ts-max-step", ts_max_step])
        if ts_max_rot:
            args.extend(["--ts-max-rot", ts_max_rot])
    return args


def _initialise_md_velocities(atoms, temperature: float, logger):
    if temperature <= 0:
        logger.info("Temperature %.2f K <= 0. Skipping velocity initialisation.", temperature)
        return
    MaxwellBoltzmannDistribution(atoms, temperature_K=temperature)
    Stationary(atoms)
    ZeroRotation(atoms)
    logger.info("Initial velocities sampled at %.2f K.", temperature)


def _create_md_dynamics(engine: str, atoms, *, md_options: Dict[str, Any]):
    params = _build_md_builder_params(md_options)
    builder = MD_ENGINE_BUILDERS.get(engine)
    if builder is None:
        raise ValueError(
            "Unsupported MD engine "
            f"'{engine}'. Available: {', '.join(MD_ENGINE_LABELS)}"
        )
    return builder(atoms, params)


def _log_md_metadata(
    logger: logging.Logger,
    *,
    record: JobRecord,
    context: AtomContext,
    md_options: Dict[str, Any],
    engine: str,
    use_pbc: bool,
    cell_parameters: Optional[List[float]],
) -> None:
    def add_value(metadata: Dict[str, Any], key: str, value: Any) -> None:
        if value is None:
            return
        metadata[key] = value

    checkpoint = md_options.get("mlff_checkpoint")
    task = md_options.get("mlff_task")

    metadata: Dict[str, Any] = {
        "run_type": "md",
        "engine": engine,
        "steps": md_options.get("steps"),
        "timestep_fs": md_options.get("timestep_fs"),
        "traj_interval": md_options.get("traj_interval"),
        "log_interval": md_options.get("log_interval"),
        "checkpoint": checkpoint,
        "method": "MLIP-UMA",
        "task": task,
        "device": context.device,
        "charge": record.charge,
        "spin": record.spin,
        "use_pbc": use_pbc,
    }
    use_input_velocities = bool(md_options.get("use_input_velocities"))
    if engine != "velocity_verlet" or not use_input_velocities:
        add_value(metadata, "temperature", md_options.get("temperature"))
    if use_input_velocities:
        add_value(metadata, "restart", True)

    if use_pbc and cell_parameters and len(cell_parameters) == 6:
        add_value(metadata, "cell_a", cell_parameters[0])
        add_value(metadata, "cell_b", cell_parameters[1])
        add_value(metadata, "cell_c", cell_parameters[2])
        add_value(metadata, "cell_alpha", cell_parameters[3])
        add_value(metadata, "cell_beta", cell_parameters[4])
        add_value(metadata, "cell_gamma", cell_parameters[5])

    if engine == "langevin":
        add_value(metadata, "friction", md_options.get("friction"))

    if engine in {"nvt_berendsen", "npt_berendsen"}:
        add_value(metadata, "relax_fs", md_options.get("relax_fs"))

    if engine in {"npt_berendsen", "isotropic_mtk_npt", "mtk_npt", "langevin_baoab_npt", "melchionna_npt"}:
        pressure_bar = md_options.get("pressure_bar")
        add_value(metadata, "pressure_bar", pressure_bar)

    if engine == "npt_berendsen":
        add_value(metadata, "barostat_relax_fs", md_options.get("barostat_relax_fs"))
        add_value(metadata, "compressibility_bar", md_options.get("compressibility_bar"))

    if engine == "nose_hoover_chain":
        add_value(metadata, "nose_tdamp_fs", md_options.get("nose_tdamp_fs"))
        add_value(metadata, "nose_tchain", md_options.get("nose_tchain"))
        add_value(metadata, "nose_tloop", md_options.get("nose_tloop"))

    if engine == "bussi":
        add_value(metadata, "bussi_taut_fs", md_options.get("bussi_taut_fs"))

    if engine == "andersen":
        add_value(metadata, "andersen_prob", md_options.get("andersen_prob"))

    if engine in {"isotropic_mtk_npt", "mtk_npt"}:
        add_value(metadata, "mtk_tdamp_fs", md_options.get("mtk_tdamp_fs"))
        add_value(metadata, "mtk_pdamp_fs", md_options.get("mtk_pdamp_fs"))
        add_value(metadata, "mtk_tchain", md_options.get("mtk_tchain"))
        add_value(metadata, "mtk_pchain", md_options.get("mtk_pchain"))
        add_value(metadata, "mtk_tloop", md_options.get("mtk_tloop"))
        add_value(metadata, "mtk_ploop", md_options.get("mtk_ploop"))

    if engine in {"langevin_baoab_nvt", "langevin_baoab_npt"}:
        add_value(metadata, "baoab_ttau_fs", md_options.get("baoab_ttau_fs"))
        if engine == "langevin_baoab_npt":
            add_value(metadata, "baoab_ptau_fs", md_options.get("baoab_ptau_fs"))
            add_value(metadata, "baoab_pmass", md_options.get("baoab_pmass"))
            add_value(metadata, "baoab_pmass_factor", md_options.get("baoab_pmass_factor"))
            add_value(metadata, "baoab_hydrostatic", md_options.get("baoab_hydrostatic"))

    if engine == "melchionna_npt":
        add_value(metadata, "melchionna_ttime_fs", md_options.get("melchionna_ttime_fs"))
        add_value(metadata, "melchionna_ptime_fs", md_options.get("melchionna_ptime_fs"))
        add_value(metadata, "melchionna_bulk_gpa", md_options.get("melchionna_bulk_gpa"))
        add_value(metadata, "melchionna_mask", md_options.get("melchionna_mask"))

    logger.info("------------------------------------------------------------------")
    logger.info("Run metadata (md)")
    logger.info("Metadata:")
    ordered_keys = [
        "run_type",
        "engine",
        "steps",
        "timestep_fs",
        "temperature",
        "traj_interval",
        "log_interval",
        "checkpoint",
        "method",
        "task",
        "charge",
        "spin",
        "device",
        "use_pbc",
        "restart",
        "cell_a",
        "cell_b",
        "cell_c",
        "cell_alpha",
        "cell_beta",
        "cell_gamma",
        "pressure_bar",
        "compressibility_bar",
        "barostat_relax_fs",
        "relax_fs",
        "friction",
        "nose_tdamp_fs",
        "nose_tchain",
        "nose_tloop",
        "bussi_taut_fs",
        "andersen_prob",
        "mtk_tdamp_fs",
        "mtk_pdamp_fs",
        "mtk_tchain",
        "mtk_pchain",
        "mtk_tloop",
        "mtk_ploop",
        "baoab_ttau_fs",
        "baoab_ptau_fs",
        "baoab_pmass",
        "baoab_pmass_factor",
        "baoab_hydrostatic",
        "melchionna_ttime_fs",
        "melchionna_ptime_fs",
        "melchionna_bulk_gpa",
        "melchionna_mask",
    ]
    for key in ordered_keys:
        if key in metadata:
            logger.info("  %-18s: %s", key, metadata[key])
    for key in sorted(metadata):
        if key in ordered_keys:
            continue
        logger.info("  %-18s: %s", key, metadata[key])
    logger.info("")


def _collect_log(temp_dir: Path) -> str:
    logs = sorted(temp_dir.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
    if not logs:
        return "No log file generated."
    return logs[0].read_text(encoding="utf-8", errors="replace")


def _safe_save_upload(storage, base_dir: Path) -> Path:
    filename = storage.filename or getattr(storage, "name", None)
    if not filename:
        raise ValueError("Uploaded file missing name.")
    relative_parts = [secure_filename(part) for part in Path(filename).parts if part not in ("", ".", "..")]
    if not relative_parts:
        relative_parts = [secure_filename(filename)]
    destination = base_dir.joinpath(*relative_parts)
    destination.parent.mkdir(parents=True, exist_ok=True)
    storage.save(destination)
    return destination


def _build_analyze_url(token: str, path: Path | None) -> str | None:
    if not path:
        return None
    return f"/api/uma-ase/analyze/{token}/{path.name}"


def _sanitize_relative_path(relpath: str | None) -> Path | None:
    if not relpath:
        return None
    parts = [
        secure_filename(part)
        for part in Path(relpath).parts
        if part not in ("", ".", "..")
    ]
    cleaned = [part for part in parts if part]
    if not cleaned:
        return None
    return Path(*cleaned)


def _display_label_for_upload(raw_name: str | None, saved_path: Path) -> str:
    sanitized = _sanitize_relative_path(raw_name)
    if sanitized:
        return sanitized.as_posix()
    if raw_name:
        simplified = secure_filename(Path(raw_name).name)
        if simplified:
            return simplified
    return saved_path.name


def _extract_zip_members(zip_path: Path, base_label: str) -> List[Tuple[Path, str]]:
    extract_root = zip_path.parent / f"{zip_path.stem}_unzipped"
    if extract_root.exists():
        shutil.rmtree(extract_root, ignore_errors=True)
    extract_root.mkdir(parents=True, exist_ok=True)
    extracted: List[Tuple[Path, str]] = []
    try:
        with zipfile.ZipFile(zip_path) as archive:
            for info in archive.infolist():
                if info.is_dir():
                    continue
                sanitized = _sanitize_relative_path(info.filename)
                if not sanitized:
                    continue
                destination = extract_root / sanitized
                destination.parent.mkdir(parents=True, exist_ok=True)
                with archive.open(info) as source, destination.open("wb") as target:
                    shutil.copyfileobj(source, target)
                rel_label = sanitized.as_posix()
                label = f"{base_label}/{rel_label}" if rel_label else base_label
                extracted.append((destination, label))
    except zipfile.BadZipFile as exc:
        raise ValueError(f"{zip_path.name} is not a valid ZIP archive.") from exc
    if not extracted:
        raise ValueError(f"{zip_path.name} did not contain any files.")
    return extracted


def _expand_geometry_inputs(files: List[Tuple[Path, str]]) -> Tuple[List[Tuple[Path, str]], List[Dict[str, str]]]:
    candidates: List[Tuple[Path, str]] = []
    skipped: List[Dict[str, str]] = []
    for path, label in files:
        if path.suffix.lower() == ".zip":
            try:
                extracted = _extract_zip_members(path, label)
            except ValueError as exc:
                skipped.append({"label": label, "reason": str(exc)})
                continue
            candidates.extend(extracted)
        else:
            candidates.append((path, label))
    return candidates, skipped


def _load_first_structure(path: Path):
    try:
        atoms = read(path)
        if atoms is not None:
            return atoms
    except Exception:
        pass
    with suppress(Exception):
        frames = read(path, ":")
        if isinstance(frames, list) and frames:
            return frames[0]
    if path.suffix.lower() == ".traj":
        with suppress(Exception):
            with Trajectory(path) as traj:
                if len(traj):
                    return traj[0]
    return None


def _pair_label(symbol_a: str, symbol_b: str) -> str:
    first = (symbol_a or "X").strip() or "X"
    second = (symbol_b or "X").strip() or "X"
    ordered = sorted((first, second))
    return f"{ordered[0]}-{ordered[1]}"


def _build_simple_geometry_summary(
    structures: List[Dict[str, Any]],
    pair_series: List[Dict[str, Any]],
    total_pairs: int,
    skipped: List[Dict[str, str]],
    *,
    include_matrix: bool = False,
) -> str:
    timestamp = _format_time(_utcnow().replace(microsecond=0))
    lines: List[str] = [
        "Simple Geometry Analysis Report",
        f"Generated at {timestamp} UTC",
        "",
        f"Structures processed: {len(structures)}",
        f"Unique pair types: {len(pair_series)}",
        f"Total interatomic pairs: {total_pairs}",
        "",
    ]
    if structures:
        lines.append("Structures")
        lines.append("----------")
        for index, entry in enumerate(structures, start=1):
            label = entry.get("label") or f"Structure {index}"
            formula = entry.get("formula") or "Unknown formula"
            atom_count = entry.get("atom_count") or "-"
            lines.append(f"{index}. {label} — {formula} ({atom_count} atoms)")
            if include_matrix:
                matrix = entry.get("distance_matrix") or []
                if matrix:
                    lines.append("   Distance matrix (Å):")
                    for row in matrix:
                        if not isinstance(row, list):
                            continue
                        values = [
                            f"{value:.4f}" if isinstance(value, (int, float)) else "-"
                            for value in row
                        ]
                        lines.append(f"   {'  '.join(values)}")
                else:
                    lines.append("   Distance matrix (Å): not available")
        lines.append("")
    if pair_series:
        lines.append("Pair Distance Statistics (Å)")
        lines.append("-----------------------------")
        for entry in pair_series:
            label = entry.get("label") or "Pair"
            count = entry.get("count") or 0
            minimum = entry.get("min")
            maximum = entry.get("max")
            mean_value = entry.get("mean")
            min_text = f"{minimum:.4f}" if isinstance(minimum, (int, float)) else "-"
            max_text = f"{maximum:.4f}" if isinstance(maximum, (int, float)) else "-"
            mean_text = f"{mean_value:.4f}" if isinstance(mean_value, (int, float)) else "-"
            lines.append(f"- {label}: count={count}, min={min_text}, max={max_text}, avg={mean_text}")
        lines.append("")
    if skipped:
        lines.append("Skipped files")
        lines.append("-------------")
        for entry in skipped:
            label = entry.get("label") or "Unknown file"
            reason = entry.get("reason") or "Unknown reason"
            lines.append(f"- {label}: {reason}")
        lines.append("")
    if not pair_series:
        lines.append("No interatomic distance pairs were detected.")
    lines.append("")
    lines.append("End of report.")
    return "\n".join(lines)


def _format_metric(value: Any, digits: int = 3) -> str:
    try:
        numeric = float(value)
    except (TypeError, ValueError):
        return "—"
    return f"{numeric:.{digits}f}"


def _build_rmsd_report_html(
    header: List[str],
    rows: List[List[str]],
    stats: Dict[str, Any],
    column_stats: Dict[str, Dict[str, Any]],
) -> str:
    def cell(text: Any) -> str:
        return escape(str(text)) if text is not None else "—"

    summary_rows = [
        ("Count", stats.get("count")),
        ("Mean (Å)", _format_metric(stats.get("mean"), 3)),
        ("Median (Å)", _format_metric(stats.get("median"), 3)),
        ("Std dev (Å)", _format_metric(stats.get("std_dev"), 3)),
        ("Min (Å)", _format_metric(stats.get("min"), 3)),
        ("Max (Å)", _format_metric(stats.get("max"), 3)),
        ("Best entry", stats.get("best_entry") or "—"),
        ("Worst entry", stats.get("worst_entry") or "—"),
    ]
    summary_table = "".join(
        f"<tr><th>{cell(label)}</th><td>{cell(value)}</td></tr>" for label, value in summary_rows
    )

    checkpoint_rows = []
    for checkpoint, info in (column_stats or {}).items():
        checkpoint_rows.append(
            "<tr>"
            f"<td>{cell(checkpoint)}</td>"
            f"<td>{_format_metric(info.get('mean'), 3)}</td>"
            f"<td>{_format_metric(info.get('min'), 3)}</td>"
            f"<td>{_format_metric(info.get('max'), 3)}</td>"
            f"<td>{cell(info.get('count'))}</td>"
            "</tr>"
        )
    checkpoint_table = (
        "<table><thead><tr>"
        "<th>Checkpoint | Optimizer</th><th>Mean (Å)</th><th>Min (Å)</th><th>Max (Å)</th><th>Count</th>"
        "</tr></thead><tbody>"
        + "".join(checkpoint_rows)
        + "</tbody></table>"
        if checkpoint_rows
        else "<p>No checkpoint statistics available.</p>"
    )

    table_rows = []
    for row in rows:
        cells = "".join(f"<td>{cell(value)}</td>" for value in row)
        table_rows.append(f"<tr>{cells}</tr>")
    rmsd_table = (
        "<table><thead><tr>"
        + "".join(f"<th>{cell(col)}</th>" for col in header)
        + "</tr></thead><tbody>"
        + "".join(table_rows)
        + "</tbody></table>"
        if header and rows
        else "<p>No RMSD entries available.</p>"
    )

    return (
        '<div class="rmsd-html-report">'
        '<div class="analysis-plot-block">'
        '<h5>RMSD Summary Plot</h5>'
        '<img class="analysis-plot-image rmsd-plot-image" alt="RMSD summary plot" />'
        "</div>"
        "<div>"
        "<h5>Overall statistics</h5>"
        f"<table>{summary_table}</table>"
        "</div>"
        "<div>"
        "<h5>Checkpoint statistics</h5>"
        f"{checkpoint_table}"
        "</div>"
        "<div>"
        "<h5>RMSD table</h5>"
        f"{rmsd_table}"
        "</div>"
        "</div>"
    )


_DRIVER_RMSD_RE = re.compile(r"RMSD:\s*([+-]?\d+(?:\.\d+)?(?:[Ee][+-]?\d+)?)\s*Å")
_DRIVER_BONDS_RE = re.compile(r"Found\s+(\d+)\s+bonds\s+and\s+(\d+)\s+angles")
_DRIVER_DIST_RE = re.compile(r"Distance RMSD:\s*([+-]?\d+(?:\.\d+)?(?:[Ee][+-]?\d+)?)\s*Å")
_DRIVER_ANGLE_RE = re.compile(r"Angle RMSD:\s*([+-]?\d+(?:\.\d+)?(?:[Ee][+-]?\d+)?)\s*degrees")


def _parse_driver_rmsd(output: str) -> Optional[float]:
    if not output:
        return None
    match = _DRIVER_RMSD_RE.search(output)
    if not match:
        return None
    try:
        return float(match.group(1))
    except ValueError:
        return None


def _parse_driver_details(output: str) -> Dict[str, Any]:
    parsed: Dict[str, Any] = {}
    if not output:
        return parsed
    bonds_match = _DRIVER_BONDS_RE.search(output)
    if bonds_match:
        with suppress(ValueError):
            parsed["bonds"] = int(bonds_match.group(1))
        with suppress(ValueError):
            parsed["angles"] = int(bonds_match.group(2))
    dist_match = _DRIVER_DIST_RE.search(output)
    if dist_match:
        with suppress(ValueError):
            parsed["distance_rmsd"] = float(dist_match.group(1))
    angle_match = _DRIVER_ANGLE_RE.search(output)
    if angle_match:
        with suppress(ValueError):
            parsed["angle_rmsd"] = float(angle_match.group(1))
    return parsed


def _compute_driver_metric(values: List[Optional[float]]) -> Dict[str, Any]:
    numeric = [value for value in values if isinstance(value, (int, float))]
    if not numeric:
        return {"count": 0, "mean": None, "min": None, "max": None}
    total = float(sum(numeric))
    return {
        "count": len(numeric),
        "mean": total / len(numeric),
        "min": min(numeric),
        "max": max(numeric),
    }


def _build_driver_report_html(results: List[Dict[str, Any]]) -> str:
    total_pairs = len(results)
    rmsd_stats = _compute_driver_metric([entry.get("rmsd") for entry in results])
    dist_stats = _compute_driver_metric([entry.get("distance_rmsd") for entry in results])
    angle_stats = _compute_driver_metric([entry.get("angle_rmsd") for entry in results])
    summary_rows = [
        ("Pairs analyzed", total_pairs),
        ("RMSD mean (Å)", _format_metric(rmsd_stats.get("mean"), 3)),
        ("RMSD min (Å)", _format_metric(rmsd_stats.get("min"), 3)),
        ("RMSD max (Å)", _format_metric(rmsd_stats.get("max"), 3)),
        ("Distance RMSD mean (Å)", _format_metric(dist_stats.get("mean"), 3)),
        ("Distance RMSD min (Å)", _format_metric(dist_stats.get("min"), 3)),
        ("Distance RMSD max (Å)", _format_metric(dist_stats.get("max"), 3)),
        ("Angle RMSD mean (deg)", _format_metric(angle_stats.get("mean"), 2)),
        ("Angle RMSD min (deg)", _format_metric(angle_stats.get("min"), 2)),
        ("Angle RMSD max (deg)", _format_metric(angle_stats.get("max"), 2)),
    ]
    summary_table = "".join(
        f"<tr><th>{escape(str(label))}</th><td>{escape(str(value))}</td></tr>" for label, value in summary_rows
    )

    table_rows = []
    for entry in results:
        label = escape(entry.get("label") or "Pair")
        rmsd = _format_metric(entry.get("rmsd"), 3)
        dist = _format_metric(entry.get("distance_rmsd"), 3)
        angle = _format_metric(entry.get("angle_rmsd"), 2)
        bonds = entry.get("bonds")
        angles = entry.get("angles")
        charge = entry.get("charge")
        table_rows.append(
            "<tr>"
            f"<td>{label}</td>"
            f"<td>{rmsd}</td>"
            f"<td>{dist}</td>"
            f"<td>{angle}</td>"
            f"<td>{escape(str(charge)) if charge is not None else '—'}</td>"
            f"<td>{escape(str(bonds)) if bonds is not None else '—'}</td>"
            f"<td>{escape(str(angles)) if angles is not None else '—'}</td>"
            "</tr>"
        )
    pair_table = (
        "<table><thead><tr>"
        "<th>Pair</th><th>RMSD (Å)</th><th>Distance RMSD (Å)</th><th>Angle RMSD (deg)</th><th>Charge</th><th>Bonds</th><th>Angles</th>"
        "</tr></thead><tbody>"
        + "".join(table_rows)
        + "</tbody></table>"
        if table_rows
        else "<p>No pair metrics available.</p>"
    )

    return (
        '<div class="driver-html-report">'
        '<div class="analysis-plot-row">'
        '<div class="analysis-plot-card">'
        '<h5>XYZ Pair RMSD 3D Scatter</h5>'
        '<div class="analysis-3d-scatter" data-plot="driver-3d"></div>'
        '<p class="help-hint">Drag to rotate. Scroll to zoom. Axes: total RMSD, distance RMSD, angle RMSD.</p>'
        "</div>"
        '<div class="analysis-plot-card">'
        '<h5>RMSD vs Charge</h5>'
        '<div class="analysis-3d-scatter analysis-2d-scatter" data-plot="driver-charge"></div>'
        '<p class="help-hint">Charge read from XYZ comment when available.</p>'
        "</div>"
        "</div>"
        "<div>"
        "<h5>Summary</h5>"
        f"<table>{summary_table}</table>"
        "</div>"
        "<div>"
        "<h5>Pair metrics</h5>"
        f"{pair_table}"
        "</div>"
        "</div>"
    )


def _write_driver_plot(results: List[Dict[str, Any]], plot_path: Path) -> Optional[Path]:
    try:
        if not results:
            return None
        labels = [entry.get("label") or f"Pair {index + 1}" for index, entry in enumerate(results)]
        values_rmsd = [entry.get("rmsd") for entry in results]
        values_dist = [entry.get("distance_rmsd") for entry in results]
        values_angle = [entry.get("angle_rmsd") for entry in results]
        count = len(labels)
        use_index = count > 12
        x_labels = [str(index + 1) for index in range(count)] if use_index else labels
        x = range(count)
        fig, axes = plt.subplots(3, 1, figsize=(max(6.5, count * 0.5), 9), sharex=True)
        axes[0].bar(x, [v if v is not None else math.nan for v in values_rmsd], color="#0ea5a3")
        axes[0].set_ylabel("RMSD (Å)")
        axes[0].set_title("Overall RMSD per pair")

        axes[1].bar(x, [v if v is not None else math.nan for v in values_dist], color="#0ea5e9")
        axes[1].set_ylabel("Distance RMSD (Å)")
        axes[1].set_title("Distance RMSD per pair")

        axes[2].bar(x, [v if v is not None else math.nan for v in values_angle], color="#f97316")
        axes[2].set_ylabel("Angle RMSD (deg)")
        axes[2].set_title("Angle RMSD per pair")

        axes[2].set_xticks(list(x))
        axes[2].set_xticklabels(x_labels, rotation=40, ha="right")
        if use_index:
            axes[2].set_xlabel("Pair index (see table for names)")
        fig.tight_layout()
        fig.savefig(plot_path, dpi=150)
        plt.close(fig)
        return plot_path
    except Exception:
        if plot_path.exists():
            plot_path.unlink(missing_ok=True)  # type: ignore[arg-type]
        return None


def _freqs_ready(record: JobRecord) -> bool:
    if record.freq_archive_path and record.freq_archive_path.exists():
        return True
    folder = record.freq_folder
    if not folder:
        return False
    if folder.exists():
        for path in folder.rglob("*"):
            if path.is_file():
                return True
    return False


def _convert_traj_tree_to_xyz(root: Path) -> int:
    """Convert any *.traj files under *root* to multi-frame XYZ."""

    converted = 0
    for traj_file in root.rglob("*.traj"):
        xyz_path = traj_file.with_suffix(".xyz")
        try:
            frames = read(traj_file, ":")
            write(xyz_path, frames, format="xyz")
            traj_file.unlink()
            converted += 1
        except Exception as exc:  # pragma: no cover - best effort
            app.logger.warning("Unable to convert %s to XYZ: %s", traj_file, exc)
    return converted


_MINMODE_LINE = re.compile(
    r"MinModeTranslate:\s*(\d+)\s+\d+:\d+:\d+\s+([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?)"
)


def _extract_minmode_energies(log_path: Optional[Path]) -> Dict[int, float]:
    if log_path is None or not log_path.exists():
        return {}
    energies: Dict[int, float] = {}
    try:
        for line in log_path.read_text(encoding="utf-8", errors="ignore").splitlines():
            match = _MINMODE_LINE.search(line)
            if match:
                step = int(match.group(1))
                energy = float(match.group(2))
                energies[step] = energy
    except OSError:
        return {}
    return energies


def _resolve_job_traj_visual_source(record: JobRecord) -> Optional[Path]:
    if record.traj_visual_path and record.traj_visual_path.exists():
        return record.traj_visual_path
    if record.traj_path and record.traj_path.exists():
        visual_path = _export_traj_for_visualization(record.traj_path, log_path=record.log_path)
        if visual_path and visual_path.exists():
            record.traj_visual_path = visual_path
            return visual_path
    if record.md_multi_xyz and record.md_multi_xyz.exists():
        return record.md_multi_xyz
    return None


def _job_traj_download_url(record: JobRecord) -> Optional[str]:
    if record.traj_path and record.traj_path.exists():
        return f"/api/uma-ase/job/{record.job_id}/trajectory"
    return None


def _job_traj_frames_download_url(record: JobRecord) -> Optional[str]:
    if (
        (record.traj_visual_path and record.traj_visual_path.exists())
        or (record.traj_path and record.traj_path.exists())
        or (record.md_multi_xyz and record.md_multi_xyz.exists())
    ):
        return f"/api/uma-ase/job/{record.job_id}/trajectory_frames"
    return None


def _export_traj_for_visualization(source: Path, *, log_path: Optional[Path] = None) -> Optional[Path]:
    """Convert an ASE trajectory to multi-frame XYZ with metadata."""
    if source is None or not source.exists():
        return None
    destination = source.with_suffix(".xyz")
    try:
        frames = read(str(source), ":")
    except Exception as exc:
        app.logger.warning("Unable to read trajectory %s: %s", source, exc)
        return None

    if not frames:
        return None

    energy_by_step = _extract_minmode_energies(log_path)
    ordered_energy_items = sorted(energy_by_step.items())
    align_energy_by_order = bool(
        ordered_energy_items
        and len(ordered_energy_items) == len(frames)
        and sorted(energy_by_step) != list(range(len(frames)))
    )

    try:
        with destination.open("w", encoding="utf-8") as handle:
            for index, atoms in enumerate(frames):
                symbols = atoms.get_chemical_symbols()
                positions = atoms.get_positions()
                step_value = index
                energy = None
                if align_energy_by_order:
                    step_value, energy = ordered_energy_items[index]
                else:
                    energy = energy_by_step.get(index)
                comment_parts = [f"step={step_value}"]

                if energy is None:
                    calc = getattr(atoms, "calc", None)
                    if calc is not None:
                        energy = calc.results.get("energy") or calc.results.get("free_energy")
                    if energy is None:
                        energy = atoms.info.get("energy_ev")
                    if energy is None:
                        energy = atoms.info.get("energy")
                    if energy is None:
                        with suppress(Exception):
                            energy = atoms.get_potential_energy()
                if energy is not None:
                    with suppress(Exception):
                        comment_parts.append(f"energy_ev={float(energy):.8f}")

                if "time_fs" in atoms.info:
                    with suppress(Exception):
                        comment_parts.append(f"time_fs={float(atoms.info['time_fs']):.4f}")

                lattice_comment = _lattice_comment_from_atoms(atoms)
                if lattice_comment:
                    comment_parts.append(lattice_comment)

                existing = atoms.info.get("comment") or atoms.info.get("uma_comment")
                if existing:
                    comment_parts.append(str(existing).strip())

                comment_line = " ".join(part for part in comment_parts if part).strip()
                handle.write(f"{len(symbols)}\n")
                handle.write(f"{comment_line}\n")
                for symbol, (x_coord, y_coord, z_coord) in zip(symbols, positions):
                    handle.write(f"{symbol:2s} {x_coord: .10f} {y_coord: .10f} {z_coord: .10f}\n")
        return destination
    except Exception as exc:
        app.logger.warning("Unable to export %s to XYZ: %s", source, exc)
        return None


def _extract_xyz_frames(path: Path, start_index: int, limit: int) -> tuple[list[str], int]:
    """Read multi-frame XYZ data and return frame strings plus total count."""

    frames: list[str] = []
    total = 0

    if not path or not path.exists():
        return frames, total

    start_index = max(0, start_index)
    limit = max(1, limit)

    with path.open("r", encoding="utf-8", errors="ignore") as handle:
        while True:
            header_line = handle.readline()
            if not header_line:
                break
            stripped = header_line.strip()
            if not stripped:
                continue
            try:
                atom_count = int(stripped)
            except ValueError:
                continue
            comment_line = handle.readline()
            if comment_line == "":
                break
            atom_lines: list[str] = []
            incomplete = False
            for _ in range(atom_count):
                atom_line = handle.readline()
                if atom_line == "":
                    incomplete = True
                    break
                atom_lines.append(atom_line.rstrip("\n"))
            if incomplete:
                break
            if total >= start_index and len(frames) < limit:
                frame_text = "\n".join(
                    [
                        str(atom_count),
                        comment_line.rstrip("\n"),
                        *atom_lines,
                    ]
                )
                frames.append(frame_text)
            total += 1
    return frames, total


def _extract_frame_meta_from_comment(comment: str, fallback_step: int) -> Dict[str, float]:
    meta: Dict[str, float] = {"step": float(fallback_step)}
    if not comment:
        return meta
    for token in comment.strip().split():
        if "=" not in token:
            continue
        key, value = token.split("=", 1)
        try:
            numeric = float(value)
        except ValueError:
            continue
        key_norm = key.strip().lower()
        if key_norm == "step":
            meta["step"] = numeric
        elif key_norm in {"time", "time_fs"}:
            meta["time_fs"] = numeric
        elif key_norm in {"energy", "energy_ev"}:
            meta["energy_ev"] = numeric
    return meta


def _atoms_from_xyz_frame_text(frame_text: str) -> Tuple[Atoms, str]:
    lines = frame_text.replace("\r\n", "\n").splitlines()
    if len(lines) < 3:
        raise ValueError("XYZ frame is incomplete.")
    try:
        atom_count = int(lines[0].strip())
    except ValueError as exc:
        raise ValueError("XYZ frame header is invalid.") from exc
    comment = lines[1].strip() if len(lines) >= 2 else ""
    atom_lines = lines[2:2 + atom_count]
    if len(atom_lines) != atom_count:
        raise ValueError("XYZ frame atom count does not match payload.")

    symbols: List[str] = []
    positions: List[List[float]] = []
    for atom_line in atom_lines:
        parts = atom_line.strip().split()
        if len(parts) < 4:
            raise ValueError("XYZ frame contains an invalid atom row.")
        symbols.append(parts[0])
        positions.append([float(parts[1]), float(parts[2]), float(parts[3])])

    atoms = Atoms(symbols=symbols, positions=positions)

    lattice_match = re.search(r'Lattice="([^"]+)"', comment)
    if lattice_match:
        values = [float(item) for item in lattice_match.group(1).split()]
        if len(values) == 9:
            atoms.set_cell([values[0:3], values[3:6], values[6:9]])
            pbc_match = re.search(r'pbc="([^"]+)"', comment, re.IGNORECASE)
            if pbc_match:
                flags = [token.upper().startswith("T") for token in pbc_match.group(1).split()]
                if len(flags) == 3:
                    atoms.set_pbc(flags)
                else:
                    atoms.set_pbc(True)
            else:
                atoms.set_pbc(True)
    return atoms, comment


def _viewer_frame_from_xyz_text(frame_text: str, fallback_step: int) -> Dict[str, Any]:
    atoms, comment = _atoms_from_xyz_frame_text(frame_text)
    meta = _extract_frame_meta_from_comment(comment, fallback_step)
    with suppress(Exception):
        meta["center_of_mass"] = [float(value) for value in atoms.get_center_of_mass()]
    if hasattr(atoms, "pbc") and atoms.pbc is not None and atoms.pbc.any():
        return {
            "format": "cif",
            "text": _atoms_to_cif_text(atoms),
            "meta": meta,
        }
    return {
        "format": "xyz",
        "text": frame_text,
        "meta": meta,
    }


def _write_text_pdf(text: str, pdf_path: Path) -> Path | None:
    try:
        lines = text.splitlines() or [""]
        lines_per_page = 55
        with PdfPages(pdf_path) as pdf:
            for start in range(0, len(lines), lines_per_page):
                chunk = lines[start : start + lines_per_page]
                fig = plt.figure(figsize=(8.27, 11.69))
                fig.patch.set_facecolor("white")
                plt.axis("off")
                fig.text(
                    0.03,
                    0.97,
                    "\n".join(chunk),
                    family="monospace",
                    fontsize=8,
                    va="top",
                    ha="left",
                )
                pdf.savefig(fig, bbox_inches="tight")
                plt.close(fig)
        return pdf_path
    except Exception:
        if pdf_path.exists():
            pdf_path.unlink(missing_ok=True)  # type: ignore[arg-type]
        return None


def _build_geometry_histogram_data(pair_map: Dict[str, List[float]]) -> Optional[Dict[str, Any]]:
    min_value = None
    max_value = None
    for distances in pair_map.values():
        for value in distances:
            if not math.isfinite(value):
                continue
            if min_value is None or value < min_value:
                min_value = value
            if max_value is None or value > max_value:
                max_value = value
    if min_value is None or max_value is None:
        return None
    span = max(max_value - min_value, 0.05)
    desired_bin_width = 0.1
    suggested_bins = round(span / desired_bin_width)
    bin_count = max(1, min(80, suggested_bins or 1))
    bin_width = span / bin_count or 0.05
    centers = [min_value + (index + 0.5) * bin_width for index in range(bin_count)]
    datasets = []
    for label in sorted(pair_map):
        counts = [0] * bin_count
        for value in pair_map[label]:
            if not math.isfinite(value):
                continue
            bin_index = int(math.floor((value - min_value) / bin_width))
            bin_index = max(0, min(bin_count - 1, bin_index))
            counts[bin_index] += 1
        datasets.append({"label": label, "counts": counts})
    return {
        "min": round(min_value, 6),
        "max": round(max_value, 6),
        "bin_width": round(bin_width, 6),
        "centers": [round(value, 6) for value in centers],
        "datasets": datasets,
    }


def _write_geometry_histogram(histogram: Optional[Dict[str, Any]], png_path: Path) -> Optional[Path]:
    try:
        if not histogram:
            return None
        centers = histogram.get("centers") or []
        datasets = histogram.get("datasets") or []
        if not centers or not datasets:
            return None
        bin_width = histogram.get("bin_width")
        if not isinstance(bin_width, (int, float)) or bin_width <= 0:
            if len(centers) > 1:
                bin_width = float(centers[1]) - float(centers[0])
            else:
                bin_width = 0.1
        fig, ax = plt.subplots(figsize=(6.8, 4.2))
        color_palette = [
            "#0891b2",
            "#0ea5e9",
            "#22d3ee",
            "#14b8a6",
            "#10b981",
            "#84cc16",
            "#facc15",
            "#f97316",
            "#f43f5e",
            "#a855f7",
            "#6366f1",
        ]
        bottoms = [0] * len(centers)
        for idx, dataset in enumerate(datasets):
            counts = dataset.get("counts") or []
            normalized = []
            for value in counts[: len(centers)]:
                try:
                    normalized.append(int(value))
                except (TypeError, ValueError):
                    normalized.append(0)
            if len(normalized) < len(centers):
                normalized.extend([0] * (len(centers) - len(normalized)))
            label = dataset.get("label") or "Pairs"
            ax.bar(
                centers,
                normalized,
                width=bin_width,
                bottom=bottoms,
                color=color_palette[idx % len(color_palette)],
                alpha=0.85,
                edgecolor="#0f172a",
                label=label,
            )
            bottoms = [base + add for base, add in zip(bottoms, normalized)]
        ax.set_title("Pair Distance Histogram")
        ax.set_xlabel("Distance (Å)")
        ax.set_ylabel("Count")
        ax.legend(loc="upper right", fontsize="x-small", ncol=1, frameon=False)
        fig.tight_layout()
        fig.savefig(png_path, dpi=150)
        plt.close(fig)
        return png_path
    except Exception:
        if png_path.exists():
            png_path.unlink(missing_ok=True)  # type: ignore[arg-type]
        return None


def _write_geometry_pdf(text: str, pdf_path: Path, histogram_path: Optional[Path]) -> Path | None:
    try:
        lines = text.splitlines() or [""]
        lines_per_page = 55
        with PdfPages(pdf_path) as pdf:
            for start in range(0, len(lines), lines_per_page):
                chunk = lines[start : start + lines_per_page]
                fig = plt.figure(figsize=(8.27, 11.69))
                fig.patch.set_facecolor("white")
                plt.axis("off")
                fig.text(
                    0.03,
                    0.97,
                    "\n".join(chunk),
                    family="monospace",
                    fontsize=8,
                    va="top",
                    ha="left",
                )
                pdf.savefig(fig, bbox_inches="tight")
                plt.close(fig)
            if histogram_path and histogram_path.exists():
                fig = plt.figure(figsize=(8.27, 11.69))
                fig.patch.set_facecolor("white")
                plt.axis("off")
                image = plt.imread(str(histogram_path))
                plt.imshow(image)
                pdf.savefig(fig, bbox_inches="tight")
                plt.close(fig)
        return pdf_path
    except Exception:
        if pdf_path.exists():
            pdf_path.unlink(missing_ok=True)  # type: ignore[arg-type]
        return None


def _write_geometry_latex(
    text: str,
    tex_path: Path,
    title: str,
    histogram_path: Optional[Path],
) -> Path | None:
    try:
        lines = [
            r"\documentclass{article}",
            r"\usepackage[margin=1in]{geometry}",
            r"\usepackage{fancyvrb}",
        ]
        if histogram_path:
            lines.append(r"\usepackage{graphicx}")
        lines.extend(
            [
                r"\begin{document}",
                rf"\section*{{{title}}}",
                r"\begin{Verbatim}[fontsize=\small]",
                text,
                r"\end{Verbatim}",
            ]
        )
        if histogram_path:
            lines.extend(
                [
                    r"\begin{center}",
                    rf"\includegraphics[width=0.9\linewidth]{{{histogram_path.name}}}",
                    r"\end{center}",
                ]
            )
        lines.extend([r"\end{document}", ""])
        tex_path.write_text("\n".join(lines), encoding="utf-8")
        return tex_path
    except OSError:
        return None


def _write_geometry_docx(text: str, docx_path: Path, histogram_path: Optional[Path]) -> Path | None:
    if DRIVER_DOCX_AVAILABLE:
        try:
            document = Document()
            for line in text.splitlines():
                document.add_paragraph(line)
            if histogram_path and histogram_path.exists():
                try:
                    from docx.shared import Inches  # type: ignore[import-not-found]

                    document.add_picture(str(histogram_path), width=Inches(6))
                except Exception:
                    pass
            document.save(docx_path)
            return docx_path
        except Exception:
            pass
    return _write_text_docx(text, docx_path)


def _write_text_latex(text: str, tex_path: Path, title: str) -> Path | None:
    try:
        latex = "\n".join(
            [
                r"\documentclass{article}",
                r"\usepackage[margin=1in]{geometry}",
                r"\usepackage{fancyvrb}",
                r"\begin{document}",
                rf"\section*{{{title}}}",
                r"\begin{Verbatim}[fontsize=\small]",
                text,
                r"\end{Verbatim}",
                r"\end{document}",
                "",
            ]
        )
        tex_path.write_text(latex, encoding="utf-8")
        return tex_path
    except OSError:
        return None


def _write_text_docx(text: str, docx_path: Path) -> Path | None:
    if DRIVER_DOCX_AVAILABLE:
        try:
            document = Document()
            for line in text.splitlines():
                document.add_paragraph(line)
            document.save(docx_path)
            return docx_path
        except Exception:
            pass

    lines = text.splitlines() or [""]
    fallback = _write_basic_report_docx(lines, docx_path)
    if fallback:
        return fallback
    if docx_path.exists():
        docx_path.unlink(missing_ok=True)  # type: ignore[arg-type]
    return None


def _run_driver_analysis(xyz_root: Path, output_dir: Path) -> Dict[str, Path | str | int]:
    xyz_files = [
        path
        for path in xyz_root.rglob("*")
        if path.is_file() and path.suffix.lower() == ".xyz"
    ]
    if not xyz_files:
        raise ValueError("Upload at least one XYZ file in the selected folder.")

    def _is_opt_variant(path: Path) -> bool:
        stem = path.stem.lower()
        return "opt" in stem or "sp-opt" in stem

    def _matches_base(base: Path, candidate: Path) -> bool:
        base_key = base.stem.lower()
        cand_key = candidate.stem.lower()
        if cand_key == base_key:
            return False
        prefix = f"{base_key}-"
        if not cand_key.startswith(prefix):
            return False
        suffix = cand_key[len(prefix) :]
        return "opt" in suffix or "sp-opt" in suffix

    by_parent: Dict[Path, Dict[str, Path]] = {}
    for path in xyz_files:
        by_parent.setdefault(path.parent, {})[path.name.lower()] = path

    file_pairs: List[tuple[Path, Path]] = []
    for folder_files in by_parent.values():
        bases = {name: path for name, path in folder_files.items() if not _is_opt_variant(path)}
        variants = {name: path for name, path in folder_files.items() if _is_opt_variant(path)}
        for base_name, base_path in bases.items():
            prefix = f"{base_name}"
            matches = [
                variants[name]
                for name in variants
                if name.startswith(f"{base_name[:-4]}-") and _matches_base(base_path, variants[name])
            ]
            for match in matches:
                file_pairs.append((base_path, match))

    if not file_pairs:
        raise ValueError("No XYZ/-geoopt-OPT pairs found. Ensure optimized counterparts are present.")

    scripts_root = resources.files("uma_ase").joinpath("scripts-to-share_v2")
    with resources.as_file(scripts_root) as resolved_root:
        root_path = Path(resolved_root)
        rmsd_script = root_path / "rmsd.py"
        hetero_script = root_path / "rmsd_dist-angles_ranking_hetero-cutoff.py"
        if not rmsd_script.exists() or not hetero_script.exists():
            raise RuntimeError("Driver scripts are unavailable in this installation.")

        def _run_tool(tool_path: Path, file_a: Path, file_b: Path) -> str:
            result = subprocess.run(
                [sys.executable, str(tool_path), str(file_a), str(file_b)],
                capture_output=True,
                text=True,
                cwd=str(root_path),
            )
            if result.returncode != 0:
                stderr = (result.stderr or "").strip()
                stdout = (result.stdout or "").strip()
                details = stderr or stdout or f"Exited with status {result.returncode}"
                return f"Error running {tool_path.name}: {details}\n"
            return result.stdout

        output_dir.mkdir(parents=True, exist_ok=True)
        output_path = output_dir / "compiled_rmsd_results.txt"
        results: List[Dict[str, Any]] = []
        with output_path.open("w", encoding="utf-8", buffering=1024 * 1024) as handle:
            handle.write("=== RMSD Analysis Results ===\n\n")
            for index, (file_a, file_b) in enumerate(file_pairs, start=1):
                handle.write(f"[{index}] File pair: {file_a.name}  vs  {file_b.name}\n")
                handle.write("-" * 60 + "\n")
                handle.write("--- rmsd.py output ---\n")
                rmsd_output = _run_tool(rmsd_script, file_a, file_b)
                handle.write(rmsd_output)
                handle.write("\n--- rmsd_dist-angles_ranking_hetero-cutoff.py output ---\n")
                hetero_output = _run_tool(hetero_script, file_a, file_b)
                handle.write(hetero_output)
                handle.write("\n" + "=" * 80 + "\n\n")
                meta_a = extract_xyz_metadata(file_a)
                meta_b = extract_xyz_metadata(file_b)
                charge = None
                if meta_a.charge is not None and meta_b.charge is not None:
                    charge = meta_a.charge if meta_a.charge == meta_b.charge else meta_a.charge
                elif meta_a.charge is not None:
                    charge = meta_a.charge
                elif meta_b.charge is not None:
                    charge = meta_b.charge
                entry = {
                    "label": f"{file_a.name} vs {file_b.name}",
                    "file_a": file_a.name,
                    "file_b": file_b.name,
                    "rmsd": _parse_driver_rmsd(rmsd_output),
                    "charge": charge,
                    "charge_a": meta_a.charge,
                    "charge_b": meta_b.charge,
                }
                entry.update(_parse_driver_details(hetero_output))
                results.append(entry)
            handle.flush()

    preview_text = output_path.read_text(encoding="utf-8", errors="replace")
    preview_limit = 200_000
    trimmed_preview = (
        preview_text if len(preview_text) <= preview_limit else f"{preview_text[:preview_limit]}\n...\n"
    )
    plot_path = _write_driver_plot(results, output_dir / "xyz_pair_analysis.png")
    pdf_path = _write_text_pdf(preview_text, output_dir / "compiled_rmsd_results.pdf")
    tex_path = _write_text_latex(preview_text, output_dir / "compiled_rmsd_results.tex", "Compiled RMSD Results")
    docx_path = _write_text_docx(preview_text, output_dir / "compiled_rmsd_results.docx")

    return {
        "text_path": output_path,
        "pdf_path": pdf_path,
        "latex_path": tex_path,
        "docx_path": docx_path,
        "plot_path": plot_path,
        "results": results,
        "pairs": len(file_pairs),
        "preview": trimmed_preview,
    }


@app.route("/")
def index() -> Response:
    """Serve the single-page frontend bundled with the package."""
    html_path = resources.files("uma_ase").joinpath("static", STATIC_HTML)
    return Response(html_path.read_bytes(), mimetype="text/html")


@app.route("/assets/<path:asset>")
def serve_static_asset(asset: str):
    """Serve packaged static assets (e.g. logo.svg) referenced from the frontend."""
    candidate = resources.files("uma_ase").joinpath("static", asset)
    if not candidate.is_file():
        abort(404)
    with resources.as_file(candidate) as fs_path:
        return send_file(fs_path)


@app.route("/screenshot<int:index>.png")
def serve_readme_screenshot(index: int):
    """Expose README screenshot assets referenced by relative paths."""
    if index < 1:
        abort(404)
    filename = f"screenshot{index}.png"
    candidate = resources.files("uma_ase").joinpath("static", "screenshots", filename)
    if not candidate.is_file():
        abort(404)
    with resources.as_file(candidate) as fs_path:
        return send_file(fs_path)


@app.route("/assets/")
def serve_static_root():
    """Provide a no-op response for tools that probe the asset root (e.g. JSmol)."""
    return Response(status=204)


@app.route("/LICENSE")
def serve_license() -> Response:
    """Expose the repository license so README links work inside the webapp."""
    license_path = _find_local_license()
    if license_path is None:
        abort(404)
    return send_file(license_path, mimetype="text/plain; charset=utf-8")


@app.route("/api/uma-ase/readme", methods=["GET"])
def fetch_readme():
    try:
        content = _read_readme_text()
    except FileNotFoundError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 404
    return jsonify({"status": "ok", "content": content})


@app.route("/api/uma-ase/analyze", methods=["POST"])
def analyze_logs():
    uploads = request.files.getlist("files")
    if not uploads:
        return jsonify({"status": "error", "message": "Upload at least one log file or folder."}), 400

    with tempfile.TemporaryDirectory() as tmpdir:
        logs_root = Path(tmpdir) / "logs"
        logs_root.mkdir(parents=True, exist_ok=True)
        saved = 0
        for storage in uploads:
            if not storage or not storage.filename:
                continue
            try:
                _safe_save_upload(storage, logs_root)
                saved += 1
            except ValueError:
                continue

        if not saved:
            return jsonify({"status": "error", "message": "No valid files uploaded."}), 400

        token = secure_filename(uuid.uuid4().hex)
        output_dir = ANALYZE_REPORT_ROOT / token
        output_dir.mkdir(parents=True, exist_ok=True)
        try:
            outputs = generate_report(logs_root, output_dir)
        except ValueError as exc:
            shutil.rmtree(output_dir, ignore_errors=True)
            return jsonify({"status": "error", "message": str(exc)}), 400
        pdf_path = outputs.get("pdf")
        if not pdf_path or not pdf_path.exists():
            shutil.rmtree(output_dir, ignore_errors=True)
            return jsonify({"status": "error", "message": "Report generation failed."}), 500
        report_html = _build_rmsd_report_html(
            outputs.get("header") or [],
            outputs.get("rows") or [],
            outputs.get("stats") or {},
            outputs.get("column_stats") or {},
        )
        payload = {
            "status": "ok",
            "token": token,
            "text_url": _build_analyze_url(token, outputs.get("text")),
            "pdf_url": _build_analyze_url(token, pdf_path),
            "latex_url": _build_analyze_url(token, outputs.get("latex")),
            "docx_url": _build_analyze_url(token, outputs.get("docx")),
            "plot_url": _build_analyze_url(token, outputs.get("plot")),
            "report_html": report_html,
        }
        return jsonify(payload)


@app.route("/api/uma-ase/analyze/driver", methods=["POST"])
def analyze_xyz_pairs():
    uploads = request.files.getlist("files")
    if not uploads:
        return jsonify({"status": "error", "message": "Upload at least one XYZ file or folder."}), 400

    with tempfile.TemporaryDirectory() as tmpdir:
        xyz_root = Path(tmpdir) / "xyz"
        xyz_root.mkdir(parents=True, exist_ok=True)
        saved = 0
        for storage in uploads:
            if not storage or not storage.filename:
                continue
            try:
                _safe_save_upload(storage, xyz_root)
                saved += 1
            except ValueError:
                continue

        if not saved:
            return jsonify({"status": "error", "message": "No valid files uploaded."}), 400

        token = secure_filename(f"drv-{uuid.uuid4().hex}")
        output_dir = ANALYZE_REPORT_ROOT / token
        output_dir.mkdir(parents=True, exist_ok=True)
        try:
            result = _run_driver_analysis(xyz_root, output_dir)
        except ValueError as exc:
            shutil.rmtree(output_dir, ignore_errors=True)
            return jsonify({"status": "error", "message": str(exc)}), 400
        except RuntimeError as exc:
            shutil.rmtree(output_dir, ignore_errors=True)
            return jsonify({"status": "error", "message": str(exc)}), 500

    text_path = result.get("text_path")
    results = result.get("results") or []
    report_html = _build_driver_report_html(results) if isinstance(results, list) else ""
    payload = {
        "status": "ok",
        "token": token,
        "pairs": result.get("pairs", 0),
        "preview": result.get("preview"),
        "results_url": _build_analyze_url(token, text_path),
        "pdf_url": _build_analyze_url(token, result.get("pdf_path")),
        "latex_url": _build_analyze_url(token, result.get("latex_path")),
        "docx_url": _build_analyze_url(token, result.get("docx_path")),
        "plot_url": _build_analyze_url(token, result.get("plot_path")),
        "report_html": report_html,
        "scatter_points": results,
        "message": f"Processed {result.get('pairs', 0)} file pairs." if result.get("pairs") else "Analysis complete.",
    }
    return jsonify(payload)


@app.route("/api/uma-ase/analyze/simple-geometry", methods=["POST"])
def analyze_simple_geometry():
    uploads = request.files.getlist("files")
    if not uploads:
        return jsonify({"status": "error", "message": "Upload at least one geometry file or folder."}), 400
    include_matrix_raw = (request.form.get("include_matrix") or "").strip().lower()
    include_matrix = include_matrix_raw in {"1", "true", "yes", "on"}

    with tempfile.TemporaryDirectory() as tmpdir:
        geom_root = Path(tmpdir) / "geometry"
        geom_root.mkdir(parents=True, exist_ok=True)
        saved_files: List[Tuple[Path, str]] = []
        for storage in uploads:
            if not storage or not storage.filename:
                continue
            try:
                destination = _safe_save_upload(storage, geom_root)
            except ValueError:
                continue
            label = _display_label_for_upload(storage.filename, destination)
            saved_files.append((destination, label))

        if not saved_files:
            return jsonify({"status": "error", "message": "No valid geometry files uploaded."}), 400

        candidates, skipped = _expand_geometry_inputs(saved_files)
        if not candidates:
            return jsonify({
                "status": "error",
                "message": "No geometry files found inside the uploaded selection.",
                "skipped": skipped,
            }), 400

        pair_map: Dict[str, List[float]] = {}
        structures: List[Dict[str, Any]] = []
        processed = 0

        for path, label in candidates:
            if not path.is_file():
                continue
            atoms = _load_first_structure(path)
            if atoms is None:
                skipped.append({"label": label, "reason": "Unsupported geometry format."})
                continue
            atom_count = len(atoms)
            if atom_count == 0:
                skipped.append({"label": label, "reason": "Structure does not contain atoms."})
                continue
            try:
                mic = bool(atoms.pbc.any()) if hasattr(atoms, "pbc") else False
            except Exception:
                mic = False
            try:
                distances = atoms.get_all_distances(mic=mic)
            except Exception as exc:
                skipped.append({"label": label, "reason": f"Unable to compute distances: {exc}"})
                continue
            symbols = atoms.get_chemical_symbols()
            matrix_list = []
            for row in distances.tolist():
                matrix_row = []
                for value in row:
                    try:
                        numeric_value = float(value)
                    except (TypeError, ValueError):
                        matrix_row.append(None)
                        continue
                    if math.isfinite(numeric_value):
                        matrix_row.append(round(numeric_value, 6))
                    else:
                        matrix_row.append(None)
                matrix_list.append(matrix_row)

            for i in range(atom_count):
                for j in range(i + 1, atom_count):
                    try:
                        numeric = float(distances[i][j])
                    except (TypeError, ValueError):
                        continue
                    if not math.isfinite(numeric):
                        continue
                    pair_label = _pair_label(symbols[i], symbols[j])
                    pair_map.setdefault(pair_label, []).append(numeric)

            structure_entry = {
                "id": len(structures),
                "label": label,
                "formula": atoms.get_chemical_formula(),
                "atom_count": atom_count,
                "symbols": symbols,
                "distance_matrix": matrix_list,
            }
            structures.append(structure_entry)
            processed += 1

    if not structures:
        return jsonify({
            "status": "error",
            "message": "No valid geometries were detected.",
            "skipped": skipped,
        }), 400

    pair_series = []
    total_pairs = 0
    for pair_label in sorted(pair_map):
        distances = pair_map[pair_label]
        if not distances:
            continue
        distances.sort()
        total_pairs += len(distances)
        mean_value = sum(distances) / len(distances)
        pair_series.append({
            "label": pair_label,
            "count": len(distances),
            "min": round(distances[0], 6),
            "max": round(distances[-1], 6),
            "mean": round(mean_value, 6),
        })

    histogram_data = _build_geometry_histogram_data(pair_map)
    token = secure_filename(f"geo-{uuid.uuid4().hex}")
    output_dir = ANALYZE_REPORT_ROOT / token
    output_dir.mkdir(parents=True, exist_ok=True)
    summary_text = _build_simple_geometry_summary(
        structures, pair_series, total_pairs, skipped, include_matrix=include_matrix
    )
    summary_text_txt = _build_simple_geometry_summary(
        structures, pair_series, total_pairs, skipped, include_matrix=True
    )
    histogram_path = _write_geometry_histogram(histogram_data, output_dir / "simple_geometry_histogram.png")
    text_path: Optional[Path] = output_dir / "simple_geometry_results.txt"
    try:
        text_path.write_text(summary_text_txt, encoding="utf-8")
    except OSError:
        text_path = None
    pdf_path = _write_geometry_pdf(summary_text, output_dir / "simple_geometry_results.pdf", histogram_path)
    latex_path = _write_geometry_latex(
        summary_text,
        output_dir / "simple_geometry_results.tex",
        "Simple Geometry Analysis",
        histogram_path,
    )
    docx_path = _write_geometry_docx(summary_text, output_dir / "simple_geometry_results.docx", histogram_path)

    payload = {
        "status": "ok",
        "token": token,
        "structures": structures,
        "pair_series": pair_series,
        "total_pairs": total_pairs,
        "processed": processed,
        "skipped": skipped,
        "histogram": histogram_data,
        "text_url": _build_analyze_url(token, text_path) if text_path else None,
        "pdf_url": _build_analyze_url(token, pdf_path),
        "latex_url": _build_analyze_url(token, latex_path),
        "docx_url": _build_analyze_url(token, docx_path),
        "histogram_url": _build_analyze_url(token, histogram_path) if histogram_path else None,
    }
    return jsonify(payload)


@app.route("/api/uma-ase/analyze/<token>/<path:filename>")
def download_analyze_file(token: str, filename: str):
    safe_token = secure_filename(token)
    base_dir = (ANALYZE_REPORT_ROOT / safe_token).resolve()
    if not base_dir.exists():
        abort(404)
    target = (base_dir / filename).resolve()
    try:
        target.relative_to(base_dir)
    except ValueError:
        abort(404)
    if not target.is_file():
        abort(404)
    return send_file(target)


@app.route("/api/uma-ase/run", methods=["POST"])
def run_job():
    geometry = request.files.get("geometry")
    if geometry is None or geometry.filename == "":
        return jsonify({"status": "error", "message": "Geometry file is required."}), 400
    client_id = _get_client_id()

    try:
        charge_val = int(request.form.get("charge", "0"))
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "Charge must be an integer."}), 400

    try:
        spin_val = int(request.form.get("spin", "1"))
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "Spin multiplicity must be an integer."}), 400

    try:
        grad_val = float(request.form.get("grad", "0.01"))
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "Grad must be a number."}), 400
    if grad_val <= 0:
        return jsonify({"status": "error", "message": "Grad must be positive."}), 400

    try:
        iter_val = int(request.form.get("iter", "250"))
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "Max iterations must be an integer."}), 400
    if iter_val <= 0:
        return jsonify({"status": "error", "message": "Max iterations must be positive."}), 400

    optimizer = request.form.get("optimizer", "LBFGS")
    temperature_raw = (request.form.get("temperature") or "").strip()
    temperature = temperature_raw or "298.15"
    pressure_raw = (request.form.get("pressure") or "").strip()
    pressure = pressure_raw or "101325.0"
    run_types_raw = request.form.get("run_type", "sp").split()
    run_types = [item.lower() for item in run_types_raw] or ["sp"]
    mlff_checkpoint_raw = request.form.get("mlff_checkpoint", "uma-s-1p1")
    mlff_checkpoint = mlff_checkpoint_raw.strip() or "uma-s-1p1"
    mlff_task_raw = request.form.get("mlff_task", "omol")
    mlff_task = mlff_task_raw.strip() or "omol"
    ts_displacement_raw = (request.form.get("ts_displacement") or "").strip()
    if ts_displacement_raw:
        try:
            ts_displacement_val = float(ts_displacement_raw)
        except (TypeError, ValueError):
            return jsonify({"status": "error", "message": "TS displacement must be a number."}), 400
        if ts_displacement_val < 0:
            return jsonify({"status": "error", "message": "TS displacement must be non-negative."}), 400
    else:
        ts_displacement_val = 0.1

    ts_dimer_sep_raw = (request.form.get("ts_dimer_separation") or "").strip()
    if ts_dimer_sep_raw:
        try:
            ts_dimer_sep_val = float(ts_dimer_sep_raw)
        except (TypeError, ValueError):
            return jsonify({"status": "error", "message": "Dimer separation must be a number."}), 400
        if ts_dimer_sep_val <= 0:
            return jsonify({"status": "error", "message": "Dimer separation must be positive."}), 400
    else:
        ts_dimer_sep_val = 0.01

    ts_trial_step_raw = (request.form.get("ts_trial_step") or "").strip()
    if ts_trial_step_raw:
        try:
            ts_trial_step_val = float(ts_trial_step_raw)
        except (TypeError, ValueError):
            return jsonify({"status": "error", "message": "Trial translation must be a number."}), 400
        if ts_trial_step_val <= 0:
            return jsonify({"status": "error", "message": "Trial translation must be positive."}), 400
    else:
        ts_trial_step_val = 0.05

    ts_max_step_raw = (request.form.get("ts_max_step") or "").strip()
    if ts_max_step_raw:
        try:
            ts_max_step_val = float(ts_max_step_raw)
        except (TypeError, ValueError):
            return jsonify({"status": "error", "message": "Max translation must be a number."}), 400
        if ts_max_step_val <= 0:
            return jsonify({"status": "error", "message": "Max translation must be positive."}), 400
    else:
        ts_max_step_val = 0.2

    ts_max_rot_raw = (request.form.get("ts_max_rot") or "").strip()
    if ts_max_rot_raw:
        try:
            ts_max_rot_val = int(ts_max_rot_raw)
        except (TypeError, ValueError):
            return jsonify({"status": "error", "message": "Max rotations must be an integer."}), 400
        if ts_max_rot_val <= 0:
            return jsonify({"status": "error", "message": "Max rotations must be positive."}), 400
    else:
        ts_max_rot_val = 5

    ts_displacement = str(ts_displacement_val)
    ts_dimer_separation = str(ts_dimer_sep_val)
    ts_trial_step = str(ts_trial_step_val)
    ts_max_step = str(ts_max_step_val)
    ts_max_rot = str(ts_max_rot_val)

    results_root = Path(app.config["UMA_RESULTS_DIR"])
    results_root.mkdir(parents=True, exist_ok=True)

    job_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
    filename = secure_filename(geometry.filename) or "input.xyz"
    relative_field = request.form.get("relative_path") or request.form.get("source_path")
    sanitized_relative = _sanitize_relative_path(relative_field)
    folder_root_raw = request.form.get("multi_root")
    folder_root = secure_filename(folder_root_raw) if folder_root_raw else None
    multi_root_dir = results_root / "multi_runs"
    job_dir: Path
    if sanitized_relative:
        multi_root_dir.mkdir(parents=True, exist_ok=True)
        rel_parent = sanitized_relative.parent if sanitized_relative.parent != Path(".") else Path()
        base_name = sanitized_relative.stem or Path(filename).stem or "geometry"
        if folder_root:
            rel_parent = Path(folder_root) / rel_parent
        base_dir = multi_root_dir.joinpath(rel_parent, base_name)
        job_dir = base_dir
        attempt = 1
        while job_dir.exists():
            job_dir = base_dir.parent / f"{base_name}_{attempt}"
            attempt += 1
        job_dir.mkdir(parents=True, exist_ok=True)
    else:
        if folder_root:
            multi_root_dir.mkdir(parents=True, exist_ok=True)
            base_name = Path(filename).stem or "geometry"
            base_dir = multi_root_dir / folder_root / base_name
            job_dir = base_dir
            attempt = 1
            while job_dir.exists():
                job_dir = base_dir.parent / f"{base_name}_{attempt}"
                attempt += 1
            job_dir.mkdir(parents=True, exist_ok=True)
        else:
            job_dir = results_root / job_id
            job_dir.mkdir(parents=True, exist_ok=True)

    try:
        input_path, filename = _save_geometry_upload_for_ase(
            geometry,
            job_dir,
            charge=charge_val,
            spin=spin_val,
            prefer_periodic_format=False,
        )
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400

    try:
        temp_val = _coerce_float(temperature, None)
        press_val = _coerce_float(pressure, None)
        use_params = _build_workflow_use_params(
            run_types=run_types,
            charge=charge_val,
            spin=spin_val,
            optimizer=optimizer,
            grad=grad_val,
            iterations=iter_val,
            temperature=temp_val,
            pressure=press_val,
            mlff_checkpoint=mlff_checkpoint,
            mlff_task=mlff_task,
            ts_displacement=ts_displacement_val,
            ts_dimer_separation=ts_dimer_sep_val,
            ts_trial_step=ts_trial_step_val,
            ts_max_step=ts_max_step_val,
            ts_max_rot=ts_max_rot_val,
        )
        use_payload = _build_use_payload(
            input_path=input_path,
            filename=filename,
            job_kind="workflow",
            params=use_params,
        )
        _write_use_file(job_dir, filename, use_payload)
    except Exception as exc:
        app.logger.warning("Failed to write .use file for job %s: %s", job_id, exc)

    record = JobRecord(
        job_id=job_id,
        job_dir=job_dir,
        charge=charge_val,
        spin=spin_val,
        grad=grad_val,
        iterations=iter_val,
        run_types=run_types,
        relative_path=sanitized_relative,
        client_id=client_id,
        job_name=str(sanitized_relative or filename),
        job_source="multi" if folder_root else "single",
        input_filename=filename,
    )
    record.cancel_event = threading.Event()
    record.job_kind = "workflow"

    with JOB_LOCK:
        JOBS[job_id] = record

    _ensure_queue_worker()
    queue_position = _enqueue_job(
        record,
        _execute_job,
        record,
        filename,
        optimizer,
        temperature,
        pressure,
        mlff_checkpoint,
        mlff_task,
        sanitized_relative,
        ts_displacement,
        ts_dimer_separation,
        ts_trial_step,
        ts_max_step,
        ts_max_rot,
    )

    return jsonify({"job_id": job_id, "status": "queued", "queue_position": queue_position})



@app.route("/api/uma-ase/md/run", methods=["POST"])
def run_md_job():
    geometry = request.files.get("geometry")
    if geometry is None or geometry.filename == "":
        return jsonify({"status": "error", "message": "Geometry file is required."}), 400
    client_id = _get_client_id()

    form = request.form
    engine = (form.get("md_engine") or "langevin").lower()

    try:
        charge_val = _parse_int_field(form, "charge", 0, label="Charge")
        spin_val = _parse_int_field(form, "spin", 1, label="Spin multiplicity", min_value=0, min_inclusive=False)
        steps_val = _parse_int_field(form, "md_steps", 500, label="MD steps", min_value=0, min_inclusive=False)
        timestep_fs = _parse_float_field(form, "md_timestep_fs", 0.5, label="Timestep", min_value=0.0, min_inclusive=False)
        temperature_val = _parse_float_field(form, "md_temperature", 300.0, label="Temperature", min_value=0.0, min_inclusive=False)
        friction_val = _parse_float_field(form, "md_friction", 0.002, label="Friction", min_value=0.0)
        traj_interval = _parse_int_field(form, "md_traj_interval", 10, label="Trajectory interval", min_value=0, min_inclusive=False)
        log_interval = _parse_int_field(form, "md_log_interval", 10, label="Log interval", min_value=0, min_inclusive=False)
        relax_fs = _parse_float_field(form, "md_relax_fs", 100.0, label="Thermostat relaxation time", min_value=0.0, min_inclusive=False)
        pressure_bar = _parse_float_field(form, "md_pressure_bar", 1.0, label="Pressure", min_value=0.0)
        compressibility_bar = _parse_float_field(form, "md_compressibility_bar", 4.57e-5, label="Compressibility", min_value=0.0)
        barostat_relax_fs = _parse_float_field(form, "md_barostat_relax_fs", 1000.0, label="Barostat relaxation time", min_value=0.0, min_inclusive=False)
        nose_tdamp_fs = _parse_float_field(form, "md_nose_tdamp_fs", relax_fs, label="Nosé-Hoover damping", min_value=0.0, min_inclusive=False)
        nose_tchain = _parse_int_field(form, "md_nose_tchain", 3, label="Nosé-Hoover chain length", min_value=0, min_inclusive=False)
        nose_tloop = _parse_int_field(form, "md_nose_tloop", 1, label="Nosé-Hoover sub-steps", min_value=0, min_inclusive=False)
        bussi_taut_fs = _parse_float_field(form, "md_bussi_taut_fs", relax_fs, label="Bussi time constant", min_value=0.0, min_inclusive=False)
        andersen_prob = _parse_float_field(form, "md_andersen_prob", 0.001, label="Andersen collision probability", min_value=0.0)
        if andersen_prob is None or not 0 <= andersen_prob <= 1:
            raise ValueError("Andersen collision probability must be between 0 and 1.")
        mtk_tdamp_fs = _parse_float_field(form, "md_mtk_tdamp_fs", 100.0, label="MTK thermostat damping", min_value=0.0, min_inclusive=False)
        mtk_pdamp_fs = _parse_float_field(form, "md_mtk_pdamp_fs", 1000.0, label="MTK barostat damping", min_value=0.0, min_inclusive=False)
        mtk_tchain = _parse_int_field(form, "md_mtk_tchain", 3, label="MTK thermostat chain length", min_value=0, min_inclusive=False)
        mtk_pchain = _parse_int_field(form, "md_mtk_pchain", 3, label="MTK barostat chain length", min_value=0, min_inclusive=False)
        mtk_tloop = _parse_int_field(form, "md_mtk_tloop", 1, label="MTK thermostat sub-steps", min_value=0, min_inclusive=False)
        mtk_ploop = _parse_int_field(form, "md_mtk_ploop", 1, label="MTK barostat sub-steps", min_value=0, min_inclusive=False)
        baoab_ttau_fs = _parse_float_field(form, "md_baoab_ttau_fs", 50.0, label="BAOAB thermostat time", min_value=0.0, min_inclusive=False)
        baoab_ptau_fs = _parse_float_field(form, "md_baoab_ptau_fs", 1000.0, label="BAOAB barostat time", min_value=0.0, min_inclusive=False)
        baoab_pmass = _parse_float_field(form, "md_baoab_pmass", None, label="BAOAB cell mass", min_value=0.0, min_inclusive=False)
        baoab_pmass_factor = _parse_float_field(form, "md_baoab_pmass_factor", 1.0, label="BAOAB mass factor", min_value=0.0, min_inclusive=False)
        melchionna_ttime_fs = _parse_float_field(form, "md_melchionna_ttime_fs", 25.0, label="Melchionna thermostat time", min_value=0.0)
        melchionna_ptime_fs = _parse_float_field(form, "md_melchionna_ptime_fs", 75.0, label="Melchionna barostat time", min_value=0.0)
        melchionna_bulk_gpa = _parse_float_field(form, "md_melchionna_bulk_gpa", 100.0, label="Melchionna bulk modulus", min_value=0.0)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400

    melchionna_mask_raw = (form.get("md_melchionna_mask") or "").strip()
    melchionna_mask: Optional[List[int]] = None
    if melchionna_mask_raw:
        try:
            mask_values = []
            for entry in melchionna_mask_raw.split(","):
                entry_clean = entry.strip()
                if entry_clean == "":
                    continue
                value = 1 if int(round(float(entry_clean))) > 0 else 0
                mask_values.append(value)
            if mask_values:
                mask_values = (mask_values + [1, 1, 1])[:3]
                melchionna_mask = mask_values
        except (TypeError, ValueError):
            return jsonify({"status": "error", "message": "Melchionna mask must be comma separated integers."}), 400

    baoab_hydrostatic = _parse_bool_field(form, "md_baoab_hydrostatic", False)
    use_pbc = _parse_bool_field(form, "md_use_pbc", False)
    cell_parameters: Optional[List[float]] = None
    if use_pbc:
        try:
            cell_a = _parse_float_field(form, "md_cell_a", None, label="Cell a", min_value=0.0, min_inclusive=False, required=True)
            cell_b = _parse_float_field(form, "md_cell_b", None, label="Cell b", min_value=0.0, min_inclusive=False, required=True)
            cell_c = _parse_float_field(form, "md_cell_c", None, label="Cell c", min_value=0.0, min_inclusive=False, required=True)
            cell_alpha = _parse_float_field(
                form,
                "md_cell_alpha",
                None,
                label="Cell alpha",
                min_value=0.0,
                min_inclusive=False,
                max_value=180.0,
                max_inclusive=False,
                required=True,
            )
            cell_beta = _parse_float_field(
                form,
                "md_cell_beta",
                None,
                label="Cell beta",
                min_value=0.0,
                min_inclusive=False,
                max_value=180.0,
                max_inclusive=False,
                required=True,
            )
            cell_gamma = _parse_float_field(
                form,
                "md_cell_gamma",
                None,
                label="Cell gamma",
                min_value=0.0,
                min_inclusive=False,
                max_value=180.0,
                max_inclusive=False,
                required=True,
            )
            cell_parameters = [cell_a, cell_b, cell_c, cell_alpha, cell_beta, cell_gamma]  # type: ignore[list-item]
        except ValueError as exc:
            return jsonify({"status": "error", "message": str(exc)}), 400

    mlff_checkpoint_raw = request.form.get("mlff_checkpoint", "uma-s-1p1")
    mlff_checkpoint = mlff_checkpoint_raw.strip() or "uma-s-1p1"
    mlff_task_raw = request.form.get("mlff_task", "omol")
    mlff_task = mlff_task_raw.strip() or "omol"
    use_input_velocities = _parse_bool_field(form, "md_use_input_velocities", False)
    input_velocities_raw = (form.get("md_velocities") or "").strip()
    input_velocities = None
    if input_velocities_raw:
        try:
            parsed = json.loads(input_velocities_raw)
        except json.JSONDecodeError:
            return jsonify({"status": "error", "message": "Invalid MD velocities payload."}), 400
        if not isinstance(parsed, list):
            return jsonify({"status": "error", "message": "MD velocities payload must be a list."}), 400
        input_velocities = parsed
    if use_input_velocities and input_velocities is None:
        return jsonify(
            {"status": "error", "message": "MD velocities required when md_use_input_velocities is true."}
        ), 400

    results_root = Path(app.config["UMA_RESULTS_DIR"])
    results_root.mkdir(parents=True, exist_ok=True)

    job_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
    filename = secure_filename(geometry.filename) or "input.xyz"
    job_dir = results_root / job_id
    job_dir.mkdir(parents=True, exist_ok=True)

    try:
        input_path, filename = _save_geometry_upload_for_ase(
            geometry,
            job_dir,
            charge=charge_val,
            spin=spin_val,
            prefer_periodic_format=True,
        )
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400

    log_path = job_dir / f"{Path(filename).stem}-MD.log"
    traj_path = job_dir / f"{Path(filename).stem}-MD.traj"
    snapshot_path = job_dir / f"{Path(filename).stem}-MD-final.xyz"
    multi_xyz_path = job_dir / f"{Path(filename).stem}-MD.xyz"

    md_options = {
        "engine": engine,
        "steps": steps_val,
        "timestep_fs": timestep_fs,
        "temperature": temperature_val,
        "friction": friction_val,
        "traj_interval": traj_interval,
        "log_interval": log_interval,
        "relax_fs": relax_fs,
        "pressure_bar": pressure_bar,
        "compressibility_bar": compressibility_bar,
        "barostat_relax_fs": barostat_relax_fs,
        "nose_tdamp_fs": nose_tdamp_fs,
        "nose_tchain": nose_tchain,
        "nose_tloop": nose_tloop,
        "bussi_taut_fs": bussi_taut_fs,
        "andersen_prob": andersen_prob,
        "mtk_tdamp_fs": mtk_tdamp_fs,
        "mtk_pdamp_fs": mtk_pdamp_fs,
        "mtk_tchain": mtk_tchain,
        "mtk_pchain": mtk_pchain,
        "mtk_tloop": mtk_tloop,
        "mtk_ploop": mtk_ploop,
        "baoab_ttau_fs": baoab_ttau_fs,
        "baoab_ptau_fs": baoab_ptau_fs,
        "baoab_pmass": baoab_pmass,
        "baoab_pmass_factor": baoab_pmass_factor,
        "baoab_hydrostatic": baoab_hydrostatic,
        "melchionna_ttime_fs": melchionna_ttime_fs,
        "melchionna_ptime_fs": melchionna_ptime_fs,
        "melchionna_bulk_gpa": melchionna_bulk_gpa,
        "melchionna_mask": melchionna_mask,
        "use_pbc": use_pbc,
        "cell_parameters": cell_parameters,
        "mlff_checkpoint": mlff_checkpoint,
        "mlff_task": mlff_task,
        "multi_xyz": multi_xyz_path,
        "use_input_velocities": use_input_velocities,
        "input_velocities": input_velocities,
    }

    try:
        use_params = _build_md_use_params(md_options=md_options, charge=charge_val, spin=spin_val)
        use_payload = _build_use_payload(
            input_path=input_path,
            filename=filename,
            job_kind="md",
            params=use_params,
        )
        _write_use_file(job_dir, filename, use_payload)
    except Exception as exc:
        app.logger.warning("Failed to write .use file for job %s: %s", job_id, exc)

    record = JobRecord(
        job_id=job_id,
        job_dir=job_dir,
        charge=charge_val,
        spin=spin_val,
        grad=0.0,
        iterations=steps_val,
        run_types=["md"],
        log_path=log_path,
        traj_path=traj_path,
        opt_path=snapshot_path,
        md_options=md_options,
        md_multi_xyz=multi_xyz_path,
        client_id=client_id,
        job_name=str(filename),
        job_source="md",
        input_filename=filename,
    )
    record.cancel_event = threading.Event()
    record.job_kind = "md"

    with JOB_LOCK:
        JOBS[job_id] = record

    _ensure_queue_worker()
    queue_position = _enqueue_job(record, _execute_md_job, record, filename, md_options)

    return jsonify({"job_id": job_id, "status": "queued", "queue_position": queue_position})


def _execute_job(
    record: JobRecord,
    filename: str,
    optimizer: str,
    temperature: str,
    pressure: str,
    mlff_checkpoint: Optional[str],
    mlff_task: Optional[str],
    relative_path: Optional[Path],
    ts_displacement: Optional[str],
    ts_dimer_separation: Optional[str],
    ts_trial_step: Optional[str],
    ts_max_step: Optional[str],
    ts_max_rot: Optional[str],
):
    job_dir = record.job_dir
    cancel_event = record.cancel_event
    if cancel_event is None:
        cancel_event = threading.Event()
        record.cancel_event = cancel_event
    if relative_path:
        record.relative_path = relative_path
    if cancel_event.is_set():
        with JOB_LOCK:
            record.status = "cancelled"
            record.message = "Job cancelled by user."
        return

    input_path = job_dir / filename
    run_sequence = record.run_types or ["sp"]

    try:
        paths = build_output_paths(input_path, run_sequence)
        record.log_path = paths.log
        record.traj_path = paths.trajectory
        record.opt_path = paths.final_geometry
        record.freq_archive_path = paths.freq_archive
        record.freq_folder = record.job_dir / "freqs" / Path(filename).stem
        record.freq_archive_path = paths.freq_archive

        argv = _build_cli_args(
            input_path,
            record.run_types,
            str(record.charge),
            str(record.spin),
            optimizer,
            str(record.grad),
            str(record.iterations),
            temperature,
            pressure,
            mlff_checkpoint,
            mlff_task,
            ts_displacement,
            ts_dimer_separation,
            ts_trial_step,
            ts_max_step,
            ts_max_rot,
        )

        cmd = [sys.executable, "-m", "uma_ase.cli", *argv]
        process = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            cwd=str(job_dir),
        )
        with JOB_LOCK:
            record.process = process

        stdout_data, _ = process.communicate()
        exit_code = process.returncode
        with JOB_LOCK:
            record.process = None

        cancelled = cancel_event.is_set()
        error_message: Optional[str] = None
        if cancelled:
            error_message = "Job cancelled by user."
        elif exit_code != 0:
            tail = (stdout_data or "").strip().splitlines()
            detail = tail[-1] if tail else ""
            error_message = f"uma-ase exited with status {exit_code}."
            if detail:
                error_message = f"{error_message} {detail}"
        else:
            if record.opt_path and record.opt_path.exists():
                try:
                    atoms_opt = read(str(record.opt_path))
                    formula_opt = atoms_opt.get_chemical_formula()
                    comment = " ".join(
                        part
                        for part in [
                            formula_opt,
                            f"charge={record.charge}",
                            f"spin={record.spin}",
                        ]
                        if part
                    )
                    write(str(record.opt_path), atoms_opt, format="xyz", comment=comment)
                except Exception as exc:
                    error_message = f"Optimized geometry rewrite failed: {exc}"

        cml_path = None
        if not cancelled and error_message is None and record.log_path and record.log_path.exists():
            candidate = record.log_path.with_suffix(".xml")
            if not candidate.exists():
                try:
                    convert_log(record.log_path, candidate)
                except Exception as exc:
                    app.logger.warning("CML export failed for %s: %s", record.log_path, exc)
            if candidate.exists():
                cml_path = candidate

        visual_traj = None
        if record.traj_path and record.traj_path.exists():
            visual_traj = _export_traj_for_visualization(record.traj_path, log_path=record.log_path)

        with JOB_LOCK:
            if cancelled:
                record.status = "cancelled"
                record.message = "Job cancelled by user."
            elif error_message:
                record.status = "error"
                record.message = error_message
            else:
                record.status = "completed"

            if record.log_path and record.log_path.exists():
                record.log_url = f"/api/uma-ase/job/{record.job_id}/log"
            if cml_path is not None:
                record.cml_path = cml_path
                record.cml_url = f"/api/uma-ase/job/{record.job_id}/cml"
            if visual_traj is not None and visual_traj.exists():
                record.traj_visual_path = visual_traj
            if record.traj_path and record.traj_path.exists():
                record.traj_url = f"/api/uma-ase/job/{record.job_id}/trajectory"
            if record.opt_path and record.opt_path.exists():
                record.opt_url = f"/api/uma-ase/job/{record.job_id}/optimized"
            if record.freq_archive_path is None:
                fallback_zip = next(
                    (path for path in record.job_dir.glob("*-FREQS-modes.zip") if path.is_file()),
                    None,
                )
                if fallback_zip:
                    record.freq_archive_path = fallback_zip
            if _freqs_ready(record):
                record.freq_archive_url = f"/api/uma-ase/job/{record.job_id}/frequencies"
            record.worker = None

    except Exception as exc:
        with JOB_LOCK:
            record.status = "error" if not cancel_event.is_set() else "cancelled"
            record.message = "Job cancelled by user." if cancel_event.is_set() else str(exc)
            record.worker = None


def _execute_md_job(
    record: JobRecord,
    filename: str,
    md_options: Dict[str, Any],
):
    job_dir = record.job_dir
    input_path = job_dir / filename
    stem = Path(filename).stem or "input"
    log_path = record.log_path or job_dir / f"{stem}-MD.log"
    traj_path = record.traj_path or job_dir / f"{stem}-MD.traj"
    final_path = record.opt_path or job_dir / f"{stem}-MD-final.xyz"
    multi_xyz_path = job_dir / f"{stem}-MD.xyz"
    md_steps = md_options.get("steps", 0)
    traj_interval = max(1, int(md_options.get("traj_interval", 10)))
    log_interval = max(1, int(md_options.get("log_interval", 10)))
    engine = md_options.get("engine", "langevin")
    label = MD_ENGINE_LABELS.get(engine, engine)
    temperature = md_options.get("temperature", 300.0)
    timestep_fs = md_options.get("timestep_fs", 0.5)
    friction = md_options.get("friction", 0.0)
    relax_fs = md_options.get("relax_fs", 100.0)
    checkpoint = md_options.get("mlff_checkpoint")
    task = md_options.get("mlff_task")
    use_pbc = bool(md_options.get("use_pbc"))
    cell_parameters = md_options.get("cell_parameters")
    use_input_velocities = bool(md_options.get("use_input_velocities"))
    input_velocities = md_options.get("input_velocities")
    cancel_event = record.cancel_event
    if cancel_event is None:
        cancel_event = threading.Event()
        record.cancel_event = cancel_event

    if record.md_options is None:
        record.md_options = {}
    record.md_options.setdefault("multi_xyz", multi_xyz_path)
    record.md_multi_xyz = multi_xyz_path

    run_wall_start = time.perf_counter()
    run_cpu_start = time.process_time()
    success = False
    error_message: Optional[str] = None
    trajectory = None
    try:
        with configure_logging(log_path) as logger:
            try:
                args = SimpleNamespace(
                    input=str(input_path),
                    chg=record.charge,
                    spin=record.spin,
                    optimizer="",
                    grad=0.0,
                    iter=md_steps,
                    run_type=["md"],
                    mlff_chk=checkpoint,
                    mlff_task=task,
                    temp=temperature,
                    press=0.0,
                    md_engine=engine,
                    md_steps=md_steps,
                    md_timestep_fs=timestep_fs,
                    md_temperature=temperature,
                    md_friction=md_options.get("friction"),
                    md_traj_interval=md_options.get("traj_interval"),
                    md_log_interval=md_options.get("log_interval"),
                    md_relax_fs=md_options.get("relax_fs"),
                    md_pressure_bar=md_options.get("pressure_bar"),
                    md_compressibility_bar=md_options.get("compressibility_bar"),
                    md_barostat_relax_fs=md_options.get("barostat_relax_fs"),
                    md_nose_tdamp_fs=md_options.get("nose_tdamp_fs"),
                    md_nose_tchain=md_options.get("nose_tchain"),
                    md_nose_tloop=md_options.get("nose_tloop"),
                    md_bussi_taut_fs=md_options.get("bussi_taut_fs"),
                    md_andersen_prob=md_options.get("andersen_prob"),
                    md_mtk_tdamp_fs=md_options.get("mtk_tdamp_fs"),
                    md_mtk_pdamp_fs=md_options.get("mtk_pdamp_fs"),
                    md_mtk_tchain=md_options.get("mtk_tchain"),
                    md_mtk_pchain=md_options.get("mtk_pchain"),
                    md_mtk_tloop=md_options.get("mtk_tloop"),
                    md_mtk_ploop=md_options.get("mtk_ploop"),
                    md_baoab_ttau_fs=md_options.get("baoab_ttau_fs"),
                    md_baoab_ptau_fs=md_options.get("baoab_ptau_fs"),
                    md_baoab_pmass=md_options.get("baoab_pmass"),
                    md_baoab_pmass_factor=md_options.get("baoab_pmass_factor"),
                    md_baoab_hydrostatic=md_options.get("baoab_hydrostatic"),
                    md_melchionna_ttime_fs=md_options.get("melchionna_ttime_fs"),
                    md_melchionna_ptime_fs=md_options.get("melchionna_ptime_fs"),
                    md_melchionna_bulk_gpa=md_options.get("melchionna_bulk_gpa"),
                    md_melchionna_mask=md_options.get("melchionna_mask"),
                    md_use_pbc=use_pbc,
                    md_cell_a=cell_parameters[0] if cell_parameters else None,
                    md_cell_b=cell_parameters[1] if cell_parameters else None,
                    md_cell_c=cell_parameters[2] if cell_parameters else None,
                    md_cell_alpha=cell_parameters[3] if cell_parameters else None,
                    md_cell_beta=cell_parameters[4] if cell_parameters else None,
                    md_cell_gamma=cell_parameters[5] if cell_parameters else None,
                    md_use_input_velocities=use_input_velocities,
                    md_velocities=input_velocities,
                    cpu=False,
                    visualize=False,
                    _chg_explicit=True,
                    _spin_explicit=True,
                )
                status, context = setup_calculated_atoms(args, logger)
                if status != 0 or context is None:
                    raise RuntimeError("UMA calculator setup failed; see log for details.")

                _log_md_metadata(
                    logger,
                    record=record,
                    context=context,
                    md_options=md_options,
                    engine=engine,
                    use_pbc=use_pbc,
                    cell_parameters=cell_parameters,
                )

                if cancel_event.is_set():
                    logger.info("Cancellation requested before MD start.")
                    raise JobCancelled("MD job cancelled by user.")

                logger.info("*****************************************************************************")
                logger.info("*                        Molecular Dynamics Run                             *")
                logger.info("*****************************************************************************")
                show_temperature = engine != "velocity_verlet" or not use_input_velocities
                logger.info("* Engine          : %s", label)
                logger.info("* Steps           : %s", md_steps)
                logger.info("* Timestep (fs)   : %.4f", timestep_fs)
                if show_temperature:
                    logger.info("* Temperature (K) : %.2f", temperature)
                if engine == "langevin":
                    logger.info("* Friction (1/fs) : %.4f", friction)
                logger.info("* Traj interval   : %d", traj_interval)
                logger.info("* Log interval    : %d", log_interval)
                logger.info("* Checkpoint      : %s", checkpoint)
                logger.info("* Task            : %s", task)
                if use_input_velocities:
                    logger.info("* Velocities      : restart (input)")
                logger.info("* Periodic BC     : %s", "enabled" if use_pbc else "disabled")
                if use_pbc and cell_parameters:
                    logger.info(
                        "* Cell (Å | °)    : a=%.3f b=%.3f c=%.3f | α=%.2f β=%.2f γ=%.2f",
                        cell_parameters[0],
                        cell_parameters[1],
                        cell_parameters[2],
                        cell_parameters[3],
                        cell_parameters[4],
                        cell_parameters[5],
                    )
                if engine in {"npt_berendsen", "isotropic_mtk_npt", "mtk_npt", "langevin_baoab_npt", "melchionna_npt"}:
                    logger.info("* Pressure (bar)  : %.6f", md_options.get("pressure_bar", 0.0))
                if engine in {"nvt_berendsen", "npt_berendsen"}:
                    logger.info("* Thermostat tau  : %.4f fs", relax_fs)
                if engine == "npt_berendsen":
                    logger.info("* Barostat tau    : %.4f fs", md_options.get("barostat_relax_fs", 1000.0))
                    logger.info("* Compressibility : %.6e 1/bar", md_options.get("compressibility_bar", 0.0))
                if engine == "nose_hoover_chain":
                    logger.info(
                        "* Nosé-Hoover     : tdamp=%.4f fs | chain=%d | tloop=%d",
                        md_options.get("nose_tdamp_fs", relax_fs),
                        md_options.get("nose_tchain", 3),
                        md_options.get("nose_tloop", 1),
                    )
                if engine == "bussi":
                    logger.info("* Bussi taut      : %.4f fs", md_options.get("bussi_taut_fs", relax_fs))
                if engine == "andersen":
                    logger.info("* Andersen prob   : %.6f", md_options.get("andersen_prob", 0.0))
                if engine in {"isotropic_mtk_npt", "mtk_npt"}:
                    logger.info(
                        "* MTK params      : tdamp=%.4f fs | pdamp=%.4f fs | tchain=%d | pchain=%d",
                        md_options.get("mtk_tdamp_fs", 100.0),
                        md_options.get("mtk_pdamp_fs", 1000.0),
                        md_options.get("mtk_tchain", 3),
                        md_options.get("mtk_pchain", 3),
                    )
                if engine in {"langevin_baoab_nvt", "langevin_baoab_npt"}:
                    if engine == "langevin_baoab_nvt":
                        logger.info(
                            "* BAOAB params    : T_tau=%.4f fs",
                            md_options.get("baoab_ttau_fs", 50.0),
                        )
                    else:
                        logger.info(
                            "* BAOAB params    : T_tau=%.4f fs | P_tau=%.4f fs | hydrostatic=%s",
                            md_options.get("baoab_ttau_fs", 50.0),
                            md_options.get("baoab_ptau_fs", 1000.0),
                            md_options.get("baoab_hydrostatic", False),
                        )
                if engine == "melchionna_npt":
                    logger.info(
                        "* Melchionna      : ttime=%.4f fs | ptime=%.4f fs | bulk=%.3f GPa",
                        md_options.get("melchionna_ttime_fs", 25.0),
                        md_options.get("melchionna_ptime_fs", 75.0),
                        md_options.get("melchionna_bulk_gpa", 100.0),
                    )
                logger.info("*****************************************************************************")

                atoms = context.atoms
                if use_pbc:
                    if cell_parameters:
                        try:
                            cell_matrix = cellpar_to_cell(cell_parameters)
                        except Exception as exc:
                            raise RuntimeError(f"Invalid cell parameters: {exc}") from exc
                        atoms.set_cell(cell_matrix, scale_atoms=True)
                    atoms.set_pbc(True)
                    cell_vectors = atoms.get_cell()
                    logger.info("Cell matrix (Å):")
                    axis_labels = ("a", "b", "c")
                    for axis_label, vector in zip(axis_labels, cell_vectors):
                        logger.info("  %s = [%.6f %.6f %.6f]", axis_label, vector[0], vector[1], vector[2])
                else:
                    atoms.set_pbc(False)
                velocities_loaded = False
                if use_input_velocities and input_velocities:
                    try:
                        if len(input_velocities) != len(atoms):
                            raise ValueError("Velocity count does not match atoms count.")
                        converted = []
                        for row in input_velocities:
                            if not isinstance(row, (list, tuple)) or len(row) != 3:
                                raise ValueError("Velocity rows must be length-3 lists.")
                            converted.append([float(value) for value in row])
                        atoms.set_velocities(converted)
                        velocities_loaded = True
                        logger.info("Input velocities loaded for MD restart.")
                    except Exception as exc:
                        logger.warning("Unable to apply input velocities: %s", exc)
                if not velocities_loaded:
                    _initialise_md_velocities(atoms, temperature, logger)
                else:
                    logger.info("MD restart: using velocities from previous trajectory.")
                    logger.info("Velocity initialisation skipped (restart).")
                dynamics = _create_md_dynamics(
                    engine,
                    atoms,
                    md_options=md_options,
                )
                trajectory = Trajectory(str(traj_path), "w", atoms)

                def log_step():
                    step = dynamics.nsteps
                    potential = atoms.get_potential_energy()
                    kinetic = atoms.get_kinetic_energy()
                    total = potential + kinetic
                    inst_temp = atoms.get_temperature()
                    logger.info(
                        "Step %d/%d | E_pot=%.6f eV | E_kin=%.6f eV | E_tot=%.6f eV | T=%.2f K",
                        step,
                        md_steps,
                        potential,
                        kinetic,
                        total,
                        inst_temp,
                    )

                dynamics.attach(log_step, interval=log_interval)
                dynamics.attach(trajectory.write, interval=traj_interval)
                def record_xyz_frame():
                    step = dynamics.nsteps
                    try:
                        potential = atoms.get_potential_energy()
                    except Exception:
                        potential = float("nan")
                    time_fs = step * timestep_fs
                    comment_parts = [f"step={step}", f"time_fs={time_fs:.6f}", f"energy_ev={potential:.8f}"]
                    lattice_comment = _lattice_comment_from_atoms(atoms)
                    if lattice_comment:
                        comment_parts.append(lattice_comment)
                    comment = " ".join(comment_parts)
                    write(
                        multi_xyz_path,
                        atoms,
                        format="xyz",
                        append=True,
                        comment=comment,
                    )

                dynamics.attach(record_xyz_frame, interval=traj_interval)

                logger.info(
                    "Starting %s dynamics for %d steps (dt=%.4f fs).",
                    label,
                    md_steps,
                    timestep_fs,
                )
                with log_timing(logger, "MD integration"):
                    chunk = max(1, min(100, traj_interval, log_interval))
                    completed = 0
                    while completed < md_steps:
                        if cancel_event.is_set():
                            logger.info("Cancellation requested. Halting MD integration.")
                            raise JobCancelled("MD job cancelled by user.")
                        steps_this_round = min(chunk, md_steps - completed)
                        dynamics.run(steps_this_round)
                        completed += steps_this_round
                logger.info("Molecular dynamics finished successfully.")

                if not cancel_event.is_set():
                    try:
                        write(
                            str(final_path),
                            atoms,
                            format="xyz",
                            comment=f"MD final snapshot | charge={record.charge} spin={record.spin}",
                        )
                        logger.info("Final files saved to %s", final_path)
                    except Exception as exc:
                        logger.warning("Unable to write final files: %s", exc)
            finally:
                log_total_timing(logger, "md", run_wall_start, run_cpu_start)

        success = True
    except JobCancelled as exc:
        error_message = str(exc)
        logger = app.logger
        logger.info("MD job %s cancelled by user.", record.job_id)
    except Exception as exc:
        error_message = str(exc)
        app.logger.exception("MD job %s failed", record.job_id)
    finally:
        cml_path = None
        if success and log_path.exists():
            candidate = log_path.with_suffix(".xml")
            if not candidate.exists():
                try:
                    convert_log(log_path, candidate)
                except Exception as exc:
                    app.logger.warning("CML export failed for %s: %s", log_path, exc)
            if candidate.exists():
                cml_path = candidate

        if trajectory is not None:
            with suppress(Exception):
                trajectory.close()

        with JOB_LOCK:
            if cancel_event.is_set():
                record.status = "cancelled"
                record.message = error_message or "MD job cancelled by user."
            elif success:
                record.status = "completed"
                record.message = None
            else:
                record.status = "error"
                record.message = error_message or "MD job failed."

            if log_path.exists():
                record.log_url = f"/api/uma-ase/job/{record.job_id}/log"
            if cml_path is not None:
                record.cml_path = cml_path
                record.cml_url = f"/api/uma-ase/job/{record.job_id}/cml"
            if traj_path.exists():
                record.traj_url = f"/api/uma-ase/job/{record.job_id}/trajectory"
            if final_path.exists():
                record.opt_url = f"/api/uma-ase/job/{record.job_id}/optimized"
            if multi_xyz_path.exists():
                record.md_multi_xyz = multi_xyz_path
                record.md_multi_xyz_url = f"/api/uma-ase/job/{record.job_id}/md_xyz"
                record.traj_visual_path = multi_xyz_path
            if multi_xyz_path.exists():
                record.md_options["multi_xyz"] = multi_xyz_path
            record.worker = None


def _send_job_file(path: Optional[Path], mimetype: str = "text/plain"):
    if path is None or not path.exists():
        abort(404)
    return send_file(
        path,
        mimetype=mimetype,
        as_attachment=True,
        download_name=path.name,
    )


def _find_use_file(record: JobRecord) -> Optional[Path]:
    job_dir = record.job_dir
    if not job_dir or not job_dir.exists():
        return None
    if record.input_filename:
        candidate = job_dir / f"{Path(record.input_filename).stem}.use"
        if candidate.exists():
            return candidate
    for candidate in job_dir.glob("*.use"):
        if candidate.is_file():
            return candidate
    return None


def _read_log_text(path: Path, *, max_bytes: Optional[int] = None) -> Tuple[str, bool]:
    truncated = False
    try:
        if max_bytes is None:
            data = path.read_bytes()
        else:
            try:
                size = path.stat().st_size
            except OSError:
                size = None
            if size is not None and size > max_bytes:
                truncated = True
                with path.open("rb") as handle:
                    handle.seek(-max_bytes, os.SEEK_END)
                    data = handle.read()
            else:
                data = path.read_bytes()
    except OSError:
        return "", False

    text = data.decode("utf-8", errors="replace")
    if truncated:
        prefix = "...(log truncated)\n"
        text = f"{prefix}{text}"
    return text, truncated


def _is_allowed_iochem_host(host: Optional[str]) -> bool:
    if not host:
        return False
    host = host.lower()
    if host in IOCHEM_PROXY_HOSTS:
        return True
    return any(host.endswith(f".{allowed}") for allowed in IOCHEM_PROXY_HOSTS)


def _sanitize_iochem_token(raw_token: Optional[str]) -> Optional[str]:
    if not raw_token:
        return None
    token = str(raw_token).strip()
    if not token:
        return None
    if not re.fullmatch(r"[A-Za-z0-9_-]{6,64}", token):
        return None
    return token


def _replace_placeholder_host(target_url: str, base_url: str) -> str:
    try:
        parsed = urlparse(target_url)
    except Exception:
        return target_url
    if parsed.hostname != IOCHEM_PROXY_PLACEHOLDER_HOST:
        return target_url
    if parsed.path.lower().startswith("/jsmol.php"):
        return parsed._replace(scheme="", netloc="").geturl()
    base = urlparse(base_url)
    scheme = base.scheme or "https"
    host = base.hostname or urlparse(IOCHEM_PROXY_DEFAULT_ORIGIN).hostname or "iochem-bd.iciq.es"
    netloc = host
    if base.port:
        netloc = f"{host}:{base.port}"
    return parsed._replace(scheme=scheme, netloc=netloc).geturl()


def _replace_placeholder_text(html_text: str, base_url: str) -> str:
    html_text = html_text.replace("http://your.server.here/jsmol.php", "/jsmol.php")
    html_text = html_text.replace("https://your.server.here/jsmol.php", "/jsmol.php")
    html_text = html_text.replace("//your.server.here/jsmol.php", "/jsmol.php")
    return html_text


def _get_iochem_session(token: str) -> requests.Session:
    now = time.time()
    with IOCHEM_PROXY_LOCK:
        stale_tokens = [
            key
            for key, (_, last_used) in IOCHEM_PROXY_SESSIONS.items()
            if now - last_used > IOCHEM_PROXY_TTL_SECONDS
        ]
        for key in stale_tokens:
            session, _ = IOCHEM_PROXY_SESSIONS.pop(key)
            with suppress(Exception):
                session.close()

        if token in IOCHEM_PROXY_SESSIONS:
            session, _ = IOCHEM_PROXY_SESSIONS[token]
            IOCHEM_PROXY_SESSIONS[token] = (session, now)
            return session

        while len(IOCHEM_PROXY_SESSIONS) >= IOCHEM_PROXY_MAX_SESSIONS:
            oldest_key = min(
                IOCHEM_PROXY_SESSIONS.items(),
                key=lambda item: item[1][1],
            )[0]
            session, _ = IOCHEM_PROXY_SESSIONS.pop(oldest_key)
            with suppress(Exception):
                session.close()

        session = requests.Session()
        IOCHEM_PROXY_SESSIONS[token] = (session, now)
        return session


def _build_iochem_proxy_url(target_url: str, token: str) -> str:
    query = {"token": token, "url": target_url}
    return f"/api/uma-ase/iochem/proxy?{requests.compat.urlencode(query)}"


def _unwrap_proxy_url(raw_url: str) -> str:
    try:
        parsed = urlparse(raw_url)
    except Exception:
        return raw_url
    if parsed.path != "/api/uma-ase/iochem/proxy":
        return raw_url
    params = parse_qs(parsed.query or "")
    target = params.get("url", [])
    if not target:
        return raw_url
    return target[0]


def _rewrite_css_urls(css_text: str, base_url: str, token: str) -> str:
    def replace_url(match):
        raw = match.group(1).strip().strip("'\"")
        if not raw or raw.startswith(("data:", "mailto:", "javascript:")):
            return match.group(0)
        if "/api/uma-ase/iochem/proxy" in raw:
            return match.group(0)
        absolute = requests.compat.urljoin(base_url, raw)
        absolute = _replace_placeholder_host(absolute, base_url)
        if "/api/uma-ase/iochem/proxy" in absolute:
            return match.group(0)
        parsed = urlparse(absolute)
        if not _is_allowed_iochem_host(parsed.hostname):
            return match.group(0)
        return f"url('{_build_iochem_proxy_url(absolute, token)}')"

    def replace_import(match):
        raw = match.group(1).strip().strip("'\"")
        if not raw or raw.startswith(("data:", "mailto:", "javascript:")):
            return match.group(0)
        if "/api/uma-ase/iochem/proxy" in raw:
            return match.group(0)
        absolute = requests.compat.urljoin(base_url, raw)
        absolute = _replace_placeholder_host(absolute, base_url)
        if "/api/uma-ase/iochem/proxy" in absolute:
            return match.group(0)
        parsed = urlparse(absolute)
        if not _is_allowed_iochem_host(parsed.hostname):
            return match.group(0)
        return f"@import url('{_build_iochem_proxy_url(absolute, token)}')"

    css_text = re.sub(r"url\(([^)]+)\)", replace_url, css_text, flags=re.IGNORECASE)
    css_text = re.sub(r"@import\s+(?:url\()?['\"]?([^'\"\)]+)", replace_import, css_text, flags=re.IGNORECASE)
    return css_text


class _IoChemHtmlRewriter(HTMLParser):
    def __init__(self, base_url: str, token: str):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.token = token
        self.parts: List[str] = []
        self.in_style = False
        self.injected = False

    def _injection_html(self) -> str:
        proxy_base = "/api/uma-ase/iochem/proxy"
        allowed_hosts = sorted(IOCHEM_PROXY_HOSTS)
        payload = {
            "proxyBase": proxy_base,
            "token": self.token,
            "baseUrl": self.base_url,
            "hosts": allowed_hosts,
            "placeholderHost": IOCHEM_PROXY_PLACEHOLDER_HOST,
        }
        data = json.dumps(payload)
        return (
            "<script>"
            "(function(){"
            f"var cfg={data};"
            "function isAllowedHost(host){"
            "host=(host||'').toLowerCase();"
            "return cfg.hosts.some(function(h){return host===h || host.endsWith('.'+h);});"
            "}"
            "function toAbsolute(url){"
            "try{"
            "return new URL(url, cfg.baseUrl).toString();"
            "}catch(e){return url;}"
            "}"
            "function buildProxyUrl(url){"
            "if(!url){return url;}"
            "var absolute=toAbsolute(url);"
            "try{"
            "var parsed=new URL(absolute);"
            "if(parsed.pathname===cfg.proxyBase){"
            "var localProxy=new URL(parsed.pathname+parsed.search+parsed.hash, window.location.origin);"
            "return localProxy.toString();"
            "}"
            "if(parsed.protocol!=='http:'&&parsed.protocol!=='https:'){return url;}"
            "if(parsed.origin===window.location.origin){"
            "var baseOrigin=new URL(cfg.baseUrl).origin;"
            "if(parsed.pathname.indexOf('/api/')===0||parsed.pathname.indexOf('/browse/')===0||parsed.pathname.indexOf('/create/')===0||parsed.pathname.indexOf('/zkau')===0){"
            "parsed=new URL(parsed.pathname+parsed.search+parsed.hash, baseOrigin);"
            "absolute=parsed.toString();"
            "}"
            "}"
            "if(parsed.hostname===cfg.placeholderHost){"
            "if(parsed.pathname.toLowerCase().indexOf('/jsmol.php')===0){"
            "return new URL(parsed.pathname+parsed.search+parsed.hash, window.location.origin).toString();"
            "}"
            "var base=new URL(cfg.baseUrl);"
            "parsed=new URL(parsed.pathname+parsed.search+parsed.hash, base.origin);"
            "absolute=parsed.toString();"
            "}"
            "if(!isAllowedHost(parsed.hostname)){return url;}"
            "var params=new URLSearchParams({token:cfg.token,url:absolute});"
            "return cfg.proxyBase+'?'+params.toString();"
            "}catch(e){return url;}"
            "}"
            "function rewriteSrcset(value){"
            "if(!value){return value;}"
            "return value.split(',').map(function(part){"
            "var trimmed=part.trim();"
            "if(!trimmed){return '';}"
            "var seg=trimmed.split(/\\s+/);"
            "var url=seg.shift();"
            "var prox=buildProxyUrl(url);"
            "seg.unshift(prox||url);"
            "return seg.join(' ');"
            "}).filter(Boolean).join(', ');"
            "}"
            "function rewriteAttr(el,attr){"
            "if(!el||!el.getAttribute){return;}"
            "var val=el.getAttribute(attr);"
            "if(!val){return;}"
            "var prox=buildProxyUrl(val);"
            "if(prox&&prox!==val){el.setAttribute(attr,prox);}"
            "}"
            "function rewriteElement(el){"
            "if(!el||!el.tagName){return;}"
            "var tag=el.tagName.toLowerCase();"
            "if(tag==='script'||tag==='img'||tag==='iframe'||tag==='source'||tag==='track'||tag==='video'||tag==='audio'){"
            "rewriteAttr(el,'src');"
            "if(el.getAttribute&&el.getAttribute('srcset')){el.setAttribute('srcset',rewriteSrcset(el.getAttribute('srcset')));}"
            "}"
            "if(tag==='link'||tag==='a'){rewriteAttr(el,'href');}"
            "if(tag==='form'){rewriteAttr(el,'action');}"
            "if(tag==='object'){rewriteAttr(el,'data');}"
            "if(tag==='video'||tag==='audio'||tag==='img'){rewriteAttr(el,'poster');}"
            "}"
            "function rewriteTree(root){"
            "if(!root){return;}"
            "rewriteElement(root);"
            "if(root.querySelectorAll){"
            "root.querySelectorAll('a,link,script,iframe,img,form,object,source,video,audio,track').forEach(rewriteElement);"
            "}"
            "}"
            "function startObserver(){"
            "rewriteTree(document.documentElement);"
            "var observer=new MutationObserver(function(mutations){"
            "mutations.forEach(function(m){"
            "if(m.type==='attributes'){"
            "rewriteElement(m.target);"
            "}"
            "if(m.type==='childList'&&m.addedNodes){"
            "m.addedNodes.forEach(function(node){"
            "if(node&&node.nodeType===1){rewriteTree(node);}"
            "});"
            "}"
            "});"
            "});"
            "observer.observe(document.documentElement,{subtree:true,childList:true,attributes:true,attributeFilter:['src','href','action','data','poster','srcset']});"
            "document.addEventListener('submit',function(ev){"
            "var target=ev&&ev.target;"
            "if(target&&target.tagName&&target.tagName.toLowerCase()==='form'){rewriteAttr(target,'action');}"
            "},true);"
            "}"
            "if(document.readyState==='loading'){"
            "document.addEventListener('DOMContentLoaded',startObserver);"
            "}else{startObserver();}"
            "var origFetch=window.fetch;"
            "if(origFetch){"
            "window.fetch=function(input,init){"
            "try{"
            "if(typeof input==='string'){return origFetch(buildProxyUrl(input),init);}"
            "if(input&&input.url){"
            "var proxied=buildProxyUrl(input.url);"
            "if(proxied===input.url){return origFetch(input,init);}"
            "var req=new Request(proxied,input);"
            "return origFetch(req,init);"
            "}"
            "}catch(e){}"
            "return origFetch(input,init);"
            "};"
            "}"
            "var origOpen=XMLHttpRequest.prototype.open;"
            "XMLHttpRequest.prototype.open=function(method,url){"
            "var args=[method,buildProxyUrl(url)];"
            "for(var i=2;i<arguments.length;i++){args.push(arguments[i]);}"
            "return origOpen.apply(this,args);"
            "};"
            "})();"
            "</script>"
        )

    def _rewrite_url(self, raw: Optional[str]) -> Optional[str]:
        if not raw:
            return raw
        value = raw.strip()
        if value.startswith(("#", "data:", "mailto:", "javascript:")):
            return raw
        if "/api/uma-ase/iochem/proxy" in value:
            return raw
        absolute = requests.compat.urljoin(self.base_url, value)
        absolute = _replace_placeholder_host(absolute, self.base_url)
        if "/api/uma-ase/iochem/proxy" in absolute:
            return raw
        parsed = urlparse(absolute)
        if parsed.scheme not in ("http", "https"):
            return raw
        if not _is_allowed_iochem_host(parsed.hostname):
            return raw
        return _build_iochem_proxy_url(absolute, self.token)

    def _rewrite_srcset(self, raw: str) -> str:
        entries = []
        for entry in raw.split(","):
            item = entry.strip()
            if not item:
                continue
            parts = item.split()
            url_part = parts[0]
            rewritten = self._rewrite_url(url_part) or url_part
            entries.append(" ".join([rewritten] + parts[1:]))
        return ", ".join(entries)

    def handle_starttag(self, tag, attrs):
        if tag.lower() == "style":
            self.in_style = True

        if tag.lower() == "meta":
            attr_dict = {key.lower(): value for key, value in attrs}
            if attr_dict.get("http-equiv", "").lower() == "content-security-policy":
                return

        clean_attrs = []
        for key, value in attrs:
            if key in {"href", "src", "action", "data", "poster"}:
                clean_attrs.append((key, self._rewrite_url(value)))
            elif key == "srcset" and value:
                clean_attrs.append((key, self._rewrite_srcset(value)))
            elif key == "style" and value:
                clean_attrs.append((key, _rewrite_css_urls(value, self.base_url, self.token)))
            else:
                clean_attrs.append((key, value))
        attr_text = "".join(
            f' {key}="{escape(str(value), HTML_ATTR_ESCAPE_MAP)}"' if value is not None else f" {key}"
            for key, value in clean_attrs
        )
        self.parts.append(f"<{tag}{attr_text}>")
        if tag.lower() in {"head", "body"} and not self.injected:
            self.parts.append(self._injection_html())
            self.injected = True

    def handle_startendtag(self, tag, attrs):
        clean_attrs = []
        for key, value in attrs:
            if key in {"href", "src", "action", "data", "poster"}:
                clean_attrs.append((key, self._rewrite_url(value)))
            elif key == "srcset" and value:
                clean_attrs.append((key, self._rewrite_srcset(value)))
            elif key == "style" and value:
                clean_attrs.append((key, _rewrite_css_urls(value, self.base_url, self.token)))
            else:
                clean_attrs.append((key, value))
        attr_text = "".join(
            f' {key}="{escape(str(value), HTML_ATTR_ESCAPE_MAP)}"' if value is not None else f" {key}"
            for key, value in clean_attrs
        )
        self.parts.append(f"<{tag}{attr_text} />")

    def handle_endtag(self, tag):
        if tag.lower() == "style":
            self.in_style = False
        self.parts.append(f"</{tag}>")

    def handle_data(self, data):
        if self.in_style:
            self.parts.append(_rewrite_css_urls(data, self.base_url, self.token))
        else:
            self.parts.append(data)

    def handle_comment(self, data):
        self.parts.append(f"<!--{data}-->")

    def handle_decl(self, decl):
        self.parts.append(f"<!{decl}>")

    def get_html(self) -> str:
        if not self.injected:
            self.parts.append(self._injection_html())
            self.injected = True
        return "".join(self.parts)


def _rewrite_iochem_html(html_text: str, base_url: str, token: str) -> str:
    html_text = _replace_placeholder_text(html_text, base_url)
    parser = _IoChemHtmlRewriter(base_url, token)
    parser.feed(html_text)
    parser.close()
    return parser.get_html()


def _decode_response_text(response: requests.Response, default_encoding: str = "utf-8") -> str:
    encoding = response.encoding or default_encoding
    try:
        return response.content.decode(encoding, errors="replace")
    except LookupError:
        return response.content.decode(default_encoding, errors="replace")


@app.route("/api/uma-ase/job/<job_id>", methods=["GET"])
def job_status(job_id: str):
    record = _get_job(job_id)
    queue_position = _queue_positions().get(job_id) if record.status == "queued" else None
    if _freqs_ready(record):
        record.freq_archive_url = f"/api/uma-ase/job/{record.job_id}/frequencies"
    if record.cml_path and record.cml_path.exists():
        record.cml_url = f"/api/uma-ase/job/{record.job_id}/cml"
    log_text = ""
    log_truncated = False
    if record.log_path and record.log_path.exists():
        log_text, log_truncated = _read_log_text(
            record.log_path,
            max_bytes=LOG_STATUS_MAX_BYTES,
        )
    traj_download = _job_traj_download_url(record)
    traj_frames_download = _job_traj_frames_download_url(record)
    return jsonify(
        {
            "status": record.status,
            "message": record.message,
            "log": log_text,
            "log_truncated": log_truncated,
            "log_download": record.log_url,
            "traj_download": traj_download,
            "traj_frames_download": traj_frames_download,
            "traj_raw_download": f"/api/uma-ase/job/{record.job_id}/trajectory_raw" if record.traj_path and record.traj_path.exists() else None,
            "opt_download": record.opt_url,
            "md_xyz_download": f"/api/uma-ase/job/{record.job_id}/md_xyz" if record.md_multi_xyz and record.md_multi_xyz.exists() else None,
            "freqs_download": record.freq_archive_url,
            "cml_download": record.cml_url,
            "queue_position": queue_position,
            "created_at": _format_time(record.created_at),
            "queued_at": _format_time(record.queued_at),
            "started_at": _format_time(record.started_at),
            "finished_at": _format_time(record.finished_at),
            "elapsed_seconds": _job_elapsed_seconds(record),
            "queued_seconds": _job_queue_seconds(record),
        }
    )


@app.route("/api/uma-ase/jobs", methods=["GET"])
def list_jobs():
    queue_positions = _queue_positions()
    with JOB_LOCK:
        records = [record for record in JOBS.values() if not record.deleted]
    total_bytes = sum(_job_dir_size(record.job_dir) for record in records if record.job_dir)
    max_bytes = _parse_job_byte_limit(app.config.get("UMA_JOBS_MAX_BYTES"))
    jobs = []
    for record in records:
        traj_available = record.traj_path is not None and record.traj_path.exists()
        if record.log_path and record.log_path.exists():
            record.log_url = f"/api/uma-ase/job/{record.job_id}/log"
        if record.cml_path and record.cml_path.exists():
            record.cml_url = f"/api/uma-ase/job/{record.job_id}/cml"
        if traj_available:
            record.traj_url = f"/api/uma-ase/job/{record.job_id}/trajectory"
        if record.opt_path and record.opt_path.exists():
            record.opt_url = f"/api/uma-ase/job/{record.job_id}/optimized"
        if _freqs_ready(record):
            record.freq_archive_url = f"/api/uma-ase/job/{record.job_id}/frequencies"
        jobs.append(
            {
                "job_id": record.job_id,
                "status": record.status,
                "message": record.message,
                "job_kind": record.job_kind,
                "run_types": record.run_types,
                "job_name": record.job_name or (str(record.relative_path) if record.relative_path else None),
                "job_source": record.job_source,
                "queue_position": queue_positions.get(record.job_id),
                "created_at": _format_time(record.created_at),
                "queued_at": _format_time(record.queued_at),
                "started_at": _format_time(record.started_at),
                "finished_at": _format_time(record.finished_at),
                "elapsed_seconds": _job_elapsed_seconds(record),
                "cml_download": record.cml_url,
                "traj_download": record.traj_url,
                "md_restart_available": record.job_kind == "md" and traj_available,
            }
        )
    jobs.sort(key=lambda item: item.get("created_at") or "", reverse=True)
    return jsonify({"status": "ok", "jobs": jobs, "total_bytes": total_bytes, "max_bytes": max_bytes})


@app.route("/api/uma-ase/job/<job_id>/cancel", methods=["POST"])
def cancel_job(job_id: str):
    record = _get_job(job_id)
    with JOB_LOCK:
        if record.status not in ("running", "queued"):
            return jsonify({"status": "error", "message": "Job is not running or queued."}), 400
        cancel_event = record.cancel_event
        if cancel_event is None:
            cancel_event = threading.Event()
            record.cancel_event = cancel_event
        already_set = cancel_event.is_set()
        cancel_event.set()
        process = record.process
        job_kind = record.job_kind
        record.message = "Cancellation requested..."
    _persist_job(record)
    if record.status == "queued":
        removed = _remove_from_queue(job_id)
        with JOB_LOCK:
            record.status = "cancelled"
            record.message = "Job cancelled by user."
            record.finished_at = _utcnow()
        _persist_job(record)
        return jsonify({"status": "ok", "removed": removed})
    if already_set:
        return jsonify({"status": "ok"})
    if job_kind == "workflow" and process and process.poll() is None:
        try:
            process.terminate()
        except Exception:
            pass
    return jsonify({"status": "ok"})


@app.route("/api/uma-ase/job/<job_id>/log", methods=["GET"])
def download_job_log(job_id: str):
    record = _get_job(job_id)
    return _send_job_file(record.log_path, "text/plain")


@app.route("/api/uma-ase/job/<job_id>/cml", methods=["GET"])
def download_job_cml(job_id: str):
    record = _get_job(job_id)
    return _send_job_file(record.cml_path, "application/xml")


@app.route("/api/uma-ase/job/<job_id>/trajectory", methods=["GET"])
def download_job_trajectory(job_id: str):
    record = _get_job(job_id)
    visual_source = _resolve_job_traj_visual_source(record)
    if visual_source and visual_source.exists():
        return _send_job_file(visual_source, "text/plain")
    return _send_job_file(record.traj_path, "application/octet-stream")


@app.route("/api/uma-ase/job/<job_id>/trajectory_raw", methods=["GET"])
def download_job_trajectory_raw(job_id: str):
    record = _get_job(job_id)
    return _send_job_file(record.traj_path, "application/octet-stream")


@app.route("/api/uma-ase/job/<job_id>/optimized", methods=["GET"])
def download_job_optimized(job_id: str):
    record = _get_job(job_id)
    return _send_job_file(record.opt_path, "text/plain")


@app.route("/api/uma-ase/job/<job_id>/md_xyz", methods=["GET"])
def download_job_md_xyz(job_id: str):
    record = _get_job(job_id)
    return _send_job_file(record.md_multi_xyz, "text/plain")


@app.route("/api/uma-ase/job/<job_id>/bundle", methods=["GET"])
def download_job_bundle(job_id: str):
    record = _get_job(job_id)
    job_dir = record.job_dir
    if not job_dir or not job_dir.exists():
        abort(404)
    temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
    temp_path = Path(temp_file.name)
    temp_file.close()

    with zipfile.ZipFile(temp_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for path in job_dir.rglob("*"):
            if not path.is_file():
                continue
            try:
                rel_path = path.relative_to(job_dir)
            except ValueError:
                continue
            archive.write(path, rel_path)

    @after_this_request
    def _cleanup_temp(response):  # type: ignore[unused-argument]
        with suppress(FileNotFoundError):
            temp_path.unlink()
        return response

    bundle_name = record.job_name or record.job_id
    safe_name = secure_filename(str(bundle_name)) or record.job_id
    return send_file(
        temp_path,
        as_attachment=True,
        download_name=f"{safe_name}-bundle.zip",
        mimetype="application/zip",
    )


@app.route("/api/uma-ase/job/<job_id>/delete", methods=["POST"])
def delete_job(job_id: str):
    record = _get_job(job_id)
    if record.status in {"running", "queued"}:
        return jsonify({"status": "error", "message": "Cancel the job before deleting."}), 400
    job_dir = record.job_dir
    if job_dir and job_dir.exists():
        shutil.rmtree(job_dir, ignore_errors=True)
    record.deleted = True
    record.status = "deleted"
    record.message = "Job deleted by user."
    record.finished_at = record.finished_at or _utcnow()
    _persist_job(record)
    with JOB_LOCK:
        JOBS.pop(record.job_id, None)
    return jsonify({"status": "ok"})


@app.route("/api/uma-ase/job/<job_id>/rename", methods=["POST"])
def rename_job(job_id: str):
    record = _get_job(job_id)
    payload = request.get_json(silent=True) or {}
    raw_name = (payload.get("name") or "").strip()
    if not raw_name:
        return jsonify({"status": "error", "message": "New job name is required."}), 400
    record.job_name = raw_name
    _persist_job(record)
    return jsonify(
        {
            "status": "ok",
            "job_name": record.job_name,
            "message": "Job label updated.",
        }
    )


def _parse_run_types_value(raw: Optional[str]) -> List[str]:
    if not raw:
        return []
    try:
        parsed = ast.literal_eval(raw)
    except (SyntaxError, ValueError):
        parsed = [raw]
    if isinstance(parsed, (list, tuple)):
        return [str(item).lower() for item in parsed]
    return [str(parsed).lower()]


def _geometry_block_to_xyz(
    geometry: "GeometryBlock",
    *,
    charge: Optional[int],
    spin: Optional[int],
    formula: Optional[str],
) -> str:
    comment_parts = []
    if charge is not None:
        comment_parts.append(str(charge))
    if spin is not None:
        comment_parts.append(str(spin))
    if formula:
        comment_parts.append(str(formula))
    comment = " ".join(comment_parts)
    lines = [str(len(geometry.atoms)), comment]
    for atom in geometry.atoms:
        lines.append(f"{atom.element} {atom.x:.6f} {atom.y:.6f} {atom.z:.6f}")
    return "\n".join(lines) + "\n"


def _load_job_geometry(record: JobRecord, parsed: Optional[LogData]) -> Tuple[Optional[str], Optional[str]]:
    charge_val = None
    spin_val = None
    formula = None
    if parsed:
        header = parsed.header_metadata
        formula = header.get("formula")
        try:
            charge_val = int(float(header.get("charge"))) if header.get("charge") is not None else None
        except (TypeError, ValueError):
            charge_val = None
        try:
            spin_val = int(float(header.get("spin_multiplicity"))) if header.get("spin_multiplicity") is not None else None
        except (TypeError, ValueError):
            spin_val = None
        geometry = None
        if parsed.geometries:
            for key, item in parsed.geometries.items():
                if "initial" in key:
                    geometry = item
                    break
            if geometry is None:
                geometry = next(iter(parsed.geometries.values()))
        if geometry is not None:
            return _geometry_block_to_xyz(geometry, charge=charge_val, spin=spin_val, formula=formula), "log-geometry.xyz"

    input_name = record.input_filename
    if input_name:
        input_path = record.job_dir / input_name
        if input_path.exists():
            return input_path.read_text(encoding="utf-8"), input_path.name

    for candidate in (record.opt_path, record.md_multi_xyz, record.traj_visual_path):
        if candidate and candidate.exists():
            try:
                return candidate.read_text(encoding="utf-8"), candidate.name
            except OSError:
                continue
    return None, None


@app.route("/api/uma-ase/job/<job_id>/view", methods=["GET"])
def view_job(job_id: str):
    record = _get_job(job_id)
    if record.log_path and record.log_path.exists():
        record.log_url = f"/api/uma-ase/job/{record.job_id}/log"
    if record.cml_path and record.cml_path.exists():
        record.cml_url = f"/api/uma-ase/job/{record.job_id}/cml"
    if record.traj_path and record.traj_path.exists():
        record.traj_url = f"/api/uma-ase/job/{record.job_id}/trajectory"
    if record.opt_path and record.opt_path.exists():
        record.opt_url = f"/api/uma-ase/job/{record.job_id}/optimized"
    if _freqs_ready(record):
        record.freq_archive_url = f"/api/uma-ase/job/{record.job_id}/frequencies"
    use_path = _find_use_file(record)
    traj_download = _job_traj_download_url(record)
    traj_frames_download = _job_traj_frames_download_url(record)

    parsed = None
    if record.log_path and record.log_path.exists():
        try:
            parsed = UmaLogParser(record.log_path).parse()
        except Exception:
            parsed = None

    log_preview = None
    log_truncated = False
    if record.log_path and record.log_path.exists():
        log_preview, log_truncated = _read_log_text(record.log_path, max_bytes=LOG_STATUS_MAX_BYTES)

    geometry_text, geometry_name = _load_job_geometry(record, parsed)

    metadata = parsed.metadata if parsed else {}
    header_metadata = parsed.header_metadata if parsed else {}
    run_types = parsed.run_types if parsed else []
    requested_raw = metadata.get("run_type_requested") or metadata.get("run_type")
    run_types_requested = _parse_run_types_value(requested_raw)

    return jsonify(
        {
            "status": "ok",
            "job_id": record.job_id,
            "job_status": record.status,
            "job_kind": record.job_kind,
            "job_source": record.job_source,
            "job_name": record.job_name or (str(record.relative_path) if record.relative_path else None),
            "metadata": metadata,
            "header_metadata": header_metadata,
            "run_types": run_types,
            "run_types_requested": run_types_requested,
            "geometry": geometry_text,
            "geometry_name": geometry_name,
            "log_download": record.log_url,
            "log_preview": log_preview,
            "log_truncated": log_truncated,
            "traj_download": traj_download,
            "traj_frames_download": traj_frames_download,
            "opt_download": record.opt_url,
            "freqs_download": record.freq_archive_url,
            "cml_download": record.cml_url,
            "use_download": f"/api/uma-ase/job/{record.job_id}/use" if use_path else None,
            "md_xyz_download": f"/api/uma-ase/job/{record.job_id}/md_xyz"
            if record.md_multi_xyz and record.md_multi_xyz.exists()
            else None,
        }
    )


@app.route("/api/uma-ase/job/<job_id>/use", methods=["GET"])
def download_job_use(job_id: str):
    record = _get_job(job_id)
    use_path = _find_use_file(record)
    return _send_job_file(use_path, "application/json")


@app.route("/api/uma-ase/job/<job_id>/md/restart", methods=["GET"])
def fetch_md_restart_payload(job_id: str):
    record = _get_job(job_id)
    if record.job_kind != "md":
        return jsonify({"status": "error", "message": "MD restart available only for MD jobs."}), 400
    traj_path = record.traj_path
    if traj_path is None or not traj_path.exists():
        return jsonify({"status": "error", "message": "Trajectory file unavailable for restart."}), 404
    try:
        with Trajectory(str(traj_path)) as traj:
            if len(traj) == 0:
                raise RuntimeError("Trajectory is empty.")
            atoms = traj[-1]
    except Exception as exc:
        return jsonify({"status": "error", "message": f"Unable to read trajectory: {exc}"}), 500

    comment = f"{record.charge} {record.spin} MD restart"
    handle = io.StringIO()
    write(handle, atoms, format="xyz", comment=comment)
    geometry_text = handle.getvalue()
    velocities = atoms.get_velocities()
    velocities_payload = velocities.tolist() if velocities is not None else None
    filename = record.input_filename or "md_restart.xyz"
    safe_stem = Path(filename).stem or "md_restart"
    restart_name = f"{safe_stem}-MD-restart.xyz"
    return jsonify(
        {
            "status": "ok",
            "geometry": geometry_text,
            "filename": restart_name,
            "velocities": velocities_payload,
            "has_velocities": velocities_payload is not None,
        }
    )


@app.route("/api/uma-ase/md/job/<job_id>/frames", methods=["GET"])
def fetch_md_job_frames(job_id: str):
    record = _get_job(job_id)
    start_raw = request.args.get("start", "0")
    limit_raw = request.args.get("limit", "25")
    try:
        start_index = max(0, int(start_raw))
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "start must be an integer."}), 400
    try:
        limit_val = int(limit_raw)
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "limit must be an integer."}), 400
    limit_val = max(1, min(limit_val, 200))

    multi_xyz_path = record.md_multi_xyz
    if multi_xyz_path is None and record.md_options:
        candidate = record.md_options.get("multi_xyz")
        if isinstance(candidate, Path):
            multi_xyz_path = candidate
        elif isinstance(candidate, str):
            multi_xyz_path = Path(candidate)

    available = multi_xyz_path is not None and multi_xyz_path.exists()
    if not available:
        return jsonify(
            {
                "frames": [],
                "next_index": start_index,
                "total_frames": 0,
                "job_status": record.status,
                "available": False,
            }
        )

    raw_frames, total_frames = _extract_xyz_frames(multi_xyz_path, start_index, limit_val)
    frames = [
        _viewer_frame_from_xyz_text(frame_text, start_index + offset)
        for offset, frame_text in enumerate(raw_frames)
    ]
    next_index = start_index + len(frames)
    return jsonify(
        {
            "frames": frames,
            "next_index": next_index,
            "total_frames": total_frames,
            "job_status": record.status,
            "available": True,
            "has_more": next_index < total_frames or record.status == "running",
        }
    )


@app.route("/api/uma-ase/job/<job_id>/trajectory_frames", methods=["GET"])
def download_job_trajectory_frames(job_id: str):
    record = _get_job(job_id)
    source = _resolve_job_traj_visual_source(record)
    if source is None or not source.exists():
        return jsonify({"status": "error", "message": "Trajectory visualization file unavailable."}), 404

    raw_frames, total_frames = _extract_xyz_frames(source, 0, 100000000)
    frames = [
        _viewer_frame_from_xyz_text(frame_text, index)
        for index, frame_text in enumerate(raw_frames)
    ]
    return jsonify({"status": "ok", "frames": frames, "total_frames": total_frames})


@app.route("/api/uma-ase/md/traj/convert", methods=["POST"])
def convert_md_traj_to_xyz():
    trajectory_file = request.files.get("trajectory")
    if trajectory_file is None or trajectory_file.filename == "":
        return jsonify({"status": "error", "message": "Trajectory file is required."}), 400

    max_frames_raw = request.form.get("max_frames")
    max_frames = 500
    if max_frames_raw not in (None, ""):
        try:
            max_frames = max(1, min(int(max_frames_raw), 2000))
        except (TypeError, ValueError):
            return jsonify({"status": "error", "message": "max_frames must be an integer."}), 400

    with tempfile.TemporaryDirectory(prefix="uma_traj_convert_") as temp_dir_str:
        temp_dir = Path(temp_dir_str)
        temp_path = temp_dir / (secure_filename(trajectory_file.filename) or "trajectory.traj")
        trajectory_file.save(temp_path)
        try:
            images = read(temp_path, ":")
        except Exception as exc:  # pragma: no cover - depends on input formats
            return jsonify({"status": "error", "message": f"Unable to parse trajectory: {exc}"}), 400

    frames: list[str] = []
    for index, atoms in enumerate(images):
        if index >= max_frames:
            break
        buffer = io.StringIO()
        try:
            potential = atoms.get_potential_energy()
        except Exception:
            potential = None
        time_value = atoms.info.get("time_fs")
        if time_value is None:
            time_value = atoms.info.get("time")
        try:
            time_value = float(time_value)
        except (TypeError, ValueError):
            time_value = None
        comment_parts = [f"step={index}"]
        if time_value is not None and math.isfinite(time_value):
            comment_parts.append(f"time_fs={time_value:.6f}")
        if potential is not None and math.isfinite(potential):
            comment_parts.append(f"energy_ev={float(potential):.8f}")
        write(buffer, atoms, format="xyz", comment=" ".join(comment_parts))
        frames.append(buffer.getvalue().strip())

    total_frames = len(images)
    return jsonify(
        {
            "status": "ok",
            "frames": frames,
            "total_frames": total_frames,
            "returned_frames": len(frames),
            "truncated": len(frames) < total_frames,
        }
    )


@app.route("/api/uma-ase/job/<job_id>/frequencies", methods=["GET"])
def download_job_frequencies(job_id: str):
    record = _get_job(job_id)
    freq_folder = record.freq_folder or (record.job_dir / "freqs")
    if freq_folder.exists():
        converted = _convert_traj_tree_to_xyz(freq_folder)
        if converted:
            app.logger.info("Converted %d normal mode trajectories under %s", converted, freq_folder)

    if record.freq_archive_path and record.freq_archive_path.exists():
        return _send_job_file(record.freq_archive_path, "application/zip")
    if not freq_folder.exists():
        abort(404)
    files = [path for path in freq_folder.rglob("*") if path.is_file()]
    if not files:
        abort(404)
    freq_base = freq_folder.name or Path(record.opt_path or record.job_dir).stem
    temp_dir = Path(tempfile.mkdtemp(prefix="uma_freqs_"))
    archive_path = temp_dir / f"{freq_base}-FREQS-modes.zip"
    try:
        with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
            for file_path in files:
                try:
                    arcname = file_path.relative_to(freq_folder.parent)
                except ValueError:
                    arcname = file_path.name
                archive.write(file_path, str(arcname))
    except Exception:
        shutil.rmtree(temp_dir, ignore_errors=True)
        raise

    @after_this_request
    def cleanup(response):  # pragma: no cover
        shutil.rmtree(temp_dir, ignore_errors=True)
        return response

    return send_file(
        archive_path,
        mimetype="application/zip",
        as_attachment=True,
        download_name=f"{freq_base}-FREQS-modes.zip",
    )


@app.route("/api/uma-ase/clean", methods=["POST"])
def clean_results_root():
    client_id = _get_client_id()
    base_dir = Path.home() / ".uma_ase"
    try:
        if not client_id:
            if base_dir.exists():
                shutil.rmtree(base_dir)
            results_root = Path(app.config["UMA_RESULTS_DIR"])
            results_root.mkdir(parents=True, exist_ok=True)
            ANALYZE_REPORT_ROOT.mkdir(parents=True, exist_ok=True)
            return jsonify({"status": "ok"})

        results_root = Path(app.config["UMA_RESULTS_DIR"])
        results_root.mkdir(parents=True, exist_ok=True)
        ANALYZE_REPORT_ROOT.mkdir(parents=True, exist_ok=True)
        return jsonify({"status": "ok"})
    except OSError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 500


@app.route("/api/uma-ase/multi/logs/<path:folder>", methods=["GET"])
def download_multi_logs(folder: str):
    safe_folder = secure_filename(folder)
    if not safe_folder:
        abort(404)
    multi_root = (Path(app.config["UMA_RESULTS_DIR"]) / "multi_runs").resolve()
    target_dir = (multi_root / safe_folder).resolve()
    try:
        target_dir.relative_to(multi_root)
    except ValueError:
        abort(404)
    if not target_dir.exists():
        abort(404)
    produced_files = [path for path in target_dir.rglob("*") if path.is_file()]
    if not produced_files:
        abort(404)

    extra_freq_entries = []
    for freq_dir in target_dir.glob("**/freqs"):
        if not freq_dir.is_dir():
            continue
        try:
            rel_freq_dir = freq_dir.relative_to(target_dir)
        except ValueError:
            continue
        parts = rel_freq_dir.parts
        if parts and parts[0] == "freqs":
            continue
        try:
            rel_parent = freq_dir.parent.relative_to(target_dir)
        except ValueError:
            continue
        arc_prefix = Path("freqs") / rel_parent
        for file_path in freq_dir.rglob("*"):
            if file_path.is_file():
                arcname = arc_prefix / file_path.relative_to(freq_dir)
                extra_freq_entries.append((file_path, arcname))

    temp_dir = Path(tempfile.mkdtemp(prefix="uma_logs_"))
    archive_path = temp_dir / f"{safe_folder}_files.zip"
    try:
        with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
            for file_path in produced_files:
                archive.write(file_path, file_path.relative_to(target_dir))
            for src_path, arcname in extra_freq_entries:
                archive.write(src_path, str(arcname))
    except Exception:
        shutil.rmtree(temp_dir, ignore_errors=True)
        raise

    @after_this_request
    def cleanup(response):  # pragma: no cover
        shutil.rmtree(temp_dir, ignore_errors=True)
        return response

    return send_file(
        archive_path,
        mimetype="application/zip",
        as_attachment=True,
        download_name=f"{safe_folder}_files.zip",
    )




@app.route("/api/uma-ase/iochem/fetch", methods=["POST"])
def fetch_iochem_geometry():
    payload = request.get_json(silent=True) or {}
    raw_handle = payload.get("handle") or request.form.get("handle")
    if raw_handle is None or not str(raw_handle).strip():
        return jsonify({"status": "error", "message": "Handle is required."}), 400
    verify_ssl = _parse_iochem_verify(payload.get("verify_ssl"))
    try:
        geometry = _download_iochem_geometry(str(raw_handle), verify_ssl=verify_ssl)
    except ValueError as exc:
        app.logger.warning("ioChem-BD fetch failed for %s: %s", raw_handle, exc)
        return jsonify({"status": "error", "message": str(exc)}), 400
    except Exception as exc:  # pragma: no cover - defensive logging
        app.logger.exception("Unexpected ioChem-BD error: %s", exc)
        return jsonify({"status": "error", "message": f"Unexpected ioChem-BD error: {exc}"}), 502

    return jsonify(
        {
            "status": "ok",
            "handle": geometry.handle,
            "source_url": geometry.source_url,
            "filename": geometry.filename,
            "geometry": geometry.xyz_content,
            "cml": geometry.cml_content,
            "charge": geometry.charge,
            "spin": geometry.spin,
            "formula": geometry.formula,
            "num_atoms": geometry.num_atoms,
            "element_counts": geometry.element_counts,
            "cell_parameters": geometry.cell_parameters,
            "pbc": geometry.pbc,
            "has_crystal": bool(geometry.cell_parameters),
            "viewer_filename": geometry.viewer_filename,
            "viewer_content": geometry.viewer_content,
        }
    )


@app.route("/api/uma-ase/iochem/cml", methods=["POST"])
def fetch_iochem_cml():
    payload = request.get_json(silent=True) or {}
    raw_handle = payload.get("handle") or request.form.get("handle")
    if raw_handle is None or not str(raw_handle).strip():
        return jsonify({"status": "error", "message": "Handle is required."}), 400
    verify_ssl = _parse_iochem_verify(payload.get("verify_ssl"))
    try:
        cml_text = _download_iochem_cml_text(str(raw_handle), verify_ssl=verify_ssl)
    except ValueError as exc:
        app.logger.warning("ioChem-BD CML fetch failed for %s: %s", raw_handle, exc)
        return jsonify({"status": "error", "message": str(exc)}), 400
    except Exception as exc:  # pragma: no cover
        app.logger.exception("Unexpected ioChem-BD error: %s", exc)
        return jsonify({"status": "error", "message": f"Unexpected ioChem-BD error: {exc}"}), 502

    return jsonify(
        {
            "status": "ok",
            "filename": f"ioChem_{_normalize_iochem_handle(raw_handle).replace('/', '_')}.cml",
            "cml": cml_text,
        }
    )


@app.route("/api/uma-ase/iochem/cml-file", methods=["GET"])
def fetch_iochem_cml_file():
    raw_handle = request.args.get("handle")
    if raw_handle is None or not str(raw_handle).strip():
        return Response("Handle is required.", status=400)
    verify_ssl = _parse_iochem_verify(request.args.get("verify_ssl"))
    try:
        cml_text = _download_iochem_cml_text(str(raw_handle), verify_ssl=verify_ssl)
    except ValueError as exc:
        app.logger.warning("ioChem-BD CML fetch failed for %s: %s", raw_handle, exc)
        return Response(str(exc), status=400)
    except Exception as exc:  # pragma: no cover
        app.logger.exception("Unexpected ioChem-BD error: %s", exc)
        return Response("Unable to download ioChem-BD CML.", status=502)
    return Response(cml_text, mimetype="chemical/x-cml")


@app.route("/api/uma-ase/iochem/cml-file/<host>/<path:handle>", methods=["GET"])
def fetch_iochem_cml_file_hosted(host: str, handle: str):
    if not host or not _is_allowed_iochem_host(host):
        return Response("ioChem-BD host is not allowed.", status=400)
    raw_handle = f"https://{host}/handle/{handle}"
    verify_ssl = _parse_iochem_verify(request.args.get("verify_ssl"))
    try:
        cml_text = _download_iochem_cml_text(raw_handle, verify_ssl=verify_ssl)
    except ValueError as exc:
        app.logger.warning("ioChem-BD CML fetch failed for %s: %s", raw_handle, exc)
        return Response(str(exc), status=400)
    except Exception as exc:  # pragma: no cover
        app.logger.exception("Unexpected ioChem-BD error: %s", exc)
        return Response("Unable to download ioChem-BD CML.", status=502)
    return Response(cml_text, mimetype="chemical/x-cml")


@app.route("/api/uma-ase/chem/status", methods=["GET"])
def chem_status():
    return jsonify({"status": "ok", "rdkit_available": RDKIT_AVAILABLE})


@app.route("/api/uma-ase/chem/diagnostics", methods=["GET"])
def chem_diagnostics():
    return jsonify({"status": "ok", "diagnostics": _collect_rdkit_diagnostics()})


@app.route("/api/uma-ase/pubchem/fetch", methods=["POST"])
def fetch_pubchem_geometry():
    payload = request.get_json(silent=True) or {}
    raw_input = str(payload.get("cid") or payload.get("name") or payload.get("query") or "").strip()
    if not raw_input:
        return jsonify({"status": "error", "message": "Enter a PubChem CID or name."}), 400
    if re.fullmatch(r"\d+", raw_input):
        raw_cid = raw_input
    else:
        try:
            cids = _fetch_pubchem_cids_for_name(raw_input)
        except ValueError as exc:
            return jsonify({"status": "error", "message": str(exc)}), 502
        if not cids:
            return jsonify({"status": "error", "message": "No PubChem CID found for this name."}), 404
        raw_cid = cids[0]
    comment = f"PubChem CID={raw_cid}"
    if raw_input and raw_input != raw_cid:
        comment = f"{comment} name={raw_input}"
    try:
        xyz_content = _resolve_pubchem_geometry("cid", raw_cid, comment)
    except ValueError as exc:
        try:
            smiles = _fetch_pubchem_smiles(raw_cid)
            fallback_comment = f"{comment} SMILES={smiles}"
            if not RDKIT_AVAILABLE:
                raise ValueError("RDKit is required to generate 3D coordinates from SMILES.")
            rdkit_xyz = _rdkit_xyz_from_query(smiles, "smiles", fallback_comment)
            if not rdkit_xyz:
                raise ValueError("RDKit is required to generate 3D coordinates from SMILES.")
            xyz_content = rdkit_xyz
        except ValueError as fallback_exc:
            message = f"{exc} Fallback failed: {fallback_exc}"
            app.logger.warning("PubChem fetch failed for CID %s: %s", raw_cid, message)
            return jsonify({"status": "error", "message": message}), 502
    iupac_name = None
    structure_2d = None
    try:
        iupac_name = _fetch_pubchem_iupac_name(raw_cid)
    except ValueError as exc:
        app.logger.warning("PubChem IUPAC fetch failed for CID %s: %s", raw_cid, exc)
    try:
        structure_2d = _fetch_pubchem_2d_png(raw_cid)
    except ValueError as exc:
        app.logger.warning("PubChem 2D image fetch failed for CID %s: %s", raw_cid, exc)
    conformers_payload: List[Dict[str, Any]] = []
    conformer_source: Optional[str] = None
    conformer_sdf: Optional[str] = None
    if RDKIT_AVAILABLE:
        try:
            conformer_sdf = _fetch_pubchem_conformers_sdf(raw_cid)
        except ValueError as exc:
            app.logger.warning("PubChem conformer fetch failed for CID %s: %s", raw_cid, exc)
            conformer_sdf = None
        if conformer_sdf:
            if _pubchem_debug_enabled():
                record_count = len(_split_sdf_records(conformer_sdf))
                app.logger.warning(
                    "PubChem conformer SDF for CID %s returned %s record(s).",
                    raw_cid,
                    record_count,
                )
            conformers_payload = _parse_pubchem_conformers_sdf(
                conformer_sdf,
                cid=raw_cid,
                iupac_name=iupac_name,
                comment=comment,
            )
            if _pubchem_debug_enabled():
                app.logger.warning(
                    "PubChem conformer parse for CID %s produced %s conformer(s).",
                    raw_cid,
                    len(conformers_payload),
                )
            if conformers_payload:
                xyz_content = conformers_payload[0]["content"]
                conformer_source = "pubchem"
        elif _pubchem_debug_enabled():
            app.logger.warning("PubChem conformer SDF for CID %s returned no payload.", raw_cid)
    filename = f"pubchem_{raw_cid}.xyz"
    isomers_payload = []
    if conformers_payload:
        isomers_payload = [
            {
                "isomer_id": "1",
                "source": conformer_source or "pubchem",
                "cid": raw_cid,
                "iupac_name": iupac_name,
                "conformers": conformers_payload,
                "active_conf_id": conformers_payload[0]["conf_id"],
            }
        ]
    smiles_payload = None
    smiles_error: Optional[Exception] = None
    try:
        smiles_payload = _fetch_pubchem_smiles(raw_cid)
    except ValueError as exc:
        smiles_error = exc
    if not smiles_payload:
        sdf_source = conformer_sdf
        if not sdf_source:
            try:
                sdf_source = _fetch_pubchem_sdf("cid", raw_cid)
            except ValueError as exc:
                app.logger.warning("PubChem SDF fetch failed for SMILES (CID %s): %s", raw_cid, exc)
                sdf_source = None
        if sdf_source:
            smiles_payload = _extract_pubchem_smiles_from_sdf(sdf_source)
    if not smiles_payload:
        try:
            smiles_payload = _fetch_pubchem_record_smiles(raw_cid)
        except ValueError as exc:
            app.logger.warning("PubChem record JSON fetch failed for CID %s: %s", raw_cid, exc)
    if not smiles_payload and smiles_error:
        app.logger.warning("PubChem SMILES fetch failed for CID %s: %s", raw_cid, smiles_error)
    return jsonify(
        {
            "status": "ok",
            "geometry": xyz_content,
            "filename": filename,
            "cid": raw_cid,
            "iupac_name": iupac_name,
            "structure_2d": structure_2d,
            "smiles": smiles_payload,
            "isomers": isomers_payload,
        }
    )


@app.route("/api/uma-ase/pdbe/fetch", methods=["POST"])
def fetch_pdbe_structure():
    payload = request.get_json(silent=True) or {}
    raw_id = payload.get("pdb_id") or payload.get("id") or payload.get("pdbId")
    try:
        pdb_id = _normalize_pdbe_id(raw_id)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    try:
        filename, content = _fetch_pdbe_entry(pdb_id)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 502
    return jsonify({"status": "ok", "geometry": content, "filename": filename})


@app.route("/api/uma-ase/chem/resolve", methods=["POST"])
def resolve_external_geometry():
    payload = request.get_json(silent=True) or {}
    raw_query = str(payload.get("query", "")).strip()
    if not raw_query:
        return jsonify({"status": "error", "message": "Enter a SMILES or InChIKey value."}), 400
    namespace, label = _infer_identifier_namespace(raw_query)
    comment = f"{label}={raw_query}"
    if namespace in ("smiles", "inchi"):
        if not RDKIT_AVAILABLE:
            return jsonify({"status": "error", "message": "RDKit is required to generate 3D coordinates."}), 502
        try:
            rdkit_xyz = _rdkit_xyz_from_query(raw_query, namespace, comment)
        except ValueError as exc:
            return jsonify({"status": "error", "message": str(exc)}), 502
        if rdkit_xyz:
            structure_2d = None
            iupac_name = None
            structure_notice = None
            with suppress(ValueError):
                mol = _rdkit_mol_from_query(raw_query, namespace)
                structure_2d = _rdkit_2d_png_from_mol(mol)
                smiles = raw_query if namespace == "smiles" else Chem.MolToSmiles(mol)
                if smiles:
                    iupac_name = _fetch_pubchem_iupac_from_smiles(smiles)
                    if not structure_2d:
                        cids = _fetch_pubchem_cids_for_smiles(smiles)
                        if cids:
                            structure_2d = _fetch_pubchem_2d_png(cids[0])
                            if structure_2d:
                                structure_notice = "2D sketch loaded from PubChem."
            safe_stub = re.sub(r"[^A-Za-z0-9]+", "_", raw_query).strip("_")[:32] or "structure"
            filename = secure_filename(f"{label.lower()}_{safe_stub}.xyz")
            return jsonify(
                {
                    "status": "ok",
                    "geometry": rdkit_xyz,
                    "filename": filename,
                    "iupac_name": iupac_name,
                    "structure_2d": structure_2d,
                    "structure_notice": structure_notice,
                }
            )
    try:
        xyz_content = _resolve_pubchem_geometry(namespace, raw_query, comment)
    except ValueError as exc:
        message = str(exc)
        app.logger.warning("SMILES/InChI resolve failed for %s: %s", raw_query, message)
        return jsonify({"status": "error", "message": message}), 502
    safe_stub = re.sub(r"[^A-Za-z0-9]+", "_", raw_query).strip("_")[:32] or "structure"
    filename = secure_filename(f"{label.lower()}_{safe_stub}.xyz")
    return jsonify({"status": "ok", "geometry": xyz_content, "filename": filename})


@app.route("/api/uma-ase/config", methods=["GET"])
def get_app_config():
    return jsonify(
        {
            "status": "ok",
            "max_conformers": _get_max_conformers(),
            "jobs_max_count": _parse_job_limit(app.config.get("UMA_JOBS_MAX_COUNT")),
            "jobs_retention_days": _parse_job_limit(app.config.get("UMA_JOBS_RETENTION_DAYS")),
            "jobs_max_bytes": _parse_job_byte_limit(app.config.get("UMA_JOBS_MAX_BYTES")),
        }
    )


@app.route("/api/uma-ase/chem/conformers", methods=["POST"])
def generate_rdkit_conformers():
    if not RDKIT_AVAILABLE:
        return jsonify({"status": "error", "message": "RDKit is required to generate conformers."}), 502
    payload = request.get_json(silent=True) or {}
    raw_query = str(payload.get("query", "")).strip()
    if not raw_query:
        return jsonify({"status": "error", "message": "Enter a SMILES or InChI value."}), 400
    namespace, label = _infer_identifier_namespace(raw_query)
    if namespace not in ("smiles", "inchi"):
        return jsonify(
            {
                "status": "error",
                "message": "InChIKey values are not supported for conformer generation. Provide SMILES or InChI.",
            }
        ), 400
    try:
        num_confs = int(payload.get("num_confs", 10))
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "num_confs must be an integer."}), 400
    num_confs = max(1, min(num_confs, _get_max_conformers()))
    comment = f"{label}={raw_query}"
    structure_2d = None
    iupac_name = None
    try:
        mol = _rdkit_mol_from_query(raw_query, namespace)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    structure_2d = _rdkit_2d_png_from_mol(mol)
    if namespace == "smiles":
        with suppress(ValueError):
            iupac_name = _fetch_pubchem_iupac_from_smiles(raw_query)
    try:
        conformers = _generate_rdkit_conformers(raw_query, comment, num_confs=num_confs, namespace=namespace)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    if not conformers:
        return jsonify({"status": "error", "message": "RDKit did not return any conformers."}), 502
    isomers_payload = [
        {
            "isomer_id": "1",
            "source": "rdkit",
            "cid": None,
            "iupac_name": iupac_name,
            "conformers": conformers,
            "active_conf_id": conformers[0]["conf_id"],
        }
    ]
    return jsonify(
        {
            "status": "ok",
            "source": "rdkit",
            "query": raw_query,
            "num_confs": len(conformers),
            "isomers": isomers_payload,
            "iupac_name": iupac_name,
            "structure_2d": structure_2d,
        }
    )


@app.route("/api/uma-ase/chem/conformers-from-structure", methods=["POST"])
def generate_rdkit_conformers_from_structure():
    if not RDKIT_AVAILABLE:
        return jsonify({"status": "error", "message": "RDKit is required to generate conformers."}), 502
    payload = request.get_json(silent=True) or {}
    content = str(payload.get("content") or "").strip()
    filename = str(payload.get("filename") or "structure.xyz")
    try:
        num_confs = int(payload.get("num_confs", 10))
    except (TypeError, ValueError):
        return jsonify({"status": "error", "message": "num_confs must be an integer."}), 400
    num_confs = max(1, min(num_confs, _get_max_conformers()))
    try:
        charge = int(payload.get("charge", 0))
    except (TypeError, ValueError):
        charge = 0
    try:
        mol = _mol_from_structure_content(content, filename, charge)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    bonding_warning = _infer_bonding_warning(mol)
    iupac_name = None
    structure_2d = None
    structure_notice = None
    if mol.GetNumAtoms() <= _get_rdkit_max_2d_atoms():
        structure_2d = _rdkit_2d_png_from_mol(mol)
    else:
        structure_notice = (
            f"2D sketch skipped for large structures (>{_get_rdkit_max_2d_atoms()} atoms)."
        )
    with suppress(Exception):
        smiles = Chem.MolToSmiles(mol)
        if smiles:
            iupac_name = _fetch_pubchem_iupac_from_smiles(smiles)
    comment = f"structure={Path(filename).stem or 'structure'}"
    try:
        conformers = _generate_rdkit_conformers_from_mol(mol, comment, num_confs=num_confs)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    if not conformers:
        return jsonify({"status": "error", "message": "RDKit did not return any conformers."}), 502
    isomers_payload = [
        {
            "isomer_id": "1",
            "source": "structure",
            "cid": None,
            "iupac_name": iupac_name,
            "conformers": conformers,
            "active_conf_id": conformers[0]["conf_id"],
        }
    ]
    response = {
        "status": "ok",
        "source": "structure",
        "filename": filename,
        "num_confs": len(conformers),
        "isomers": isomers_payload,
        "iupac_name": iupac_name,
        "structure_2d": structure_2d,
    }
    if bonding_warning:
        response["bonding_warning"] = bonding_warning
    if structure_notice:
        response["structure_notice"] = structure_notice
    return jsonify(response)


@app.route("/api/uma-ase/chem/structure-details", methods=["POST"])
def fetch_structure_details():
    payload = request.get_json(silent=True) or {}
    content = str(payload.get("content") or "").strip()
    filename = str(payload.get("filename") or "structure.xyz")
    if not RDKIT_AVAILABLE:
        return jsonify({"status": "error", "message": "RDKit is required to generate structure details."}), 502
    try:
        charge = int(payload.get("charge", 0))
    except (TypeError, ValueError):
        charge = 0
    try:
        mol = _mol_from_structure_content(content, filename, charge)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    detail_atom_limit = 150
    atom_count = mol.GetNumAtoms()
    structure_2d = None
    structure_notice = None
    if atom_count <= detail_atom_limit:
        structure_2d = _rdkit_2d_png_from_mol(mol)
    else:
        structure_notice = (
            f"2D sketch, IUPAC name, and SMILES are not generated for systems with more than "
            f"{detail_atom_limit} atoms."
        )
    iupac_name = None
    smiles = None
    if atom_count <= detail_atom_limit:
        with suppress(Exception):
            smiles = Chem.MolToSmiles(mol)
        with suppress(Exception):
            if smiles:
                iupac_name = _fetch_pubchem_iupac_from_smiles(smiles)
    bonding_warning = _infer_bonding_warning(mol)
    response = {
        "status": "ok",
        "structure_2d": structure_2d,
        "iupac_name": iupac_name,
        "smiles": smiles,
    }
    if bonding_warning:
        response["bonding_warning"] = bonding_warning
    if structure_notice:
        response["structure_notice"] = structure_notice
    return jsonify(response)


@app.route("/api/uma-ase/iochem/create/login", methods=["POST"])
def iochem_create_login():
    payload = request.get_json(silent=True) or {}
    base_url = payload.get("base_url") or payload.get("baseUrl") or payload.get("base")
    username = payload.get("username") or payload.get("user")
    password = payload.get("password")
    if not username or not password:
        return jsonify({"status": "error", "message": "Username and password are required."}), 400
    try:
        rest_base = _normalize_iochem_create_rest_url(base_url)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    verify_ssl = _parse_iochem_verify(payload.get("verify_ssl"))
    login_url = f"{rest_base.rstrip('/')}/login"
    try:
        resp = requests.post(
            login_url,
            headers={"Accept": "text/plain, application/json"},
            auth=(str(username), str(password)),
            timeout=30,
            verify=verify_ssl,
        )
    except requests.exceptions.SSLError as exc:
        return jsonify(
            {
                "status": "error",
                "message": (
                    "TLS verification failed when contacting ioChem-BD. "
                    "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
                ),
            }
        ), 502
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Unable to contact ioChem-BD: {exc}"}), 502
    if resp.status_code >= 400:
        message = _format_iochem_error(resp, "login")
        app.logger.warning("ioChem-BD login failed: %s", message)
        status_code = resp.status_code if 400 <= resp.status_code < 500 else 502
        return jsonify({"status": "error", "message": message}), status_code

    token = ""
    try:
        json_payload = resp.json()
    except ValueError:
        json_payload = None
    if isinstance(json_payload, dict):
        token = str(
            json_payload.get("token")
            or json_payload.get("jwt")
            or json_payload.get("access_token")
            or ""
        ).strip()
    if not token:
        token = (resp.text or "").strip()
    lowered_token = token.lower()
    if lowered_token.startswith("<!doctype") or lowered_token.startswith("<html"):
        return jsonify(
            {
                "status": "error",
                "message": "ioChem-BD returned an HTML page instead of a JWT token. Check the Create REST base URL.",
            }
        ), 502
    if not token:
        return jsonify({"status": "error", "message": "ioChem-BD did not return a token."}), 502
    return jsonify({"status": "ok", "token": token})


@app.route("/api/uma-ase/iochem/create/types", methods=["POST"])
def iochem_create_types():
    payload = request.get_json(silent=True) or {}
    base_url = payload.get("base_url") or payload.get("baseUrl") or payload.get("base")
    token = payload.get("token")
    if not token:
        return jsonify({"status": "error", "message": "JWT token is required."}), 400
    try:
        rest_base = _normalize_iochem_create_rest_url(base_url)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    verify_ssl = _parse_iochem_verify(payload.get("verify_ssl"))
    types_url = f"{rest_base.rstrip('/')}/calculation/types"
    try:
        resp = requests.get(
            types_url,
            headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
            timeout=30,
            verify=verify_ssl,
        )
    except requests.exceptions.SSLError as exc:
        return jsonify(
            {
                "status": "error",
                "message": (
                    "TLS verification failed when contacting ioChem-BD. "
                    "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
                ),
            }
        ), 502
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Unable to contact ioChem-BD: {exc}"}), 502
    if resp.status_code >= 400:
        return jsonify({"status": "error", "message": f"ioChem-BD returned HTTP {resp.status_code}."}), 502
    try:
        payload_json = resp.json()
    except ValueError:
        return jsonify({"status": "error", "message": "ioChem-BD returned an invalid response."}), 502
    return jsonify({"status": "ok", "types": payload_json})


@app.route("/api/uma-ase/iochem/create/tree", methods=["POST"])
def iochem_create_tree():
    try:
        payload = request.get_json(silent=True) or {}
        base_url = payload.get("base_url") or payload.get("baseUrl") or payload.get("base")
        token = payload.get("token")
        root_path = payload.get("path") or payload.get("root_path") or payload.get("rootPath")
        if not token:
            return jsonify({"status": "error", "message": "JWT token is required."}), 400
        if not root_path:
            return jsonify({"status": "error", "message": "Root path is required."}), 400
        try:
            rest_base = _normalize_iochem_create_rest_url(base_url)
        except ValueError as exc:
            return jsonify({"status": "error", "message": str(exc)}), 400
        verify_ssl = _parse_iochem_verify(payload.get("verify_ssl"))
        headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
        path_value = str(root_path).strip()
        if not path_value.startswith("/"):
            path_value = f"/{path_value}"
        if not path_value.startswith("/db/"):
            path_value = f"/db{path_value}"

        descendants_url = f"{rest_base.rstrip('/')}/project/descendant"
        def fetch_descendants(params):
            try:
                response = requests.get(
                    descendants_url,
                    headers=headers,
                    params=params,
                    timeout=60,
                    verify=verify_ssl,
                )
            except requests.exceptions.SSLError:
                return None, (
                    jsonify(
                        {
                            "status": "error",
                            "message": (
                                "TLS verification failed when contacting ioChem-BD. "
                                "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
                            ),
                        }
                    ),
                    502,
                )
            except requests.RequestException as exc:
                return None, (jsonify({"status": "error", "message": f"Unable to contact ioChem-BD: {exc}"}), 502)
            return response, None

        def parse_items(response):
            try:
                payload = response.json()
            except ValueError:
                return None, (jsonify({"status": "error", "message": "ioChem-BD returned an invalid response."}), 502)
            if not isinstance(payload, list):
                return None, (jsonify({"status": "error", "message": "Unexpected response format from ioChem-BD."}), 502)
            return payload, None

        def is_user_root(candidate):
            parts = candidate.strip("/").split("/")
            return len(parts) == 2 and parts[0] == "db"

        resp, error = fetch_descendants({"path": path_value, "type": "calculation", "depth": "all"})
        if error:
            return error
        if resp.status_code == 404 and not path_value.endswith("/"):
            resp, error = fetch_descendants({"path": f"{path_value}/", "type": "calculation", "depth": "all"})
            if error:
                return error

        if resp.status_code >= 400:
            if is_user_root(path_value):
                resp_projects, error = fetch_descendants({"path": path_value, "type": "project", "depth": "all"})
                if error:
                    return error
                if resp_projects.status_code == 404 and not path_value.endswith("/"):
                    resp_projects, error = fetch_descendants(
                        {"path": f"{path_value}/", "type": "project", "depth": "all"}
                    )
                    if error:
                        return error
                if resp_projects.status_code >= 400:
                    return jsonify({"status": "error", "message": _format_iochem_error(resp_projects, "project list")}), 502
                projects, error = parse_items(resp_projects)
                if error:
                    return error
                calc_items = []
                for project in projects:
                    if not isinstance(project, dict):
                        continue
                    project_id = project.get("id")
                    project_path = project.get("path")
                    if not project_id and not project_path:
                        continue
                    params = {"type": "calculation", "depth": "all"}
                    if project_id:
                        params["id"] = str(project_id)
                    else:
                        params["path"] = str(project_path)
                    resp_calcs, error = fetch_descendants(params)
                    if error:
                        return error
                    if resp_calcs.status_code >= 400:
                        continue
                    items, error = parse_items(resp_calcs)
                    if error:
                        return error
                    calc_items.extend(items)
                return jsonify({"status": "ok", "items": calc_items})
            return jsonify({"status": "error", "message": _format_iochem_error(resp, "project descendant")}), 502

        items, error = parse_items(resp)
        if error:
            return error
        return jsonify({"status": "ok", "items": items})
    except Exception as exc:
        return jsonify({"status": "error", "message": f"Unable to load tree: {exc}"}), 500


@app.route("/api/uma-ase/iochem/create/shares", methods=["POST"])
def iochem_create_shares():
    payload = request.get_json(silent=True) or {}
    base_url = payload.get("base_url") or payload.get("baseUrl") or payload.get("base")
    token = payload.get("token")
    if not token:
        return jsonify({"status": "error", "message": "JWT token is required."}), 400
    try:
        rest_base = _normalize_iochem_create_rest_url(base_url)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    verify_ssl = _parse_iochem_verify(payload.get("verify_ssl"))
    shares_url = f"{rest_base.rstrip('/')}/navigation/shares"
    try:
        resp = requests.get(
            shares_url,
            headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
            timeout=60,
            verify=verify_ssl,
        )
    except requests.exceptions.SSLError:
        return jsonify(
            {
                "status": "error",
                "message": (
                    "TLS verification failed when contacting ioChem-BD. "
                    "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
                ),
            }
        ), 502
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Unable to contact ioChem-BD: {exc}"}), 502
    if resp.status_code >= 400:
        return jsonify({"status": "error", "message": f"ioChem-BD returned HTTP {resp.status_code}."}), 502
    try:
        payload_json = resp.json()
    except ValueError:
        return jsonify({"status": "error", "message": "ioChem-BD returned an invalid response."}), 502
    return jsonify({"status": "ok", "items": payload_json})


def _extract_iochem_create_files_list(payload: Any) -> List[str]:
    if isinstance(payload, dict):
        files = payload.get("files")
        if isinstance(files, list):
            return [str(item) for item in files]
    if isinstance(payload, list):
        return [str(item) for item in payload]
    return []


@app.route("/api/uma-ase/iochem/create/geometry", methods=["POST"])
def iochem_create_geometry():
    payload = request.get_json(silent=True) or {}
    base_url = payload.get("base_url") or payload.get("baseUrl") or payload.get("base")
    token = payload.get("token")
    calc_id = payload.get("calc_id") or payload.get("calcId") or payload.get("id")
    if not token:
        return jsonify({"status": "error", "message": "JWT token is required."}), 400
    if not calc_id:
        return jsonify({"status": "error", "message": "Calculation ID is required."}), 400
    try:
        rest_base = _normalize_iochem_create_rest_url(base_url)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    verify_ssl = _parse_iochem_verify(payload.get("verify_ssl"))
    headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}

    files_url = f"{rest_base.rstrip('/')}/calculation/files"
    try:
        resp = requests.get(
            files_url,
            headers=headers,
            params={"id": str(calc_id), "type": "files"},
            timeout=30,
            verify=verify_ssl,
        )
    except requests.exceptions.SSLError:
        return jsonify(
            {
                "status": "error",
                "message": (
                    "TLS verification failed when contacting ioChem-BD. "
                    "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
                ),
            }
        ), 502
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Unable to contact ioChem-BD: {exc}"}), 502
    if resp.status_code >= 400:
        return jsonify({"status": "error", "message": f"ioChem-BD returned HTTP {resp.status_code}."}), 502

    files = []
    try:
        files = _extract_iochem_create_files_list(resp.json())
    except ValueError:
        pass
    if not files:
        raw_text = (resp.text or "").strip()
        if raw_text.startswith("[") and raw_text.endswith("]"):
            raw_text = raw_text.strip("[]")
        if raw_text:
            files = [item.strip().strip('"') for item in raw_text.split(",") if item.strip()]
    if not files:
        return jsonify({"status": "error", "message": "No files found for this calculation."}), 502

    filename = None
    for candidate in files:
        if candidate.lower() == "output.cml":
            filename = candidate
            break
    if not filename:
        for candidate in files:
            if candidate.lower().endswith(".cml"):
                filename = candidate
                break
    if not filename:
        return jsonify({"status": "error", "message": "No CML output file found."}), 502

    bitstream_url = f"{rest_base.rstrip('/')}/calculation/files/bitstream"
    try:
        resp = requests.get(
            bitstream_url,
            headers={"Authorization": f"Bearer {token}", "Accept": "application/octet-stream"},
            params={"id": str(calc_id), "fileId": filename},
            timeout=60,
            verify=verify_ssl,
        )
    except requests.exceptions.SSLError:
        return jsonify(
            {
                "status": "error",
                "message": (
                    "TLS verification failed when contacting ioChem-BD. "
                    "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
                ),
            }
        ), 502
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Unable to contact ioChem-BD: {exc}"}), 502
    if resp.status_code >= 400:
        return jsonify({"status": "error", "message": f"ioChem-BD returned HTTP {resp.status_code}."}), 502
    cml_text = (resp.text or "").strip()
    if not cml_text:
        return jsonify({"status": "error", "message": "Downloaded CML file is empty."}), 502

    charge, spin = _extract_charge_spin_from_cml(cml_text)
    atoms = _atoms_from_cml_text(cml_text)
    atoms.info["charge"] = charge
    atoms.info["spin"] = spin
    source_url = f"{rest_base.rstrip('/')}/calculation?id={calc_id}"
    xyz_content = _atoms_to_xyz(atoms, source_url, charge, spin)
    filename_out = f"ioChem_create_{calc_id}.xyz"
    return jsonify(
        {
            "status": "ok",
            "calc_id": calc_id,
            "filename": filename_out,
            "geometry": xyz_content,
            "charge": charge,
            "spin": spin,
        }
    )


@app.route("/api/uma-ase/iochem/create/upload-cml", methods=["POST"])
def iochem_create_upload_cml():
    base_url = request.form.get("base_url") or request.form.get("base")
    token = request.form.get("token")
    name = request.form.get("name")
    description = request.form.get("description")
    path = request.form.get("path")
    calc_type = request.form.get("type")
    if not token:
        return jsonify({"status": "error", "message": "JWT token is required."}), 400
    missing = [field for field in ("name", "description", "path", "type") if not request.form.get(field)]
    if missing:
        return jsonify({"status": "error", "message": f"Missing fields: {', '.join(missing)}."}), 400
    if not request.files:
        return jsonify({"status": "error", "message": "CML file is required."}), 400
    try:
        rest_base = _normalize_iochem_create_rest_url(base_url)
    except ValueError as exc:
        return jsonify({"status": "error", "message": str(exc)}), 400
    verify_ssl = _parse_iochem_verify(request.form.get("verify_ssl"))

    file_field = next(iter(request.files.values()))
    file_field_name = file_field.name or "output.cml"
    file_payload = file_field.read()
    if not file_payload:
        return jsonify({"status": "error", "message": "Uploaded CML file is empty."}), 400

    data = {
        "name": name,
        "description": description,
        "path": path,
        "type": calc_type,
    }
    if request.form.get("lineage"):
        data["lineage"] = request.form.get("lineage")

    upload_url = f"{rest_base.rstrip('/')}/calculation/upload/shell"
    try:
        resp = requests.post(
            upload_url,
            headers={"Authorization": f"Bearer {token}"},
            data=data,
            files={file_field_name: (file_field_name, file_payload, file_field.mimetype or "application/xml")},
            timeout=120,
            verify=verify_ssl,
        )
    except requests.exceptions.SSLError as exc:
        return jsonify(
            {
                "status": "error",
                "message": (
                    "TLS verification failed when contacting ioChem-BD. "
                    "Set UMA_ASE_IOCHEM_VERIFY=0 to skip certificate checks."
                ),
            }
        ), 502
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Unable to contact ioChem-BD: {exc}"}), 502
    if resp.status_code >= 400:
        return jsonify({"status": "error", "message": f"ioChem-BD returned HTTP {resp.status_code}."}), 502
    calc_id = (resp.text or "").strip()
    return jsonify({"status": "ok", "calc_id": calc_id})


@app.route("/api/uma-ase/iochem/proxy", methods=["GET", "POST"])
def proxy_iochem():
    token = _sanitize_iochem_token(request.args.get("token"))
    if token is None:
        return jsonify({"status": "error", "message": "Missing or invalid proxy token."}), 400

    raw_url = request.args.get("url")
    if not raw_url:
        return jsonify({"status": "error", "message": "Missing target URL."}), 400
    raw_url = _unwrap_proxy_url(raw_url)
    raw_url = _replace_placeholder_host(raw_url, IOCHEM_PROXY_DEFAULT_ORIGIN)

    try:
        parsed = urlparse(raw_url)
    except Exception:
        return jsonify({"status": "error", "message": "Invalid target URL."}), 400

    if parsed.scheme not in ("http", "https") or not _is_allowed_iochem_host(parsed.hostname):
        return jsonify({"status": "error", "message": "Target host is not allowed."}), 403

    session = _get_iochem_session(token)
    target_origin = f"{parsed.scheme}://{parsed.netloc}"
    headers = {
        "User-Agent": request.headers.get("User-Agent", "uma-ase-proxy"),
        "Referer": target_origin,
        "Origin": target_origin,
    }
    if request.headers.get("Accept"):
        headers["Accept"] = request.headers.get("Accept")
    if request.headers.get("Accept-Language"):
        headers["Accept-Language"] = request.headers.get("Accept-Language")
    if request.headers.get("Content-Type"):
        headers["Content-Type"] = request.headers.get("Content-Type")
    if request.headers.get("X-Requested-With"):
        headers["X-Requested-With"] = request.headers.get("X-Requested-With")

    data = None
    if request.method == "POST":
        data = request.get_data(cache=False)

    try:
        upstream = session.request(
            request.method,
            raw_url,
            headers=headers,
            data=data,
            allow_redirects=True,
            timeout=30,
        )
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Proxy request failed: {exc}"}), 502

    if upstream.status_code == 404:
        try:
            parsed_url = urlparse(raw_url)
        except Exception:
            parsed_url = None
        if parsed_url and "/static/css/fonts/" in (parsed_url.path or ""):
            fallback_path = parsed_url.path.replace("/static/css/fonts/", "/static/fonts/")
            fallback_url = parsed_url._replace(path=fallback_path).geturl()
            try:
                upstream = session.request(
                    request.method,
                    fallback_url,
                    headers=headers,
                    data=data,
                    allow_redirects=True,
                    timeout=30,
                )
            except requests.RequestException:
                pass
        if parsed_url and "/browse/static/css/" in (parsed_url.path or ""):
            fallback_path = parsed_url.path.replace("/browse/static/css/", "/browse/static/")
            fallback_url = parsed_url._replace(path=fallback_path).geturl()
            try:
                upstream = session.request(
                    request.method,
                    fallback_url,
                    headers=headers,
                    data=data,
                    allow_redirects=True,
                    timeout=30,
                )
            except requests.RequestException:
                pass
        if parsed_url and (parsed_url.path or "").startswith("/api/") and not (parsed_url.path or "").startswith("/browse/"):
            fallback_path = f"/browse{parsed_url.path}"
            fallback_url = parsed_url._replace(path=fallback_path).geturl()
            try:
                upstream = session.request(
                    request.method,
                    fallback_url,
                    headers=headers,
                    data=data,
                    allow_redirects=True,
                    timeout=30,
                )
            except requests.RequestException:
                pass

    content_type = upstream.headers.get("Content-Type", "application/octet-stream")
    body = upstream.content

    if "text/html" in content_type or "application/xhtml+xml" in content_type:
        rewritten = _rewrite_iochem_html(upstream.text, upstream.url, token)
        body = rewritten.encode("utf-8")
        content_type = "text/html; charset=utf-8"
    elif "text/css" in content_type:
        css_text = _decode_response_text(upstream)
        rewritten = _rewrite_css_urls(css_text, upstream.url, token)
        body = rewritten.encode("utf-8")
        content_type = "text/css; charset=utf-8"
    else:
        is_js = "javascript" in content_type.lower() if content_type else False
        if not is_js:
            try:
                path_lower = urlparse(upstream.url).path.lower()
            except Exception:
                path_lower = ""
            is_js = path_lower.endswith(".js")
        if is_js:
            js_text = _decode_response_text(upstream)
            rewritten = _replace_placeholder_text(js_text, upstream.url)
            body = rewritten.encode("utf-8")
            content_type = "application/javascript; charset=utf-8"

    response = Response(body, status=upstream.status_code, content_type=content_type)
    response.headers["Cache-Control"] = "no-store"
    return response


@app.route("/jsmol.php", methods=["GET"])
def proxy_jsmol():
    call = request.args.get("call", "")
    query = request.args.get("query", "")
    if call != "getRawDataFromDatabase":
        return jsonify({"status": "error", "message": "Unsupported call."}), 400
    if not query:
        return jsonify({"status": "error", "message": "Missing query."}), 400

    query = _unwrap_proxy_url(query)
    query = _replace_placeholder_host(query, IOCHEM_PROXY_DEFAULT_ORIGIN)
    try:
        parsed = urlparse(query)
    except Exception:
        return jsonify({"status": "error", "message": "Invalid query URL."}), 400
    if parsed.scheme not in ("http", "https") or not _is_allowed_iochem_host(parsed.hostname):
        return jsonify({"status": "error", "message": "Query host is not allowed."}), 403

    try:
        upstream = requests.get(query, timeout=30)
    except requests.RequestException as exc:
        return jsonify({"status": "error", "message": f"Upstream request failed: {exc}"}), 502

    content_type = upstream.headers.get("Content-Type", "application/octet-stream")
    response = Response(upstream.content, status=upstream.status_code, content_type=content_type)
    response.headers["Cache-Control"] = "no-store"
    return response


@app.route("/api/uma-ase/preview", methods=["POST"])
def preview_structure():
    geometry = request.files.get("geometry")
    if geometry is None or geometry.filename == "":
        return jsonify({"status": "error", "message": "Geometry file is required."}), 400

    raw_filename = secure_filename(geometry.filename) or "input.xyz"
    raw_payload = geometry.read()
    if not raw_payload:
        return jsonify({"status": "error", "message": "Uploaded geometry file is empty."}), 400

    charge_raw = request.form.get("charge")
    spin_raw = request.form.get("spin")
    spin_val = 1

    with tempfile.TemporaryDirectory(prefix="uma_preview_") as temp_dir_str:
        temp_dir = Path(temp_dir_str)
        try:
            storage = FileStorage(stream=io.BytesIO(raw_payload), filename=raw_filename)
            input_path, filename = _save_geometry_upload_for_ase(
                storage,
                temp_dir,
                charge=0,
                spin=spin_val,
                prefer_periodic_format=True,
            )
        except ValueError as exc:
            return jsonify({"status": "error", "message": str(exc)}), 400

        viewer_filename, viewer_content, viewer_cell_parameters, viewer_pbc = _viewer_geometry_from_payload(
            raw_filename,
            raw_payload,
        )

        metadata = extract_xyz_metadata(input_path)

        if charge_raw is None or charge_raw.strip() == "":
            charge_val = metadata.charge if metadata.charge is not None else 0
        else:
            try:
                charge_val = int(charge_raw)
            except (TypeError, ValueError):
                return jsonify({"status": "error", "message": "Charge must be an integer."}), 400

        if spin_raw is None or spin_raw.strip() == "":
            if metadata.spin is not None and metadata.spin > 0:
                spin_val = metadata.spin
            else:
                spin_val = 1
        else:
            try:
                spin_val = int(spin_raw)
            except (TypeError, ValueError):
                return jsonify({"status": "error", "message": "Spin multiplicity must be an integer."}), 400
            if spin_val <= 0:
                return jsonify({"status": "error", "message": "Spin multiplicity must be positive."}), 400

        try:
            atoms = read(str(input_path))
        except Exception as exc:  # pragma: no cover - depends on external IO
            return jsonify({"status": "error", "message": f"Unable to read geometry: {exc}"}), 400

        atoms.info["charge"] = charge_val
        atoms.info["spin"] = spin_val
        xyz_comment = metadata.comment
        if xyz_comment:
            atoms.info.setdefault("uma_comment", xyz_comment)
        if metadata.url:
            atoms.info.setdefault("uma_comment_url", metadata.url)

        counts = Counter(atoms.get_chemical_symbols())
        num_atoms = len(atoms)
        formula = atoms.get_chemical_formula()
        element_counts = dict(counts)

        # Decide device availability using fairchem rules
        try:
            device = select_device()
        except TorchUnavailable:
            device = "cpu"

    cell_parameters = viewer_cell_parameters
    if cell_parameters is None:
        cell_parameters = _extract_cell_parameters_from_atoms(atoms)
    pbc_flags = viewer_pbc
    if pbc_flags is None:
        pbc_flags = _extract_pbc_flags_from_atoms(atoms)

    summary_lines = [
        f"Number of atoms: {num_atoms}",
        f"Formula: {formula}",
        f"Element counts: {element_counts}",
        f"Device: {device}",
    ]
    summary_lines.insert(0, f"Spin multiplicity: {spin_val}")
    summary_lines.insert(0, f"Charge: {charge_val}")
    if xyz_comment:
        summary_lines.insert(0, f"Comment: {xyz_comment}")
    if metadata.url:
        summary_lines.insert(0, f"Source URL: {metadata.url}")

    return jsonify(
        {
            "status": "ok",
            "initial_geometry": filename,
            "num_atoms": num_atoms,
            "formula": formula,
            "element_counts": element_counts,
            "charge": charge_val,
            "spin": spin_val,
            "device": device,
            "comment": xyz_comment,
            "cell_parameters": cell_parameters,
            "pbc": pbc_flags,
            "viewer_filename": viewer_filename,
            "viewer_content": viewer_content,
            "lines": summary_lines,
        }
    )
def create_app() -> Flask:
    """Factory for embedding in external WSGI servers."""
    _load_job_store()
    _ensure_queue_worker()
    return app


def main() -> None:
    """Run the development server."""
    _load_job_store()
    _ensure_queue_worker()
    app.run(debug=True, host="0.0.0.0", port=8000)


if __name__ == "__main__":  # pragma: no cover
    main()
