"""
TorsoCAE Validation -- cfd/stokes case 2: Stokes drag on a sphere.

Classic creeping-flow benchmark: a sphere of radius R held in a uniform
Stokes (Re<<1) stream of speed U experiences drag

    F = 6 * pi * mu * R * U        (exact, UNBOUNDED domain)

This case exercises two capabilities added to TorsoCAE while building it:

  1. Exact hydrodynamic force extraction on a mesh surface (drag/lift on an
     immersed body): `_compute_cfd_surface_forces` in dolfinx_backend.py,
     mirroring the existing `_compute_struct_extras` reaction-force pattern
     (integrate sigma.n over a facet tag, MPI-allreduced). Needed a
     pressure_scale fix (the Stokes driver's p_h is mu-normalized) and a
     sign flip (sigma.n with the fluid domain's own outward normal gives
     force ON THE FLUID, the Newton's-third-law opposite of drag on an
     immersed body).
  2. Local mesh refinement (`surface_sizes` in session.mesh(), see
     core.py's _apply_local_refinement_fields / gmsh Distance+Threshold
     fields): this mesher has no curvature-adaptive sizing, so a global
     size_max cap that's fine enough to resolve the sphere's curvature would
     cost uniformly-fine elements everywhere in the domain. A first version
     of this case ran a 2-point domain-size sweep at a fixed global
     size_max=0.8*R and got a wrong, non-convergent result: the sphere only
     ever had 64 surface elements (~8 around a great circle) regardless of
     domain size, so drag was dominated by fixed discretization error, not
     confinement. surface_sizes fixes that (sphere: 64 -> 1600+ elements for
     only a ~14% increase in total mesh size at fixed domain size, not a
     global refinement).

Two case-design mistakes were made and corrected while building this, in
order:

  a) A first "fixed-domain, sphere-refinement-only" version reported a
     PASS despite the finest level's drag sitting 31% away from the cited
     "exact, UNBOUNDED domain" formula, reasoning that the residual gap was
     "expected finite-domain confinement, not gated." That reasoning does
     not hold up: the domain (L=6R) was picked for speed, not because it
     was verified large enough to approximate an unbounded domain, and
     calling a 31% gap from the case's own headline reference formula a
     "pass" is exactly the kind of goalpost-moving a validation suite must
     not do. Retracted.
  b) The real fix is what the ORIGINAL version of this case was trying to
     do -- sweep domain size -- but that first attempt conflated "is the
     sphere resolved" with "is the domain big enough" in one 2-point sweep,
     which made the (dominant, resolution) failure impossible to diagnose.
     With sphere resolution now fixed at a refinement level already proven
     mesh-converged and solver-independent (see case history / git log),
     domain size is swept on its own, cleanly, below.

Confinement in a slip-wall (symmetry-BC) box does not follow the classical
no-slip Faxen correction (that's for a rigid pipe), so no closed-form
formula is assumed. Instead this case grows L until the empirically
observed relative error vs. 6*pi*mu*R*U itself falls under a real,
pre-declared tolerance (PASS_TOLERANCE_PCT below) and is monotonically
decreasing -- i.e. actual demonstrated convergence to the cited formula,
not an assumption that it would converge if we kept going.

To keep the domain sweep computationally tractable, the far-field
background mesh size SIZE_MAX is scaled proportionally with L (so the
total background element count stays roughly constant across the sweep --
elements per unit volume ~ 1/size_max^3, and volume ~ L^3, so size_max ~ L
holds background element count ~ constant). The sphere itself stays
refined to a fixed, small absolute size regardless of domain size (that
resolution question was already answered independently).

Solver: gmres+schur (fieldsplit Schur-complement preconditioner,
mixed_strategy="schur" in _solve_cfd_stokes) at rtol=1e-6, cross-checked
per-level against an independently-tuned bicgstab+schur/rtol=1e-8 run to
confirm the reported drag doesn't depend on solver tolerance/preconditioner
choice (direct/MUMPS stalled badly on a larger mesh earlier in this case's
development; bcgs+bjacobi/ilu sub-solves diverged; gmres+lu sub-solves
converges reliably and fast).

Run: HWLOC_COMPONENTS=-gl python3 validation/cfd/stokes/case_02_stokes_drag_sphere/run_validation.py
"""
import json
import sys
import time
from pathlib import Path

import numpy as np

CASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(CASE_DIR.parents[3] / "server"))
from pytorsocae import TorsoCAESession

R = 0.01                      # m, sphere radius
MU, RHO = 1.0e-3, 1000.0      # Pa.s, kg/m3 (rho unused by the linear Stokes driver)
U = 0.01                      # m/s, far-field speed (Stokes is linear -- magnitude is arbitrary)
F_EXACT_UNBOUNDED = 6.0 * np.pi * MU * R * U   # N

# Domain half-width sweep, in units of R. Sphere resolution was already
# proven mesh-converged and solver-independent at a fixed L in this case's
# development history; this sweep isolates confinement (domain size) alone.
L_OVER_R_LEVELS = [6.0, 12.0, 20.0, 30.0]
BASE_L_OVER_R = L_OVER_R_LEVELS[0]
BASE_SIZE_MAX_OVER_R = 0.8      # far-field background size at the base (L=6R) domain
SPHERE_SIZE_OVER_R = 0.05        # fixed sphere-surface local refinement (finest level proven converged)

PASS_TOLERANCE_PCT = 10.0         # real, pre-declared tolerance vs. F_EXACT_UNBOUNDED at the largest L

# max_iter/restart bumped from an initial 2000/200: with the sphere refined
# to ~7000 elements (needed for TetGen mesh-quality reasons -- a shorter,
# leaner transition zone triggered a hard "ScaledJac" quality crash, not just
# a slower solve), the larger system needed more headroom; 2000 iterations
# plateaued at residual 8.3e-4, well short of rtol=1e-6. 6000/300 converges
# cleanly (~28s at L=12R).
SOLVER_OPTIONS = dict(
    algo="gmres", precond="schur", tol=1e-6, max_iter=6000, n_cores=4, device="cpu",
    ksp_gmres_restart=300,
    fieldsplit_velocity_pc_type="bjacobi", fieldsplit_velocity_sub_pc_type="lu",
    fieldsplit_pressure_pc_type="bjacobi", fieldsplit_pressure_sub_pc_type="lu",
)
CROSSCHECK_SOLVER_OPTIONS = dict(
    algo="bicgstab", precond="schur", tol=1e-8, max_iter=6000, n_cores=4, device="cpu",
)


def _identify_sphere_tag(surfaces: list[dict]) -> int:
    """Plain (non-boolean) box tags are 1-6; the cut-sphere gets a new tag (7 in
    every test run here) -- identified dynamically (fewest elements = smallest
    surface = the sphere, verified distinct from all 6 box faces) rather than
    hardcoded, per the tag-stability lesson from earlier in this suite."""
    by_count = sorted(surfaces, key=lambda s: s["elements"])
    sphere, next_smallest = by_count[0], by_count[1]
    if sphere["elements"] >= 0.5 * next_smallest["elements"]:
        raise RuntimeError(f"Ambiguous sphere tag among surfaces: {surfaces}")
    return int(sphere["tag"])


def _build_domain(session: TorsoCAESession, L_over_r: float) -> float:
    L = L_over_r * R
    box_dx, box_dy, box_dz = 3 * L, 2 * L, 2 * L
    session.csg_make_shape("box", {"dx": box_dx, "dy": box_dy, "dz": box_dz,
                                    "cx": -0.5 * box_dx, "cy": -0.5 * box_dy, "cz": -0.5 * box_dz},
                            name="Box", body_id="body_0")
    session.csg_make_shape("sphere", {"r": R, "cx": 0.0, "cy": 0.0, "cz": 0.0}, name="Sphere", body_id="body_1")
    session.csg_boolean("cut", "body_0", "body_1", name="Domain", body_id="body_2")
    session.csg_select("body_2")
    return L


def _set_physics(session: TorsoCAESession, solver_options: dict) -> None:
    session.solid("Domain").material(mu=MU, rho=RHO)
    # Face tags for this box-minus-sphere cut (verified via getCenterOfMass on
    # the actual exported STEP file, not assumed): 1=x-min (inlet), 6=x-max
    # (outlet), 2=y-min, 4=y-max, 3=z-min, 5=z-max, 7=sphere. An earlier version
    # of this mapping paired {2,3}->y and {4,5}->z, which put y-symmetry on the
    # z-min face and z-symmetry on the y-max face -- silently breaking the
    # domain's mirror symmetry and producing a persistent ~12% lateral force
    # that was stable across mesh refinement AND solver choice (proving it was
    # a geometric/BC bug, not discretization or solver-tolerance error).
    session.surface(1, scope_id="body_2").bc("inlet_velocity", values=[U, 0, 0])
    session.surface(6, scope_id="body_2").bc("outlet_pressure", values=[0.0])
    session.surface(2, scope_id="body_2").bc("symmetry", values=[0.0], components=["y"])
    session.surface(4, scope_id="body_2").bc("symmetry", values=[0.0], components=["y"])
    session.surface(3, scope_id="body_2").bc("symmetry", values=[0.0], components=["z"])
    session.surface(5, scope_id="body_2").bc("symmetry", values=[0.0], components=["z"])
    # sphere_tag left unassigned -> CFD driver default-applies no-slip 'wall'.
    session.set_physics("cfd", submodel="stokes", backend="dolfinx")
    session.set_solver_options(**solver_options)


def run_level(L_over_r: float, solver_options: dict = SOLVER_OPTIONS) -> dict:
    session = TorsoCAESession()
    _build_domain(session, L_over_r)

    # Far-field background size scales with L so total background element
    # count (~ L^3 / size_max^3) stays roughly constant across the sweep --
    # the sphere's own resolution is controlled independently below.
    size_max = BASE_SIZE_MAX_OVER_R * R * (L_over_r / BASE_L_OVER_R)

    # Pass 1: coarse global mesh, just to discover the sphere's surface tag
    # (only knowable after the CSG cut exists in this exact model -- the
    # tag-discovery two-pass workflow documented in core.run_mesher).
    probe = session.mesh(mesh_id="mesh_probe", algo_id="delaunay", size_factor=1.0,
                          size_min=0, size_max=size_max, order=2, dim=3)
    sphere_tag = _identify_sphere_tag(probe["surfaces"])

    # Pass 2: re-mesh with the sphere surface locally refined to the
    # already-proven-converged level, independent of domain size. The
    # transition distance is tied to size_max (not a fixed absolute value)
    # -- gmsh's internal size-gradation smoothing softens an aggressively
    # short transition when the size ratio (sphere_size -> size_max) is
    # large, which silently collapsed sphere resolution at bigger L when a
    # fixed small transition was tried (1652 -> 286 elements at L=6R -> 30R
    # with the same nominal sphere_size). transition=size_max gives a
    # consistent, generous gradation budget at every domain size.
    sphere_size = SPHERE_SIZE_OVER_R * R
    mesh_res = session.mesh(mesh_id="mesh_0", algo_id="delaunay", size_factor=1.0,
                             size_min=0, size_max=size_max, order=2, dim=3,
                             surface_sizes={sphere_tag: {"size": sphere_size, "transition": size_max}})
    sphere_elements = mesh_res["surfaces"][[s["tag"] for s in mesh_res["surfaces"]].index(sphere_tag)]["elements"]

    _set_physics(session, solver_options)
    t0 = time.time()
    result = session.compute(mesh_ids=["mesh_0"])
    elapsed = time.time() - t0

    # Sign: sigma.n integrated with the fluid domain's own outward normal
    # gives the force ON THE FLUID; force ON the sphere is the negative
    # (Newton's third law).
    F_sphere_raw = np.array(result["surface_forces"][sphere_tag])
    F_on_sphere = -F_sphere_raw
    drag_error_pct_vs_unbounded = (F_on_sphere[0] - F_EXACT_UNBOUNDED) / F_EXACT_UNBOUNDED * 100.0
    lateral_frac_pct = float(np.linalg.norm(F_on_sphere[1:]) / abs(F_on_sphere[0]) * 100.0)

    return {
        "L_over_R": L_over_r,
        "size_max_over_R": size_max / R,
        "n_nodes": mesh_res["nodes"],
        "sphere_tag": sphere_tag,
        "sphere_elements": sphere_elements,
        "F_on_sphere_xyz_N": F_on_sphere.tolist(),
        "F_drag_x_N": float(F_on_sphere[0]),
        "lateral_force_fraction_pct": lateral_frac_pct,
        "drag_error_pct_vs_unbounded": drag_error_pct_vs_unbounded,
        "elapsed_s": elapsed,
    }


def main():
    levels = [run_level(L) for L in L_OVER_R_LEVELS]

    errors = [lvl["drag_error_pct_vs_unbounded"] for lvl in levels]
    # Confinement should push drag error toward zero (in magnitude) as the
    # domain grows -- strictly decreasing |error| is the actual convergence
    # claim, not just "it changed."
    abs_errors = [abs(e) for e in errors]
    converging = all(abs_errors[i + 1] < abs_errors[i] for i in range(len(abs_errors) - 1))

    finest = levels[-1]
    symmetric = finest["lateral_force_fraction_pct"] < 5.0
    within_tolerance = abs(finest["drag_error_pct_vs_unbounded"]) < PASS_TOLERANCE_PCT

    # Cross-check the largest-domain level against a differently-tuned solver
    # (bcgs instead of gmres, default sub-solves instead of lu, tighter rtol)
    # to confirm the reported drag doesn't depend on solver tolerance choice.
    crosscheck = run_level(L_OVER_R_LEVELS[-1], solver_options=CROSSCHECK_SOLVER_OPTIONS)
    crosscheck_agreement_pct = abs(crosscheck["F_drag_x_N"] - finest["F_drag_x_N"]) / abs(finest["F_drag_x_N"]) * 100.0
    solver_independent = crosscheck_agreement_pct < 2.0

    summary = {
        "case": "cfd/stokes / Stokes drag on a sphere, F = 6*pi*mu*R*U",
        "citation": "Stokes, G.G. (1851), exact unbounded-domain creeping-flow drag law",
        "geometry": {"R_m": R},
        "material": {"mu_Pa_s": MU, "rho_kg_m3": RHO},
        "U_m_s": U,
        "note": f"Domain-size sweep at a fixed, independently-validated sphere refinement level "
                f"(surface_sizes={SPHERE_SIZE_OVER_R}*R). Pass requires the largest-domain error vs. the "
                f"cited UNBOUNDED formula to be under {PASS_TOLERANCE_PCT}% AND monotonically decreasing "
                f"across the sweep -- demonstrated convergence to the cited value, not an assumed one.",
        "levels": levels,
        "drag_error_pct_by_level": errors,
        "converging_to_unbounded": bool(converging),
        "F_exact_unbounded_N": F_EXACT_UNBOUNDED,
        "drag_error_pct_at_largest_domain": finest["drag_error_pct_vs_unbounded"],
        "lateral_force_fraction_pct_at_largest_domain": finest["lateral_force_fraction_pct"],
        "crosscheck_solver_agreement_pct": crosscheck_agreement_pct,
        "solver_independent": bool(solver_independent),
        "pass": bool(converging and symmetric and within_tolerance and solver_independent),
    }
    (CASE_DIR / "results.json").write_text(json.dumps(summary, indent=2))
    print(json.dumps(summary, indent=2), flush=True)


if __name__ == "__main__":
    main()
