"""Tests for ``aieng.simulation.deck_generator`` (Phase 33).

Coverage:

- ``missing_items`` rejection paths (materials / BCs / loads absent).
- ``mesh_source_deck`` rejection path when ``source_solver_deck.inp`` is absent.
- Generated ``.inp`` contains the required CalculiX keyword cards.
- ``FileExistsError`` when overwrite is not set.
- ``FileNotFoundError`` when the package does not exist.
- The generator never claims solver convergence (honesty boundary).
"""
from __future__ import annotations

import json
import zipfile
from pathlib import Path

import pytest
import yaml

from aieng.simulation.deck_generator import (
    MissingSetupError,
    SOLVER_INPUT_PATH_TEMPLATE,
    generate_solver_input_package,
)


# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------


_MINIMAL_SOURCE_DECK = """\
*NODE
1, 0.0, 0.0, 0.0
2, 1.0, 0.0, 0.0
3, 1.0, 1.0, 0.0
4, 0.0, 1.0, 0.0
*ELEMENT, TYPE=C3D4, ELSET=E_ALL
1, 1, 2, 3, 4
*NSET, NSET=N_FIX
1, 4
*NSET, NSET=N_LOAD
2, 3
*SOLID SECTION, ELSET=E_ALL, MATERIAL=Steel
1.0
"""


_SETUP_WITH_FEATURE_REFS = {
    "materials": {
        "Steel": {
            "youngs_modulus_mpa": 210000.0,
            "poisson_ratio": 0.3,
            "density_kg_m3": 7850.0,
        },
    },
    "boundary_conditions": [
        {"id": "bc_fix_001", "target_feature": "feat_fix", "type": "fixed"},
    ],
    "loads": [
        {
            "id": "load_pull_001",
            "target_feature": "feat_load",
            "value_n": 1000.0,
            "direction": [0, -1, 0],
        },
    ],
}


_CAE_MAPPING_WITH_FEATURES = {
    "mappings": [
        {"cae_entity": "N_FIX", "maps_to": {"feature_id": "feat_fix"}},
        {"cae_entity": "N_LOAD", "maps_to": {"feature_id": "feat_load"}},
    ],
}


def _write_package(
    path: Path,
    *,
    include_source_deck: bool = True,
    setup: dict | None = None,
    cae_mapping: dict | None = None,
    parsed_materials: dict | None = None,
    parsed_bcs: dict | None = None,
    parsed_loads: dict | None = None,
) -> Path:
    """Write a minimal .aieng package that can drive deck generation."""
    with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        manifest = {
            "model_id": "deck_gen_test",
            "format_version": "0.1.0",
            "units": {"length": "mm", "mass": "kg", "force": "N", "stress": "MPa"},
            "resources": {"simulation": {}, "results": {}},
            "created_by": {"tool": "test", "created_at": "2026-01-01T00:00:00Z"},
        }
        zf.writestr("manifest.json", json.dumps(manifest))
        zf.writestr("simulation/", b"")
        zf.writestr("simulation/cae_imports/", b"")
        zf.writestr("simulation/runs/", b"")
        if include_source_deck:
            zf.writestr("simulation/cae_imports/source_solver_deck.inp", _MINIMAL_SOURCE_DECK)
        if setup is not None:
            zf.writestr("simulation/setup.yaml", yaml.safe_dump(setup))
        if cae_mapping is not None:
            zf.writestr("simulation/cae_mapping.json", json.dumps(cae_mapping))
        if parsed_materials is not None:
            zf.writestr(
                "simulation/cae_imports/parsed_materials.json",
                json.dumps(parsed_materials),
            )
        if parsed_bcs is not None:
            zf.writestr(
                "simulation/cae_imports/parsed_boundary_conditions.json",
                json.dumps(parsed_bcs),
            )
        if parsed_loads is not None:
            zf.writestr(
                "simulation/cae_imports/parsed_loads.json",
                json.dumps(parsed_loads),
            )
    return path


# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------


def test_generates_deck_with_required_keyword_cards(tmp_path: Path) -> None:
    pkg = _write_package(
        tmp_path / "ok.aieng",
        setup=_SETUP_WITH_FEATURE_REFS,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    result = generate_solver_input_package(pkg, run_id="run_001")
    assert result["ok"] is True
    assert result["missing_items"] == []

    out_path = SOLVER_INPUT_PATH_TEMPLATE.format(run_id="run_001")
    with zipfile.ZipFile(pkg) as zf:
        assert out_path in zf.namelist()
        deck = zf.read(out_path).decode("utf-8")

    # Required CalculiX keyword cards must be present.
    for kw in ("*HEADING", "*NODE", "*ELEMENT", "*MATERIAL", "*ELASTIC", "*DENSITY",
               "*BOUNDARY", "*CLOAD", "*STEP", "*STATIC", "*END STEP"):
        assert kw in deck, f"missing keyword card: {kw}"


def test_manifest_records_solver_input_for_run(tmp_path: Path) -> None:
    pkg = _write_package(
        tmp_path / "ok.aieng",
        setup=_SETUP_WITH_FEATURE_REFS,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    generate_solver_input_package(pkg, run_id="run_001")
    with zipfile.ZipFile(pkg) as zf:
        manifest = json.loads(zf.read("manifest.json"))
    runs = manifest["resources"]["simulation"]["runs"]
    assert runs["run_001"]["solver_input"] == SOLVER_INPUT_PATH_TEMPLATE.format(run_id="run_001")


# ---------------------------------------------------------------------------
# missing_items rejection paths
# ---------------------------------------------------------------------------


def test_refuses_when_source_deck_missing(tmp_path: Path) -> None:
    pkg = _write_package(
        tmp_path / "no_mesh.aieng",
        include_source_deck=False,
        setup=_SETUP_WITH_FEATURE_REFS,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    with pytest.raises(MissingSetupError) as exc:
        generate_solver_input_package(pkg, run_id="run_002")
    assert "mesh_source_deck" in str(exc.value)


def test_refuses_when_materials_missing(tmp_path: Path) -> None:
    setup = {
        "boundary_conditions": _SETUP_WITH_FEATURE_REFS["boundary_conditions"],
        "loads": _SETUP_WITH_FEATURE_REFS["loads"],
    }
    pkg = _write_package(
        tmp_path / "no_mat.aieng",
        setup=setup,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    with pytest.raises(MissingSetupError) as exc:
        generate_solver_input_package(pkg, run_id="run_003")
    assert "materials" in str(exc.value)


def test_refuses_when_boundary_conditions_missing(tmp_path: Path) -> None:
    setup = {
        "materials": _SETUP_WITH_FEATURE_REFS["materials"],
        "loads": _SETUP_WITH_FEATURE_REFS["loads"],
    }
    pkg = _write_package(
        tmp_path / "no_bc.aieng",
        setup=setup,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    with pytest.raises(MissingSetupError) as exc:
        generate_solver_input_package(pkg, run_id="run_004")
    assert "boundary_conditions" in str(exc.value)


def test_refuses_when_loads_missing(tmp_path: Path) -> None:
    setup = {
        "materials": _SETUP_WITH_FEATURE_REFS["materials"],
        "boundary_conditions": _SETUP_WITH_FEATURE_REFS["boundary_conditions"],
    }
    pkg = _write_package(
        tmp_path / "no_load.aieng",
        setup=setup,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    with pytest.raises(MissingSetupError) as exc:
        generate_solver_input_package(pkg, run_id="run_005")
    assert "loads" in str(exc.value)


# ---------------------------------------------------------------------------
# Package-level error paths
# ---------------------------------------------------------------------------


def test_missing_package_raises_file_not_found(tmp_path: Path) -> None:
    with pytest.raises(FileNotFoundError):
        generate_solver_input_package(tmp_path / "does_not_exist.aieng")


def test_non_aieng_suffix_rejected(tmp_path: Path) -> None:
    bad = tmp_path / "wrong.zip"
    bad.write_bytes(b"")
    with pytest.raises(ValueError, match=r"\.aieng"):
        generate_solver_input_package(bad)


def test_existing_deck_requires_overwrite(tmp_path: Path) -> None:
    pkg = _write_package(
        tmp_path / "again.aieng",
        setup=_SETUP_WITH_FEATURE_REFS,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    generate_solver_input_package(pkg, run_id="run_006")
    with pytest.raises(FileExistsError):
        generate_solver_input_package(pkg, run_id="run_006")
    # overwrite=True must succeed without raising
    result = generate_solver_input_package(pkg, run_id="run_006", overwrite=True)
    assert result["ok"] is True


# ---------------------------------------------------------------------------
# Honesty boundary — no convergence claim is emitted
# ---------------------------------------------------------------------------


def test_generated_deck_does_not_claim_convergence(tmp_path: Path) -> None:
    """The deck generator must never declare convergence or solver evidence.

    Convergence stays ``null`` until external solver evidence is imported.
    See issue #54 honesty boundaries.
    """
    pkg = _write_package(
        tmp_path / "honesty.aieng",
        setup=_SETUP_WITH_FEATURE_REFS,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    generate_solver_input_package(pkg, run_id="run_honesty")
    out_path = SOLVER_INPUT_PATH_TEMPLATE.format(run_id="run_honesty")
    with zipfile.ZipFile(pkg) as zf:
        deck = zf.read(out_path).decode("utf-8")
        # Generator must not touch result/convergence resources at all.
        for forbidden in (
            "results/solver_evidence",
            "validation/status.yaml",
            "results/claim_map.json",
            "results/evidence_index.json",
        ):
            assert forbidden not in zf.namelist(), (
                f"deck_generator must not synthesize {forbidden}; only an external solver may."
            )

    lowered = deck.lower()
    for forbidden_phrase in ("converged: true", "convergence: passed", "solver run completed"):
        assert forbidden_phrase not in lowered


def test_setup_material_renamed_to_match_solid_section(tmp_path: Path) -> None:
    """When setup material name differs from *SOLID SECTION, generator aligns
    the deck to keep it runnable and records a warning."""
    setup = {
        "materials": {
            "Aluminum_6061": {  # differs from "Steel" in source deck *SOLID SECTION
                "youngs_modulus_mpa": 69000.0,
                "poisson_ratio": 0.33,
                "density_kg_m3": 2700.0,
            },
        },
        "boundary_conditions": _SETUP_WITH_FEATURE_REFS["boundary_conditions"],
        "loads": _SETUP_WITH_FEATURE_REFS["loads"],
    }
    pkg = _write_package(
        tmp_path / "rename.aieng",
        setup=setup,
        cae_mapping=_CAE_MAPPING_WITH_FEATURES,
    )
    result = generate_solver_input_package(pkg, run_id="run_rename")
    assert any("Renaming setup material" in w for w in result["warnings"])
    out_path = SOLVER_INPUT_PATH_TEMPLATE.format(run_id="run_rename")
    with zipfile.ZipFile(pkg) as zf:
        deck = zf.read(out_path).decode("utf-8")
    assert "*MATERIAL, NAME=STEEL" in deck
