# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import datetime
import importlib.util
import json
import os
import sys
import typing
from unittest.mock import ANY, mock_open, patch

import pandas as pd
import pytest

from cxas_scrapi.utils.eval_utils import (
    COMBINED_REPORT_FILENAME,
    COMBINED_REPORT_JSON_FILENAME,
    add_timestamp_suffix,
)
from cxas_scrapi.utils.reporting import (
    _escape,
    _fmt_duration,
    _format_trace_line,
    _load_sim_test_cases,
    _resolve_tool_name,
    _upload_to_gcs,
    generate_combined_html_report,
    generate_combined_json_report,
    generate_combined_report_from_dir,
    generate_html_report,
    run_all_evals,
)


@patch("cxas_scrapi.utils.reporting.gcs_utils.GCSUtils")
def test_upload_to_gcs_success(mock_gcs_cls: typing.Any) -> None:
    mock_gcs = mock_gcs_cls.return_value
    mock_gcs.upload_string.return_value = (
        "https://storage.mtls.cloud.google.com/bucket/report.html"
    )

    res = _upload_to_gcs("gs://bucket/report.html", "<html></html>")
    assert res == "https://storage.mtls.cloud.google.com/bucket/report.html"


@patch("cxas_scrapi.utils.reporting.gcs_utils.GCSUtils")
def test_upload_to_gcs_failure(mock_gcs_cls: typing.Any) -> None:
    mock_gcs = mock_gcs_cls.return_value
    mock_gcs.upload_string.side_effect = Exception("error")

    res = _upload_to_gcs("gs://bucket/report.html", "<html></html>")
    assert res is None


@patch("cxas_scrapi.utils.reporting._get_html_head")
@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
@patch("builtins.open", new_callable=mock_open)
def test_generate_html_report_gcs_success(
    mock_file: typing.Any,
    mock_upload: typing.Any,
    mock_get_html_head: typing.Any,
) -> None:
    mock_get_html_head.return_value = "<html><head></head><body>"
    mock_upload.return_value = "https://url"
    results = [{"name": "test", "passed": True, "run": 1}]

    generate_html_report(results, "gs://bucket/report.html", "text", "model")

    mock_upload.assert_called_once()
    mock_file.assert_not_called()


@patch("cxas_scrapi.utils.reporting._get_html_head")
@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
@patch("builtins.open", new_callable=mock_open)
def test_generate_html_report_gcs_fallback_with_extension(
    mock_file: typing.Any,
    mock_upload: typing.Any,
    mock_get_html_head: typing.Any,
) -> None:
    mock_get_html_head.return_value = "<html><head></head><body>"
    mock_upload.return_value = None
    results = [{"name": "test", "passed": True, "run": 1}]

    generate_html_report(
        results, "gs://bucket/fail_report.html", "text", "model"
    )

    mock_upload.assert_called_once()
    mock_file.assert_called_once_with("fail_report.html", "w")


@patch("cxas_scrapi.utils.reporting._get_html_head")
@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
@patch("builtins.open", new_callable=mock_open)
def test_generate_html_report_gcs_fallback_no_extension(
    mock_file: typing.Any,
    mock_upload: typing.Any,
    mock_get_html_head: typing.Any,
) -> None:
    mock_get_html_head.return_value = "<html><head></head><body>"
    mock_upload.return_value = None
    results = [{"name": "test", "passed": True, "run": 1}]

    # Path with no extension
    generate_html_report(results, "gs://bucket/no_ext", "text", "model")

    mock_upload.assert_called_once()
    mock_file.assert_called_once_with("report_fallback.html", "w")


@patch("cxas_scrapi.utils.reporting._get_html_head")
@patch("cxas_scrapi.utils.reporting.tools.Tools")
@patch("builtins.open", new_callable=mock_open)
def test_generate_html_report_tools_failure(
    mock_file: typing.Any,
    mock_tools_cls: typing.Any,
    mock_get_html_head: typing.Any,
) -> None:
    mock_get_html_head.return_value = "<html><head></head><body>"
    # Simulate Tools(app_name).get_tools_map() failing
    mock_tools_cls.return_value.get_tools_map.side_effect = Exception(
        "Tools failed"
    )

    results = [{"name": "test", "passed": True, "run": 1}]
    generate_html_report(
        results, "local.html", "text", "model", app_name="projects/p"
    )

    mock_file.assert_called_once_with("local.html", "w")


@patch("cxas_scrapi.utils.reporting._get_html_head")
@patch("builtins.open", new_callable=mock_open)
def test_generate_html_report_local(
    mock_file: typing.Any, mock_get_html_head: typing.Any
) -> None:
    mock_get_html_head.return_value = "<html><head></head><body>"
    results = [
        {
            "name": "test_eval",
            "passed": False,
            "error": "Timeout",
            "run": 1,
            "session_id": "sess123",
            "turns": 5,
            "detailed_trace": ["User: hello", "Agent Text: hi"],
            "step_details": [
                {
                    "goal": "g",
                    "status": "Completed",
                    "success_criteria": "c",
                    "justification": "j",
                }
            ],
            "expectation_details": [
                {"expectation": "e", "status": "Met", "justification": "j2"}
            ],
        }
    ]

    generate_html_report(
        results=results,
        output_path="local.html",
        modality="audio",
        model="gemini-3.1-pro-preview",
        app_name="projects/p1/locations/l1/apps/a1",
        wall_clock_s=120.5,
    )

    mock_file.assert_called_once_with("local.html", "w")
    content = mock_file().write.call_args[0][0]
    assert "Simulation Eval Report" in content
    assert "0.0%" in content
    assert "audio" in content
    assert "gemini-3.1-pro-preview" in content
    assert "2.0m" in content
    assert "test_eval" in content
    assert "Timeout" in content
    assert "sess123" in content


def test_fmt_duration() -> None:
    assert _fmt_duration(None) == ""
    assert _fmt_duration(30) == "30.0s"
    assert _fmt_duration(90) == "1.5m"


def test_escape() -> None:
    assert _escape('<script>&"') == "&lt;script&gt;&amp;&quot;"


def test_resolve_tool_name() -> None:
    tools_map = {"projects/p/tools/t1": "MyTool"}
    assert _resolve_tool_name("projects/p/tools/t1", tools_map) == "MyTool"
    assert _resolve_tool_name("projects/p/tools/t2", tools_map) == "t2"
    assert _resolve_tool_name(None, tools_map) is None


def test_format_trace_line() -> None:
    tools_map = {"path/to/tool": "GreatTool"}
    line = "Tool Call: path/to/tool with args {}"
    assert "GreatTool" in _format_trace_line(line, tools_map)
    assert "Unrelated" in _format_trace_line("Unrelated", tools_map)


def test_generate_combined_html_report(tmp_path: typing.Any) -> None:
    output_path = os.path.join(tmp_path, "report.html")

    golden_results = [
        {
            "name": "test_golden",
            "passed": True,
            "turns": [
                {
                    "index": 1,
                    "semantic_score": 4,
                    "comparisons": [
                        {
                            "outcome": "PASS",
                            "type": "text",
                            "expected": "hello",
                            "actual": "hello",
                        }
                    ],
                }
            ],
            "expectations": [],
            "session_id": "sess_1",
            "session_parameters": {},
            "duration_s": 1.0,
        }
    ]

    sim_results = [
        {
            "name": "test_sim",
            "passed": True,
            "run": 1,
            "duration_s": 2.0,
            "goals": 1,
            "expectations": 0,
            "turns": 1,
            "session_id": "sess_2",
            "session_parameters": {},
            "step_details": [
                {
                    "goal": "test goal",
                    "success_criteria": "test criteria",
                    "status": "Completed",
                    "justification": "done",
                }
            ],
            "expectation_details": [],
            "detailed_trace": ["User: hi", "Agent Text: hello"],
        }
    ]

    tool_results = [
        {
            "name": "test_tool",
            "tool": "my_tool",
            "passed": True,
            "status": "PASSED",
            "latency_ms": 50,
            "errors": "",
        }
    ]

    callback_results = [
        {
            "name": "test_callback",
            "agent": "my_agent",
            "callback_type": "my_callback",
            "passed": True,
            "status": "PASSED",
            "error": "",
        }
    ]

    generate_combined_html_report(
        golden_results=golden_results,
        sim_results=sim_results,
        tool_results=tool_results,
        callback_results=callback_results,
        output_path=output_path,
        app_name="projects/test-proj/locations/global/apps/test-app",
    )

    assert os.path.exists(output_path)
    with open(output_path) as f:
        content = f.read()
        assert "Combined Eval Report" in content
        assert "test_golden" in content
        assert "test_sim" in content
        assert "test_tool" in content
        assert "test_callback" in content


@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
def test_generate_combined_html_report_gcs_success(
    mock_upload: typing.Any,
) -> None:
    mock_upload.return_value = "https://url"

    resolved_path = generate_combined_html_report(
        golden_results=[],
        sim_results=[],
        tool_results=[],
        callback_results=[],
        output_path="gs://bucket/report.html",
        app_name="projects/test-proj",
    )

    assert resolved_path == "https://url"
    mock_upload.assert_called_once()


@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
@patch("builtins.open", new_callable=mock_open)
def test_generate_combined_html_report_gcs_fallback(
    mock_file: typing.Any, mock_upload: typing.Any
) -> None:
    mock_upload.return_value = None

    resolved_path = generate_combined_html_report(
        golden_results=[],
        sim_results=[],
        tool_results=[],
        callback_results=[],
        output_path="gs://bucket/report.html",
        app_name="projects/test-proj",
    )

    assert resolved_path == "report.html"
    mock_file.assert_any_call("report.html", "w")


def test_generate_combined_report_from_dir(tmp_path: typing.Any) -> None:
    evals_dir = tmp_path / "evals"
    evals_dir.mkdir()

    # Create dummy files
    sim_file = evals_dir / "sim_results.json"
    sim_file.write_text(json.dumps([{"name": "test_sim", "passed": True}]))

    tool_file = evals_dir / "tool_results.csv"
    df_tool = pd.DataFrame(
        [
            {
                "test_name": "test_tool",
                "tool": "my_tool",
                "status": "PASSED",
                "latency (ms)": 50,
                "errors": "",
            }
        ]
    )
    df_tool.to_csv(tool_file, index=False)

    callback_file = evals_dir / "callback_results.csv"
    df_callback = pd.DataFrame(
        [
            {
                "test_name": "test_callback",
                "agent_name": "my_agent",
                "callback_type": "my_callback",
                "status": "PASSED",
                "error_message": "",
            }
        ]
    )
    df_callback.to_csv(callback_file, index=False)

    output_path = evals_dir / COMBINED_REPORT_FILENAME

    generate_combined_report_from_dir(
        output_dir=str(evals_dir), output_path=str(output_path)
    )

    assert os.path.exists(output_path)
    with open(output_path) as f:
        content = f.read()
        assert "Combined Eval Report" in content
        assert "test_sim" in content
        assert "test_tool" in content
        assert "test_callback" in content


def test_generate_combined_report_from_dir_include_all(
    tmp_path: typing.Any,
) -> None:
    evals_dir = tmp_path / "evals"
    evals_dir.mkdir()

    # Create dummy files
    sim_file = evals_dir / "sim_results.json"
    sim_file.write_text(json.dumps([{"name": "test_sim", "passed": True}]))

    tool_file = evals_dir / "tool_results.csv"
    df_tool = pd.DataFrame(
        [
            {
                "test_name": "test_tool",
                "tool": "my_tool",
                "status": "PASSED",
                "latency (ms)": 50,
                "errors": "",
            }
        ]
    )
    df_tool.to_csv(tool_file, index=False)

    output_path = evals_dir / COMBINED_REPORT_FILENAME

    generate_combined_report_from_dir(
        output_dir=str(evals_dir), output_path=str(output_path), include=["all"]
    )

    assert os.path.exists(output_path)
    with open(output_path) as f:
        content = f.read()
        assert "test_sim" in content
        assert "test_tool" in content


def test_generate_combined_json_report_local(tmp_path: typing.Any) -> None:
    output_path = tmp_path / COMBINED_REPORT_JSON_FILENAME

    sim_results = [
        {
            "name": "test_sim",
            "passed": True,
            "detailed_trace": ["User: hi\nAgent Text: hello"],
            "_processed_trace": [("user", "hi")],
        },
        {"name": "test_sim", "passed": False},
    ]
    golden_results = [
        {
            "name": "test_golden",
            "passed": True,
            "turns": [{"semantic_score": 3.5}],
        }
    ]
    tool_results = [
        {
            "name": "test_tool",
            "tool": "my_tool",
            "passed": True,
            "status": "PASSED",
            "latency_ms": 50,
            "errors": "",
        }
    ]
    callback_results = [
        {
            "name": "test_callback",
            "agent": "my_agent",
            "callback_type": "my_callback",
            "passed": False,
            "status": "FAILED",
            "error": "boom",
        }
    ]

    resolved_path = generate_combined_json_report(
        golden_results=golden_results,
        sim_results=sim_results,
        tool_results=tool_results,
        callback_results=callback_results,
        output_path=str(output_path),
        app_name="projects/p/locations/l/apps/a",
        sim_wall_clock_s=12.5,
    )

    assert resolved_path == str(output_path)
    with open(output_path) as f:
        report = json.load(f)

    assert report["schema_version"] == 1
    assert report["app_name"] == "projects/p/locations/l/apps/a"
    assert report["sim_wall_clock_s"] == 12.5

    summary = report["summary"]
    assert summary["total"] == 5
    assert summary["passed"] == 3
    assert summary["pass_rate_pct"] == 60.0
    assert summary["simulation"] == {"total": 2, "passed": 1}
    assert summary["golden"] == {"total": 1, "passed": 1}
    assert summary["tool"] == {"total": 1, "passed": 1}
    assert summary["callback"] == {"total": 1, "passed": 0}

    results = report["results"]
    assert results["simulation"][0]["name"] == "test_sim"
    assert results["simulation"][0]["detailed_trace"] == [
        "User: hi\nAgent Text: hello"
    ]
    # Internal keys are stripped from the JSON output.
    assert "_processed_trace" not in results["simulation"][0]
    assert results["golden"][0]["turns"] == [{"semantic_score": 3.5}]
    assert results["tool"][0]["latency_ms"] == 50
    assert results["callback"][0]["error"] == "boom"


def test_generate_combined_json_report_serializes_non_json_values(
    tmp_path: typing.Any,
) -> None:
    output_path = tmp_path / "report.json"

    generate_combined_json_report(
        sim_results=[
            {
                "name": "test_sim",
                "passed": True,
                "started_at": datetime.datetime(2026, 7, 16, 12, 0, 0),
            }
        ],
        output_path=str(output_path),
    )

    with open(output_path) as f:
        report = json.load(f)
    assert "2026-07-16" in report["results"]["simulation"][0]["started_at"]


@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
@patch("builtins.open", new_callable=mock_open)
def test_generate_combined_json_report_gcs_success(
    mock_file: typing.Any, mock_upload: typing.Any
) -> None:
    mock_upload.return_value = "https://url"

    resolved_path = generate_combined_json_report(
        sim_results=[{"name": "test_sim", "passed": True}],
        output_path="gs://bucket/report.json",
    )

    assert resolved_path == "https://url"
    mock_upload.assert_called_once_with(
        "gs://bucket/report.json", ANY, content_type="application/json"
    )
    mock_file.assert_not_called()


@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
@patch("builtins.open", new_callable=mock_open)
def test_generate_combined_json_report_gcs_fallback(
    mock_file: typing.Any, mock_upload: typing.Any
) -> None:
    mock_upload.return_value = None

    resolved_path = generate_combined_json_report(
        sim_results=[{"name": "test_sim", "passed": True}],
        output_path="gs://bucket/report.json",
    )

    assert resolved_path == "report.json"
    mock_file.assert_any_call("report.json", "w")


@patch("cxas_scrapi.utils.reporting._upload_to_gcs")
@patch("builtins.open", new_callable=mock_open)
def test_generate_combined_json_report_gcs_fallback_no_extension(
    mock_file: typing.Any, mock_upload: typing.Any
) -> None:
    mock_upload.return_value = None

    resolved_path = generate_combined_json_report(
        sim_results=[{"name": "test_sim", "passed": True}],
        output_path="gs://bucket/no_ext",
    )

    assert resolved_path == "report_fallback.json"
    mock_file.assert_any_call("report_fallback.json", "w")


def test_generate_combined_report_from_dir_json(tmp_path: typing.Any) -> None:
    evals_dir = tmp_path / "evals"
    evals_dir.mkdir()

    sim_file = evals_dir / "sim_results.json"
    sim_file.write_text(json.dumps([{"name": "test_sim", "passed": True}]))

    tool_file = evals_dir / "tool_results.csv"
    df_tool = pd.DataFrame(
        [
            {
                "test_name": "test_tool",
                "tool": "my_tool",
                "status": "PASSED",
                "latency (ms)": 50,
                "errors": "",
            }
        ]
    )
    df_tool.to_csv(tool_file, index=False)

    callback_file = evals_dir / "callback_results.csv"
    df_callback = pd.DataFrame(
        [
            {
                "test_name": "test_callback",
                "agent_name": "my_agent",
                "callback_type": "my_callback",
                "status": "FAILED",
                "error_message": "boom",
            }
        ]
    )
    df_callback.to_csv(callback_file, index=False)

    resolved_path = generate_combined_report_from_dir(
        output_dir=str(evals_dir), report_format="json"
    )

    # Defaults to combined_report.json in the output directory.
    assert resolved_path == str(evals_dir / COMBINED_REPORT_JSON_FILENAME)
    assert os.path.exists(resolved_path)

    with open(resolved_path) as f:
        report = json.load(f)

    assert report["summary"]["total"] == 3
    assert report["summary"]["passed"] == 2
    assert report["results"]["simulation"][0]["name"] == "test_sim"
    assert report["results"]["tool"][0]["name"] == "test_tool"
    assert report["results"]["tool"][0]["passed"] is True
    assert report["results"]["callback"][0]["name"] == "test_callback"
    assert report["results"]["callback"][0]["passed"] is False


def test_generate_combined_report_from_dir_json_explicit_output(
    tmp_path: typing.Any,
) -> None:
    evals_dir = tmp_path / "evals"
    evals_dir.mkdir()

    sim_file = evals_dir / "sim_results.json"
    sim_file.write_text(json.dumps([{"name": "test_sim", "passed": True}]))

    output_path = tmp_path / "custom_report.json"

    resolved_path = generate_combined_report_from_dir(
        output_dir=str(evals_dir),
        output_path=str(output_path),
        report_format="json",
    )

    assert resolved_path == str(output_path)
    with open(output_path) as f:
        report = json.load(f)
    assert report["summary"]["total"] == 1


def test_generate_combined_report_from_dir_invalid_format(
    tmp_path: typing.Any,
) -> None:
    evals_dir = tmp_path / "evals"
    evals_dir.mkdir()

    with pytest.raises(ValueError, match="Unsupported report format"):
        generate_combined_report_from_dir(
            output_dir=str(evals_dir), report_format="xml"
        )


def test_generate_combined_report_from_dir_html_default_unchanged(
    tmp_path: typing.Any,
) -> None:
    evals_dir = tmp_path / "evals"
    evals_dir.mkdir()

    sim_file = evals_dir / "sim_results.json"
    sim_file.write_text(json.dumps([{"name": "test_sim", "passed": True}]))

    resolved_path = generate_combined_report_from_dir(output_dir=str(evals_dir))

    assert resolved_path == str(evals_dir / COMBINED_REPORT_FILENAME)
    with open(resolved_path) as f:
        assert "Combined Eval Report" in f.read()


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
def test_run_all_evals_filtering(
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True
    mock_glob.side_effect = [
        ["evals/goldens/test1.yaml", "evals/goldens/test2.yaml"],
        ["evals/tool_tests/tool1.yaml"],
        ["evals/simulations/sim1.yaml"],
    ]

    # Mock load_golden_evals_from_yaml to return empty list
    mock_eval_utils.return_value.load_golden_evals_from_yaml.return_value = []

    run_all_evals(
        app_name="projects/p",
        filter_files=["test1.yaml"],
        goldens_dir="evals/goldens/",
        tool_test_file="evals/tool_tests/",
        simulation_dir="evals/simulations/",
    )

    mock_eval_utils.return_value.load_golden_evals_from_yaml.assert_called_once_with(
        "evals/goldens/test1.yaml"
    )


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
def test_run_all_evals_substring_filtering(
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True
    mock_glob.side_effect = [
        ["evals/goldens/error.yaml", "evals/goldens/other.yaml"],
        ["evals/tool_tests/tool1.yaml"],
        ["evals/simulations/sim1.yaml"],
    ]

    # Mock load_golden_evals_from_yaml to return empty list
    mock_eval_utils.return_value.load_golden_evals_from_yaml.return_value = []

    run_all_evals(
        app_name="projects/p",
        filter_files=["ERROR"],
        goldens_dir="evals/goldens/",
        tool_test_file="evals/tool_tests/",
        simulation_dir="evals/simulations/",
    )

    mock_eval_utils.return_value.load_golden_evals_from_yaml.assert_called_once_with(
        "evals/goldens/error.yaml"
    )


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
@patch("cxas_scrapi.evals.runner.RunEvaluationOperationMetadata")
@patch("cxas_scrapi.utils.reporting.load_golden_results")
@patch("yaml.safe_load")
@patch("builtins.open", new_callable=mock_open)
def test_run_all_evals_tag_filtering(
    mock_open_file: typing.Any,
    mock_yaml_load: typing.Any,
    mock_load_golden: typing.Any,
    mock_proto: typing.Any,
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True
    mock_glob.side_effect = [
        ["evals/goldens/test1.yaml"],
        ["evals/tool_tests/tool1.yaml"],
        ["evals/simulations/sim1.yaml"],
    ]

    # Mock load_golden_evals_from_yaml to return evaluations with tags
    mock_eval_utils.return_value.load_golden_evals_from_yaml.return_value = [
        {"name": "eval1", "tags": ["tag1"]},
        {"name": "eval2", "tags": ["tag2"]},
    ]

    # Mock yaml.safe_load to return simulations with tags
    mock_yaml_load.return_value = [
        {"name": "sim1", "tags": ["tag1"]},
        {"name": "sim2", "tags": ["tag2"]},
    ]

    mock_eval_client = mock_evaluations.return_value
    mock_eval_client.update_evaluation.return_value.name = "mock_name"

    run_all_evals(
        app_name="projects/p",
        filter_tags=["tag1"],
        goldens_dir="evals/goldens/",
        tool_test_file="evals/tool_tests/",
        simulation_dir="evals/simulations/",
    )

    # Verify that only eval1 was updated/run
    mock_eval_client.update_evaluation.assert_called_once_with(
        evaluation={"name": "eval1", "tags": ["tag1"]}, app_name="projects/p"
    )


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
@patch("yaml.safe_load")
@patch("builtins.open", new_callable=mock_open)
def test_run_all_evals_include_filtering(
    mock_open_file: typing.Any,
    mock_yaml_load: typing.Any,
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True
    mock_glob.side_effect = [
        ["evals/simulations/sim1.yaml"],
    ]
    mock_yaml_load.return_value = [{"name": "sim1"}]

    # Call with ONLY sims
    run_all_evals(
        app_name="projects/p",
        include=["sims"],
        goldens_dir="evals/goldens/",
        tool_test_file="evals/tool_tests/",
        simulation_dir="evals/simulations/",
    )

    # Assert SimulationEvals was instantiated and run
    mock_sim_evals.assert_called_once_with(
        app_name="projects/p",
        rate_limiter=None,
        expectations_only=False,
        deployment_id=None,
    )
    mock_sim_evals.return_value.run_simulations.assert_called_once()

    # Assert others were NOT called/instantiated
    mock_evaluations.assert_not_called()
    mock_tool_evals.assert_not_called()
    mock_callback_evals.assert_not_called()


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
def test_run_all_evals_include_tools(
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True
    mock_glob.side_effect = [
        ["evals/tool_tests/tool1.yaml"],
    ]
    mock_tool_evals.return_value.load_tool_test_cases_from_file.return_value = [
        {"name": "case1"}
    ]

    # Call with ONLY tools
    run_all_evals(
        app_name="projects/p",
        include=["tools"],
        goldens_dir="evals/goldens/",
        tool_test_file="evals/tool_tests/",
        simulation_dir="evals/simulations/",
    )

    # Assert ToolEvals was instantiated and run
    mock_tool_evals.assert_called_once_with(app_name="projects/p")
    mock_tool_evals.return_value.run_tool_tests.assert_called_once()

    # Assert others were NOT called/instantiated
    mock_evaluations.assert_not_called()
    mock_sim_evals.assert_not_called()
    mock_callback_evals.assert_not_called()


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
def test_run_all_evals_include_callbacks(
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True

    # Call with ONLY callbacks
    run_all_evals(
        app_name="projects/p",
        include=["callbacks"],
        goldens_dir="evals/goldens/",
        tool_test_file="evals/tool_tests/",
        simulation_dir="evals/simulations/",
    )

    # Assert CallbackEvals was instantiated and run
    mock_callback_evals.assert_called_once()
    mock_callback_evals.return_value.test_all_callbacks_in_app_dir.assert_called_once()

    # Assert others were NOT called/instantiated
    mock_evaluations.assert_not_called()
    mock_sim_evals.assert_not_called()
    mock_tool_evals.assert_not_called()


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
@patch(
    "builtins.open",
    new_callable=mock_open,
    read_data="evals:\n  - name: sim1\n    tags: [P0]",
)
def test_run_all_evals_dict_based_simulations(
    mock_file: typing.Any,
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True
    mock_glob.side_effect = [
        ["evals/simulations/sims.yaml"],
    ]

    run_all_evals(
        app_name="projects/p",
        include=["sims"],
        simulation_dir="evals/simulations/",
    )

    # Verify SimulationEvals was instantiated and run
    mock_sim_evals.assert_called_once_with(
        app_name="projects/p",
        rate_limiter=None,
        expectations_only=False,
        deployment_id=None,
    )
    mock_sim_evals.return_value.run_simulations.assert_called_once_with(
        [
            {
                "name": "sim1",
                "tags": ["P0"],
                "session_parameters": {},
                "expectations": [],
            }
        ],
        runs=1,
        parallel=1,
        sim_user_model=None,
        eval_model=None,
        modality="text",
        background_noise_file=None,
        burst_noise_files=None,
        use_tool_fakes=False,
        skip_playback_wait=False,
        single_bidi_stream=False,
        progress_callback=ANY,
        capture_agent_audio=False,
    )


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
@patch("glob.glob")
@patch("os.path.exists")
@patch("os.path.isdir")
@patch("yaml.safe_load")
@patch("builtins.open", new_callable=mock_open)
def test_run_all_evals_with_deployment_id(
    mock_open_file: typing.Any,
    mock_yaml_load: typing.Any,
    mock_isdir: typing.Any,
    mock_exists: typing.Any,
    mock_glob: typing.Any,
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
) -> None:
    mock_exists.return_value = True
    mock_isdir.return_value = True
    mock_glob.side_effect = [
        ["evals/simulations/sim1.yaml"],
    ]
    mock_yaml_load.return_value = [{"name": "sim1"}]

    run_all_evals(
        app_name="projects/p",
        include=["sims"],
        simulation_dir="evals/simulations/",
        deployment_id="dep123",
    )

    mock_sim_evals.assert_called_once_with(
        app_name="projects/p",
        rate_limiter=None,
        expectations_only=False,
        deployment_id="dep123",
    )


def test_load_sim_test_cases_merges_common_parameters() -> None:
    yaml_data = """
common_session_parameters:
  disable_disclaimer: true
  originationNumber: "1234567890"
evals:
  - name: sim1
    session_parameters:
      custom_param: "hello"
  - name: sim2
"""
    with patch("builtins.open", mock_open(read_data=yaml_data)):
        cases = _load_sim_test_cases("dummy.yaml")

        assert len(cases) == 2
        # Case 1 should have merged common params and its own params
        assert cases[0]["session_parameters"] == {
            "disable_disclaimer": True,
            "originationNumber": "1234567890",
            "custom_param": "hello",
        }
        # Case 2 should just have common params
        assert cases[1]["session_parameters"] == {
            "disable_disclaimer": True,
            "originationNumber": "1234567890",
        }


def test_load_sim_test_cases_merges_common_expectations() -> None:
    yaml_data = """
common_expectations:
  - "The agent welcomes the user"
  - "The agent behaves politely"
evals:
  - name: sim1
    expectations:
      - "The agent offers options"
  - name: sim2
"""
    with patch("builtins.open", mock_open(read_data=yaml_data)):
        cases = _load_sim_test_cases("dummy.yaml")

        assert len(cases) == 2
        # Case 1 should have merged expectations
        assert cases[0]["expectations"] == [
            "The agent welcomes the user",
            "The agent behaves politely",
            "The agent offers options",
        ]
        # Case 2 should just have common expectations
        assert cases[1]["expectations"] == [
            "The agent welcomes the user",
            "The agent behaves politely",
        ]


def test_generate_combined_report_from_dir_timestamped(
    tmp_path: typing.Any,
) -> None:
    evals_dir = tmp_path / "evals"
    evals_dir.mkdir()

    # Create dummy files with timestamp
    timestamp = "20260622_171403"
    sim_file = evals_dir / f"sim_results_{timestamp}.json"
    sim_file.write_text(json.dumps([{"name": "test_sim", "passed": True}]))

    tool_file = evals_dir / f"tool_results_{timestamp}.csv"
    df_tool = pd.DataFrame(
        [
            {
                "test_name": "test_tool",
                "tool": "my_tool",
                "status": "PASSED",
                "latency (ms)": 50,
                "errors": "",
            }
        ]
    )
    df_tool.to_csv(tool_file, index=False)

    callback_file = evals_dir / f"callback_results_{timestamp}.csv"
    df_callback = pd.DataFrame(
        [
            {
                "test_name": "test_callback",
                "agent_name": "my_agent",
                "callback_type": "my_callback",
                "status": "PASSED",
                "error_message": "",
            }
        ]
    )
    df_callback.to_csv(callback_file, index=False)

    # Call generate_combined_report_from_dir without output_path (so it
    # resolves it) and with run=False (so it loads from files)
    resolved_path = generate_combined_report_from_dir(
        output_dir=str(evals_dir),
        run=False,
    )

    expected_output_path = os.path.join(
        str(evals_dir),
        add_timestamp_suffix(COMBINED_REPORT_FILENAME, timestamp),
    )
    assert resolved_path == expected_output_path
    assert os.path.exists(expected_output_path)

    with open(expected_output_path) as f:
        content = f.read()
        assert "Combined Eval Report" in content
        assert "test_sim" in content
        assert "test_tool" in content
        assert "test_callback" in content


@patch("cxas_scrapi.evals.runner.Evaluations")
@patch("cxas_scrapi.evals.runner.ToolEvals")
@patch("cxas_scrapi.evals.runner.SimulationEvals")
@patch("cxas_scrapi.evals.runner.CallbackEvals")
@patch("cxas_scrapi.evals.runner.EvalUtils")
def test_run_all_evals_writes_timestamped_files(
    mock_eval_utils: typing.Any,
    mock_callback_evals: typing.Any,
    mock_sim_evals: typing.Any,
    mock_tool_evals: typing.Any,
    mock_evaluations: typing.Any,
    tmp_path: typing.Any,
) -> None:
    # Set up real files in tmp_path
    sims_dir = tmp_path / "simulations"
    sims_dir.mkdir()
    sim_yaml = sims_dir / "sims.yaml"
    sim_yaml.write_text("evals:\n  - name: sim1\n    tags: [P0]")

    output_dir = tmp_path / "output"
    output_dir.mkdir()

    # Mock SimulationEvals.run_simulations to return dummy results
    mock_sim_evals.return_value.run_simulations.return_value = [
        {"name": "sim1", "passed": True}
    ]

    timestamp = "20260622_171403"

    run_all_evals(
        app_name="projects/p",
        include=["sims"],
        simulation_dir=str(sims_dir),
        output_dir=str(output_dir),
        timestamp=timestamp,
    )

    # Verify file was written with timestamp
    expected_file = output_dir / f"sim_results_{timestamp}.json"
    assert expected_file.exists()

    with open(expected_file) as f:
        data = json.load(f)
        assert len(data) == 1
        assert data[0]["name"] == "sim1"
        assert data[0]["passed"] is True


@patch("cxas_scrapi.utils.reporting.evals_runner.run_all_evals")
def test_run_all_evals_expectations_only(
    mock_run_all_evals: typing.Any,
) -> None:
    run_all_evals(
        app_name="projects/p",
        expectations_only=True,
    )
    mock_run_all_evals.assert_called_once_with(
        app_name="projects/p",
        modality="text",
        sim_user_model=None,
        eval_model=None,
        runs=1,
        goldens_dir=None,
        tool_test_file=None,
        simulation_dir=None,
        app_dir=None,
        output_dir=None,
        filter_files=None,
        filter_tags=None,
        filter_names=None,
        parallel=1,
        golden_parallel=1,
        golden_timeout=600,
        include=None,
        bg_noise_file=None,
        burst_noise_files=None,
        use_tool_fakes=False,
        timestamp=None,
        expectations_only=True,
        deployment_id=None,
        skip_playback_wait=False,
        single_bidi_stream=False,
        progress_callback=None,
        capture_agent_audio=False,
    )


@pytest.fixture
def sim_runner() -> typing.Any:
    scripts_dir = os.path.abspath(".agents/skills/cxas-agent-foundry/scripts")
    if scripts_dir not in sys.path:
        sys.path.insert(0, scripts_dir)

    resolve_patch = patch(
        "config.resolve_project_dir", return_value="/dummy/project"
    )
    path_patch = patch(
        "config.get_project_path",
        side_effect=lambda *parts: os.path.join("/dummy/project", *parts),
    )
    with resolve_patch, path_patch:
        spec = importlib.util.spec_from_file_location(
            "scrapi_sim_runner",
            os.path.join(scripts_dir, "scrapi-sim-runner.py"),
        )
        runner = importlib.util.module_from_spec(spec)
        sys.modules["scrapi_sim_runner"] = runner
        spec.loader.exec_module(runner)
    return runner


def test_sim_runner_load_sim_templates_merges_common_expectations(
    sim_runner: typing.Any,
) -> None:
    yaml_data = """
common_expectations:
  - "Common expectation 1"
  - "Common expectation 2"
evals:
  - name: sim1
    expectations:
      - "Specific expectation 1"
  - name: sim2
"""
    with patch("os.path.exists", return_value=True):  # noqa: SIM117
        with patch("builtins.open", mock_open(read_data=yaml_data)):
            templates = sim_runner.load_sim_templates()

            assert len(templates) == 2
            assert templates["sim1"]["expectations"] == [
                "Specific expectation 1",
                "Common expectation 1",
                "Common expectation 2",
            ]
            assert templates["sim2"]["expectations"] == [
                "Common expectation 1",
                "Common expectation 2",
            ]


def test_sim_runner_load_sim_templates_handles_missing_common_expectations(
    sim_runner: typing.Any,
) -> None:
    yaml_data = """
evals:
  - name: sim1
    expectations:
      - "Specific expectation 1"
"""
    with patch("os.path.exists", return_value=True):  # noqa: SIM117
        with patch("builtins.open", mock_open(read_data=yaml_data)):
            templates = sim_runner.load_sim_templates()

            assert len(templates) == 1
            assert templates["sim1"]["expectations"] == [
                "Specific expectation 1"
            ]


def test_sim_runner_load_sim_templates_backward_compatibility_with_list(
    sim_runner: typing.Any,
) -> None:
    yaml_data = """
- name: sim1
  expectations:
    - "Specific expectation 1"
- name: sim2
  expectations:
    - "Specific expectation 2"
"""
    with patch("os.path.exists", return_value=True):  # noqa: SIM117
        with patch("builtins.open", mock_open(read_data=yaml_data)):
            templates = sim_runner.load_sim_templates()

            assert len(templates) == 2
            assert templates["sim1"]["expectations"] == [
                "Specific expectation 1"
            ]
            assert templates["sim2"]["expectations"] == [
                "Specific expectation 2"
            ]
