###############################################################################
# (c) Copyright 2024 CERN for the benefit of the LHCb Collaboration          #
#                                                                             #
# This software is distributed under the terms of the GNU General Public      #
# Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING".  #
#                                                                             #
# In applying this licence, CERN does not waive the privileges and immunities #
# granted to it by virtue of its status as an Intergovernmental Organization  #
# or submit itself to any jurisdiction.                                       #
###############################################################################
"""Unit tests for the typer-based CWL CLI commands."""

from unittest.mock import MagicMock

import pytest
from typer.testing import CliRunner

from LbAPLocal.cli.main import app

runner = CliRunner()


# ---------------------------------------------------------------------------
# A. Help and basic invocation
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "args",
    [
        ["test", "--help"],
        ["cwl", "test", "--help"],
        ["cwl", "generate", "--help"],
        ["cwl", "run", "--help"],
        ["cwl", "validate", "--help"],
    ],
)
def test_help(args):
    result = runner.invoke(app, args)
    assert result.exit_code == 0, result.output


# ---------------------------------------------------------------------------
# B. cwl validate / cwl run - file existence and not-implemented paths
# ---------------------------------------------------------------------------


def test_cwl_validate_missing_file(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    result = runner.invoke(app, ["cwl", "validate", "nonexistent.cwl"])
    assert result.exit_code == 1
    assert "not found" in result.output


def test_cwl_validate_existing_file(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    cwl_file = tmp_path / "workflow.cwl"
    cwl_file.write_text("cwlVersion: v1.2\n")
    result = runner.invoke(app, ["cwl", "validate", str(cwl_file)])
    assert result.exit_code == 0
    assert "not yet implemented" in result.output


def test_cwl_run_missing_file(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    result = runner.invoke(app, ["cwl", "run", "nonexistent.cwl"])
    assert result.exit_code == 1
    assert "not found" in result.output


def test_cwl_run_existing_file(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    cwl_file = tmp_path / "workflow.cwl"
    cwl_file.write_text("cwlVersion: v1.2\n")
    result = runner.invoke(app, ["cwl", "run", str(cwl_file)])
    assert result.exit_code == 0
    assert "not yet implemented" in result.output


def test_cwl_generate_not_implemented(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.available_productions", lambda: ["prod1"])
    monkeypatch.setattr(
        "LbAPLocal.cli.main.cwl_generate_wizard",
        lambda _: {"production_name": "prod1", "job_name": "job1"},
    )
    result = runner.invoke(app, ["cwl", "generate"])
    assert result.exit_code == 0
    assert "not yet implemented" in result.output


# ---------------------------------------------------------------------------
# C. test command - dry-run path
# ---------------------------------------------------------------------------


def test_dry_run_not_in_datapkg(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr(
        "LbAPLocal.cli.main.inside_ap_datapkg",
        MagicMock(side_effect=RuntimeError("Running command in wrong directory")),
    )
    result = runner.invoke(app, ["test", "myprod", "myjob", "--dry-run"])
    assert result.exit_code == 1
    assert "Error" in result.output


def test_dry_run_missing_production(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr(
        "LbAPLocal.cli.main.check_production",
        MagicMock(side_effect=RuntimeError("Production 'noprod' not found")),
    )
    result = runner.invoke(app, ["test", "noprod", "myjob", "--dry-run"])
    assert result.exit_code == 1
    assert "Error" in result.output


def test_dry_run_env_validation_failure(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr(
        "LbAPLocal.cli.main.validate_environment",
        MagicMock(side_effect=RuntimeError("Missing DIRAC proxy")),
    )
    result = runner.invoke(app, ["test", "myprod", "myjob", "--dry-run"])
    assert result.exit_code == 1
    assert "Environment validation failed" in result.output


def test_dry_run_missing_info_yaml(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", lambda: None)
    # Create production dir without info.yaml
    (tmp_path / "myprod").mkdir()
    result = runner.invoke(app, ["test", "myprod", "myjob", "--dry-run"])
    assert result.exit_code == 1
    assert "info.yaml not found" in result.output


def test_dry_run_invalid_job(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", lambda: None)
    # The dry-run path does a local `from LbAPCommon import parse_yaml, render_yaml`
    # so we must patch at the LbAPCommon module level
    monkeypatch.setattr("LbAPCommon.render_yaml", lambda raw: raw)
    monkeypatch.setattr(
        "LbAPCommon.parse_yaml",
        lambda rendered, prod, _: {"real_job": {}},
    )
    prod_dir = tmp_path / "myprod"
    prod_dir.mkdir()
    (prod_dir / "info.yaml").write_text(
        "jobs:\n  real_job:\n    application: DaVinci/v1\n"
    )
    result = runner.invoke(app, ["test", "myprod", "wrong_job", "--dry-run"])
    assert result.exit_code == 1
    assert "not found" in result.output
    assert "real_job" in result.output


def test_dry_run_valid(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", lambda: None)
    # Patch at LbAPCommon level since dry-run does local imports
    monkeypatch.setattr("LbAPCommon.render_yaml", lambda raw: raw)
    monkeypatch.setattr(
        "LbAPCommon.parse_yaml",
        lambda rendered, prod, _: {"myjob": {"application": "DaVinci/v1"}},
    )
    monkeypatch.setattr("LbAPCommon.validate_yaml", lambda jobs, prod: [])
    prod_dir = tmp_path / "myprod"
    prod_dir.mkdir()
    (prod_dir / "info.yaml").write_text(
        "jobs:\n  myjob:\n    application: DaVinci/v1\n"
    )
    result = runner.invoke(app, ["test", "myprod", "myjob", "--dry-run"])
    assert result.exit_code == 0, result.output
    assert "Dry-run complete" in result.output


# ---------------------------------------------------------------------------
# D. cwl test command - early error paths
# ---------------------------------------------------------------------------


def test_cwl_test_not_in_datapkg(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr(
        "LbAPLocal.cli.main.inside_ap_datapkg",
        MagicMock(side_effect=RuntimeError("Running command in wrong directory")),
    )
    monkeypatch.setattr(
        "LbAPLocal.cli.main.available_productions",
        MagicMock(side_effect=RuntimeError("not in datapkg")),
    )
    result = runner.invoke(app, ["cwl", "test", "myprod", "myjob"])
    assert result.exit_code == 1
    assert "Error" in result.output


def test_cwl_test_env_validation_failure(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.available_productions", lambda: ["myprod"])
    monkeypatch.setattr(
        "LbAPLocal.cli.main.validate_environment",
        MagicMock(side_effect=RuntimeError("No proxy")),
    )
    result = runner.invoke(app, ["cwl", "test", "myprod", "myjob"])
    assert result.exit_code == 1
    assert "Environment validation failed" in result.output


def test_cwl_test_skip_validation(tmp_path, monkeypatch):
    """--skip-validation should bypass validate_environment and proceed further."""
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.available_productions", lambda: ["myprod"])
    validate_mock = MagicMock(side_effect=RuntimeError("Should not be called"))
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", validate_mock)
    # It should skip validation but fail at info.yaml check
    prod_dir = tmp_path / "myprod"
    prod_dir.mkdir()
    result = runner.invoke(app, ["cwl", "test", "myprod", "myjob", "--skip-validation"])
    assert result.exit_code == 1
    assert "info.yaml not found" in result.output
    validate_mock.assert_not_called()


def test_cwl_test_missing_info_yaml(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.available_productions", lambda: ["myprod"])
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", lambda: None)
    (tmp_path / "myprod").mkdir()
    result = runner.invoke(app, ["cwl", "test", "myprod", "myjob"])
    assert result.exit_code == 1
    assert "info.yaml not found" in result.output


def test_cwl_test_invalid_job_name(tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.available_productions", lambda: ["myprod"])
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", lambda: None)
    # cwl_test does local `from LbAPCommon import parse_yaml, render_yaml`
    monkeypatch.setattr("LbAPCommon.render_yaml", lambda raw: raw)
    monkeypatch.setattr(
        "LbAPCommon.parse_yaml",
        lambda rendered, prod, _: {"real_job": {}},
    )
    prod_dir = tmp_path / "myprod"
    prod_dir.mkdir()
    (prod_dir / "info.yaml").write_text(
        "jobs:\n  real_job:\n    application: DaVinci/v1\n"
    )
    result = runner.invoke(app, ["cwl", "test", "myprod", "wrong_job"])
    assert result.exit_code == 1
    assert "not found" in result.output
    assert "real_job" in result.output


def test_cwl_test_executor_not_found(tmp_path, monkeypatch):
    import json

    # Force import of LbAPCommon.__main__ BEFORE mocking parse_yaml,
    # so monkeypatch saves the real references for proper cleanup.
    import LbAPCommon.__main__

    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.available_productions", lambda: ["myprod"])
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", lambda: None)

    def _fake_parse(rendered, prod, _):
        return {
            "myjob": {"application": "DaVinci/v64r8@x86_64_v2-el9-gcc13+detdesc-opt"}
        }

    monkeypatch.setattr("LbAPCommon.render_yaml", lambda raw: raw)
    monkeypatch.setattr("LbAPCommon.parse_yaml", _fake_parse)
    monkeypatch.setattr(LbAPCommon.__main__, "parse_yaml", _fake_parse)
    monkeypatch.setattr(LbAPCommon.__main__, "render_yaml", lambda raw: raw)

    out_dir = tmp_path / "out"
    out_dir.mkdir(parents=True)

    # Mock LbAPCommon.__main__.main to produce dirac_metadata.json
    metadata = {
        "productions": {
            "myprod_myjob": {
                "request": [{"name": "step#DaVinci"}],
            }
        }
    }

    async def fake_lbapcommon_main(*args, **kwargs):
        output_file = kwargs.get("output_file")
        if output_file:
            with open(output_file, "w") as f:
                json.dump(metadata, f)

    monkeypatch.setattr(LbAPCommon.__main__, "main", fake_lbapcommon_main)

    # Mock fromProductionRequestYAMLToCWL to produce a CWL object
    fake_cwl = MagicMock()
    monkeypatch.setattr(
        "LbAPCommon.prod_request_to_cwl.fromProductionRequestYAMLToCWL",
        lambda *a, **kw: (fake_cwl, {}, None),
    )
    monkeypatch.setattr("cwl_utils.parser.save", lambda obj: {})

    prod_dir = tmp_path / "myprod"
    prod_dir.mkdir()
    (prod_dir / "info.yaml").write_text(
        "jobs:\n  myjob:\n    application: DaVinci/v64r8@x86_64_v2-el9-gcc13+detdesc-opt\n"
    )

    # Executor check: shutil.which returns None → "not found" error
    monkeypatch.setattr("shutil.which", lambda _: None)

    result = runner.invoke(
        app,
        ["cwl", "test", "myprod", "myjob", "--skip-validation", "-o", str(out_dir)],
    )
    assert result.exit_code == 1
    assert "not found" in result.output


# ---------------------------------------------------------------------------
# E. --pretty experimental warning
# ---------------------------------------------------------------------------


def test_pretty_shows_experimental_warning(tmp_path, monkeypatch):
    """--pretty should display an experimental warning before execution."""
    import json

    # Force import of LbAPCommon.__main__ BEFORE mocking parse_yaml,
    # so monkeypatch saves the real references for proper cleanup.
    import LbAPCommon.__main__

    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("LbAPLocal.cli.main.inside_ap_datapkg", lambda: None)
    monkeypatch.setattr("LbAPLocal.cli.main.check_production", lambda _: None)
    monkeypatch.setattr("LbAPLocal.cli.main.available_productions", lambda: ["myprod"])
    monkeypatch.setattr("LbAPLocal.cli.main.validate_environment", lambda: None)

    def _fake_parse(rendered, prod, _):
        return {
            "myjob": {"application": "DaVinci/v64r8@x86_64_v2-el9-gcc13+detdesc-opt"}
        }

    monkeypatch.setattr("LbAPCommon.render_yaml", lambda raw: raw)
    monkeypatch.setattr("LbAPCommon.parse_yaml", _fake_parse)
    monkeypatch.setattr(LbAPCommon.__main__, "parse_yaml", _fake_parse)
    monkeypatch.setattr(LbAPCommon.__main__, "render_yaml", lambda raw: raw)

    out_dir = tmp_path / "out"
    out_dir.mkdir(parents=True)

    metadata = {
        "productions": {
            "myprod_myjob": {
                "request": [{"name": "step#DaVinci"}],
            }
        }
    }

    async def fake_lbapcommon_main(*args, **kwargs):
        output_file = kwargs.get("output_file")
        if output_file:
            with open(output_file, "w") as f:
                json.dump(metadata, f)

    monkeypatch.setattr(LbAPCommon.__main__, "main", fake_lbapcommon_main)
    fake_cwl = MagicMock()
    monkeypatch.setattr(
        "LbAPCommon.prod_request_to_cwl.fromProductionRequestYAMLToCWL",
        lambda *a, **kw: (fake_cwl, {}, None),
    )
    monkeypatch.setattr("cwl_utils.parser.save", lambda obj: {})

    # Fail at executor check so we exit before actual execution
    monkeypatch.setattr("shutil.which", lambda _: None)

    prod_dir = tmp_path / "myprod"
    prod_dir.mkdir()
    (prod_dir / "info.yaml").write_text(
        "jobs:\n  myjob:\n    application: DaVinci/v64r8@x86_64_v2-el9-gcc13+detdesc-opt\n"
    )

    # The --pretty warning is printed after executor check passes,
    # so fail at executor check to test it won't appear yet.
    # Instead, let executor pass but fail later at lb-run setup.
    result = runner.invoke(
        app,
        [
            "cwl",
            "test",
            "myprod",
            "myjob",
            "--skip-validation",
            "--pretty",
            "-o",
            str(out_dir),
        ],
    )
    # The warning should be visible regardless of later failure
    assert "Experimental" in result.output or "experimental" in result.output
