"""ANSYS Mechanical 2024 R2 declarative static-structural adapter v2."""

from __future__ import annotations

import csv
import json
import math
import os
import shutil
import sys
from importlib.metadata import version
from pathlib import Path
from typing import Any

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from ansys_geometry import (  # noqa: E402
    ANSYS_REVISION,
    EXECUTION_GATE,
    artifact_manifest,
    atomic_json,
    export_geometry_image,
    file_sha256,
    geometry_inventory,
    import_geometry,
    input_file,
    probe,
    resolve_scope,
    selected_face_records,
    terminal,
    validate_artifact,
)

ADAPTER_VERSION = "0.3.0"
_UNIT_SYSTEMS = {
    "m_kg_s": ("StandardMKS", "m"),
    "mm_kg_s": ("StandardNMM", "mm"),
}
_ARTIFACTS = {
    "project.mechdat": ("project", "application/octet-stream"),
    "summary.json": ("summary", "application/json"),
    "result-table.csv": ("result_table", "text/csv"),
    "selection-preview.png": ("selection_preview", "image/png"),
    "equivalent-stress.png": ("stress_image", "image/png"),
    "total-deformation.png": ("deformation_image", "image/png"),
    "solver-output.txt": ("solver_log", "text/plain"),
    "analysis-spec.json": ("analysis_spec", "application/json"),
    "provenance.json": ("provenance", "application/json"),
}


def _validate_semantics(request: dict[str, Any]) -> None:
    analysis = request["operation"]["analysis"]
    input_keys = {item["key"] for item in request["inputs"]}
    if analysis["geometry"]["input"] not in input_keys:
        raise ValueError("GEOMETRY_INPUT_NOT_BOUND")

    conditions = analysis["boundary_conditions"] + analysis["loads"]
    names = [item["name"] for item in conditions]
    if len({item.casefold() for item in names}) != len(names):
        raise ValueError("CONDITION_NAMES_MUST_BE_UNIQUE")

    for item in conditions:
        scope = item["scope"]
        if scope["type"] == "planar_faces":
            normal = scope["normal"]
            if not any(value != 0 for value in normal):
                raise ValueError("PLANE_NORMAL_MUST_BE_NONZERO")

    for load in analysis["loads"]:
        values = load.get("components", [load.get("magnitude")])
        if not all(isinstance(item, (int, float)) and math.isfinite(item) for item in values):
            raise ValueError("LOAD_VALUES_MUST_BE_FINITE")
        if load["type"] == "force" and not any(item != 0 for item in values):
            raise ValueError("FORCE_VECTOR_MUST_BE_NONZERO")
        if load["type"] == "pressure" and load["magnitude"] == 0:
            raise ValueError("PRESSURE_MUST_BE_NONZERO")

    output_keys = [item["key"] for item in request["outputs"]]
    if len(set(output_keys)) != len(output_keys):
        raise ValueError("OUTPUT_KEYS_MUST_BE_UNIQUE")


def _load_request(job_dir: Path) -> dict[str, Any]:
    record = json.loads(
        (job_dir / "request" / "request.json").read_text(encoding="utf-8")
    )
    request = record.get("request")
    if not isinstance(request, dict):
        raise TypeError("JOB_REQUEST_MISSING")
    _validate_semantics(request)
    return record


def _quantity(value: Any) -> dict[str, Any]:
    return {"value": float(value.Value), "unit": str(value.Unit)}


def _result_rows(result_key: str, scope: str, result: Any) -> list[dict[str, Any]]:
    if result_key == "reaction_force":
        names = ("x", "y", "z", "total")
        values = (result.XAxis, result.YAxis, result.ZAxis, result.Total)
    else:
        names = ("minimum", "maximum")
        values = (result.Minimum, result.Maximum)
    return [
        {"result": result_key, "scope": scope, "metric": name, **_quantity(value)}
        for name, value in zip(names, values, strict=True)
    ]


def _write_result_table(path: Path, rows: list[dict[str, Any]]) -> None:
    with path.open("w", encoding="utf-8-sig", newline="") as handle:
        writer = csv.DictWriter(
            handle,
            fieldnames=["result", "scope", "metric", "value", "unit"],
        )
        writer.writeheader()
        writer.writerows(rows)


def _export_result_image(app: Any, api: dict[str, Any], result: Any, path: Path) -> None:
    app.Tree.Activate([result])
    app.Graphics.Camera.SetFit()
    settings = api["Ansys"].Mechanical.Graphics.GraphicsImageExportSettings()
    settings.Resolution = api["GraphicsResolutionType"].EnhancedResolution
    settings.Width = 1600
    settings.Height = 1200
    app.Graphics.ExportImage(str(path), api["GraphicsImageExportFormat"].PNG, settings)
    validate_artifact(path)


def _copy_solver_log(analysis: Any, target: Path) -> None:
    source = Path(str(analysis.WorkingDir)) / "solve.out"
    if not source.is_file() or source.stat().st_size == 0:
        raise RuntimeError("SOLVER_OUTPUT_NOT_FOUND")
    shutil.copyfile(source, target)


def run(job_dir: Path) -> list[dict[str, Any]]:
    job_dir = job_dir.resolve(strict=True)
    record = _load_request(job_dir)
    request = record["request"]
    analysis_spec = request["operation"]["analysis"]
    geometry = input_file(job_dir, analysis_spec["geometry"]["input"])
    output = job_dir / "output"
    output.mkdir(exist_ok=True)
    requested_outputs = {item["key"] for item in request["outputs"]}

    from ansys.mechanical.core import App

    result_rows: list[dict[str, Any]] = []
    result_summary: dict[str, list[dict[str, Any]]] = {}
    resolved_summary: list[dict[str, Any]] = []
    software_version: int | None = None
    solution_status = "unknown"
    solver_log_path = output / "solver-output.txt"
    selection_preview_path = output / "selection-preview.png"
    geometry_summary: dict[str, Any] = {}
    with App(version=ANSYS_REVISION, private_appdata=True) as app:
        software_version = app.version
        api: dict[str, Any] = {}
        app.update_globals(api)
        unit_enum, length_unit = _UNIT_SYSTEMS[analysis_spec["unit_system"]]
        app.ExtAPI.Application.ActiveUnitSystem = getattr(
            api["MechanicalUnitSystem"], unit_enum
        )
        model = import_geometry(app, api, geometry)
        inventory, face_entities = geometry_inventory(app)
        geometry_summary = {
            "geometry_unit": inventory["geometry_unit"],
            "counts": inventory["counts"],
            "bounding_box": inventory["bounding_box"],
        }

        resolved: dict[str, dict[str, Any]] = {}
        all_entity_ids: list[int] = []
        conditions = analysis_spec["boundary_conditions"] + analysis_spec["loads"]
        for item in conditions:
            value = resolve_scope(
                app,
                api,
                inventory,
                face_entities,
                item["scope"],
                length_unit,
                item["name"],
            )
            resolved[item["name"]] = value
            all_entity_ids.extend(value["entity_ids"])
            resolved_summary.append(
                {
                    "condition": item["name"],
                    "source": value["source"],
                    "mechanical_name": value["name"],
                    "scope": item["scope"],
                    "entity_ids": value["entity_ids"],
                    "faces": selected_face_records(inventory, value["entity_ids"]),
                }
            )

        preview_selection = app.ExtAPI.SelectionManager.CreateSelectionInfo(
            api["SelectionTypeEnum"].GeometryEntities
        )
        preview_selection.Ids = sorted(set(all_entity_ids))
        export_geometry_image(app, api, selection_preview_path, preview_selection)

        bodies = list(model.GetChildren(api["DataModelObjectCategory"].Body, True))
        if not bodies:
            raise RuntimeError("IMPORTED_GEOMETRY_HAS_NO_BODIES")
        for body in bodies:
            body.Material = "Structural Steel"

        model.Mesh.ElementSize = api["Quantity"](
            analysis_spec["mesh"]["global_size"], length_unit
        )
        analysis = model.AddStaticStructuralAnalysis()
        fixed_supports: list[tuple[str, Any]] = []
        for item in analysis_spec["boundary_conditions"]:
            support = analysis.AddFixedSupport()
            support.Name = item["name"]
            support.Location = resolved[item["name"]]["location"]
            fixed_supports.append((item["name"], support))

        for item in analysis_spec["loads"]:
            if item["type"] == "force":
                load = analysis.AddForce()
                load.Name = item["name"]
                load.Location = resolved[item["name"]]["location"]
                load.DefineBy = api["LoadDefineBy"].Components
                for field_name, value in zip(
                    ("XComponent", "YComponent", "ZComponent"),
                    item["components"],
                    strict=True,
                ):
                    getattr(load, field_name).Output.DiscreteValues = [
                        api["Quantity"](value, "N")
                    ]
            else:
                load = analysis.AddPressure()
                load.Name = item["name"]
                load.Location = resolved[item["name"]]["location"]
                load.Magnitude.Output.DiscreteValues = [
                    api["Quantity"](item["magnitude"], "Pa")
                ]

        solution = analysis.Solution
        requested_results = set(analysis_spec["results"])
        if "deformation_image" in requested_outputs:
            requested_results.add("total_deformation")
        if "stress_image" in requested_outputs:
            requested_results.add("equivalent_stress")
        results: dict[str, list[tuple[str, Any]]] = {}
        if "total_deformation" in requested_results:
            results["total_deformation"] = [("all", solution.AddTotalDeformation())]
        if "equivalent_stress" in requested_results:
            results["equivalent_stress"] = [("all", solution.AddEquivalentStress())]
        if "reaction_force" in requested_results:
            reactions = []
            for condition_name, support in fixed_supports:
                reaction = solution.AddForceReaction()
                reaction.BoundaryConditionSelection = support
                reactions.append((condition_name, reaction))
            results["reaction_force"] = reactions

        solution.Solve(True)
        solution_status = str(solution.Status)
        if "done" not in solution_status.casefold():
            raise RuntimeError(f"MECHANICAL_SOLUTION_NOT_DONE:{solution_status}")
        for key, scoped_results in results.items():
            rows = [
                row
                for scope, result in scoped_results
                for row in _result_rows(key, scope, result)
            ]
            result_rows.extend(rows)
            result_summary[key] = rows

        _copy_solver_log(analysis, solver_log_path)
        if "stress_image" in requested_outputs:
            _export_result_image(
                app,
                api,
                results["equivalent_stress"][0][1],
                output / "equivalent-stress.png",
            )
        if "deformation_image" in requested_outputs:
            _export_result_image(
                app,
                api,
                results["total_deformation"][0][1],
                output / "total-deformation.png",
            )
        if "project" in requested_outputs:
            app.save_as(str(output / "project.mechdat"))

    result_table_path = output / "result-table.csv"
    _write_result_table(result_table_path, result_rows)
    solver_text = solver_log_path.read_text(encoding="utf-8", errors="replace")
    summary_path = output / "summary.json"
    atomic_json(
        summary_path,
        {
            "title": analysis_spec["title"],
            "analysis": "static_structural",
            "software": "ANSYS Mechanical",
            "software_version": software_version,
            "solution_status": solution_status,
            "unit_system": analysis_spec["unit_system"],
            "material": "Structural Steel",
            "mesh": analysis_spec["mesh"],
            "geometry": geometry_summary,
            "resolved_scopes": resolved_summary,
            "results": result_summary,
            "license_received_message": (
                "requested license was received" in solver_text.casefold()
            ),
        },
    )
    analysis_spec_path = output / "analysis-spec.json"
    provenance_path = output / "provenance.json"
    atomic_json(analysis_spec_path, request)
    atomic_json(
        provenance_path,
        {
            "adapter_version": ADAPTER_VERSION,
            "pymechanical_version": version("ansys-mechanical-core"),
            "ansys_revision": ANSYS_REVISION,
            "request_digest": record["request_digest"],
            "input": {
                "key": analysis_spec["geometry"]["input"],
                "filename": geometry.name,
                "sha256": file_sha256(geometry),
            },
            "resolved_scopes": resolved_summary,
            "outputs": request["outputs"],
        },
    )

    paths = [
        summary_path,
        result_table_path,
        selection_preview_path,
        analysis_spec_path,
        provenance_path,
    ]
    if "project" in requested_outputs:
        paths.append(output / "project.mechdat")
    if "stress_image" in requested_outputs:
        paths.append(output / "equivalent-stress.png")
    if "deformation_image" in requested_outputs:
        paths.append(output / "total-deformation.png")
    if "solver_log" in requested_outputs:
        paths.append(solver_log_path)
    else:
        solver_log_path.unlink(missing_ok=True)
    for path in paths:
        validate_artifact(path)
    return [artifact_manifest(path, _ARTIFACTS) for path in paths]


def main() -> int:
    if sys.argv[1:] == ["--probe"]:
        return probe(ADAPTER_VERSION)
    acceptance = len(sys.argv) == 3 and sys.argv[1] == "--acceptance"
    if not acceptance and len(sys.argv) != 2:
        print("[ERR] Usage: worker.py [--acceptance] <job-directory>", file=sys.stderr)
        return 2
    job_dir = Path(sys.argv[-1])
    record: dict[str, Any] = {}
    try:
        record = _load_request(job_dir)
        if not acceptance and os.environ.get(EXECUTION_GATE) != "1":
            raise RuntimeError("ANSYS_242_REAL_LICENSE_ACCEPTANCE_REQUIRED")
        artifacts = run(job_dir)
        atomic_json(job_dir / "artifacts.json", artifacts)
        atomic_json(job_dir / "terminal.json", terminal(record, "succeeded", artifacts, {}))
        print("[OK] ANSYS Mechanical job completed.")
        return 0
    except Exception as exc:  # noqa: BLE001 - terminal.json must capture vendor failures
        atomic_json(
            job_dir / "terminal.json",
            terminal(
                record,
                "failed",
                [],
                {"code": type(exc).__name__, "detail": str(exc)[:500]},
            ),
        )
        print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
