"""Tests for noisegate.importer."""
from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner
from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart
from pydantic_ai.models.function import AgentInfo, FunctionModel
from pydantic_ai.models.test import TestModel

import noisegate.config as config_module
from noisegate.cli import cli
from noisegate.exceptions import ProgramImportError
from noisegate.importer import (
    _MAX_CHARS_PER_PAGE,
    _fetch_page,
    _read_file_as_text,
    import_program_from_file,
    import_program_from_url,
)
from noisegate.models import Program


@pytest.fixture(autouse=True)
def isolated_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    config_dir = tmp_path / "noisegate"
    programs_dir = config_dir / "programs"
    monkeypatch.setattr(config_module, "_CONFIG_DIR", config_dir)
    monkeypatch.setattr(config_module, "_CONFIG_FILE", config_dir / "config.json")
    monkeypatch.setattr(config_module, "_POLICY_FILE", config_dir / "policy.yaml")
    monkeypatch.setattr(config_module, "_PROGRAMS_DIR", programs_dir)


# ---------------------------------------------------------------------------
# _fetch_page — unit tests
# ---------------------------------------------------------------------------

def _pw_mock(html: str | None = "<html><body>content</body></html>",
             goto_raises: Exception | None = None,
             status: int = 200) -> MagicMock:
    """Build a mock sync_playwright() context manager that returns `html`."""
    mock_page = MagicMock()
    if goto_raises:
        mock_page.goto.side_effect = goto_raises
    else:
        mock_page.goto.return_value.status = status
        mock_page.content.return_value = html
    mock_browser = MagicMock()
    mock_browser.new_page.return_value = mock_page
    mock_p = MagicMock()
    mock_p.chromium.launch.return_value.__enter__ = MagicMock(return_value=mock_browser)
    mock_p.chromium.launch.return_value.__exit__ = MagicMock(return_value=False)
    cm = MagicMock()
    cm.__enter__ = MagicMock(return_value=mock_p)
    cm.__exit__ = MagicMock(return_value=False)
    return cm


def test_fetch_page_returns_content() -> None:
    html = "<h1>Scope</h1><p>*.example.com</p>"
    with patch("noisegate.importer.sync_playwright", return_value=_pw_mock(html)):
        content, status = _fetch_page("https://example.com")
    assert "example.com" in content
    assert status == 200


def test_fetch_page_truncates_large_content() -> None:
    big_html = "<p>" + "X" * (_MAX_CHARS_PER_PAGE + 1000) + "</p>"
    with patch("noisegate.importer.sync_playwright", return_value=_pw_mock(big_html)):
        content, status = _fetch_page("https://example.com")
    assert len(content) == _MAX_CHARS_PER_PAGE


def test_fetch_page_raises_when_goto_fails() -> None:
    with patch("noisegate.importer.sync_playwright",
               return_value=_pw_mock(goto_raises=TimeoutError("timeout"))):
        with pytest.raises(ProgramImportError, match="Failed to fetch"):
            _fetch_page("https://example.com")


def test_fetch_page_raises_when_content_is_empty() -> None:
    with patch("noisegate.importer.sync_playwright", return_value=_pw_mock("<html></html>")):
        with pytest.raises(ProgramImportError, match="Could not extract"):
            _fetch_page("https://example.com")


# ---------------------------------------------------------------------------
# import_program_from_url — TestModel (no real LLM or HTTP)
# ---------------------------------------------------------------------------

def _program_output_args() -> dict:
    return {
        "name": "Test Program",
        "platform": "HackerOne",
        "source": "https://hackerone.com/test",
        "assets": {"include": ["*.test.com"], "exclude": []},
        "ineligible_findings": [],
        "required_report_sections": [],
        "notes": [],
    }


def test_import_program_from_url_returns_program() -> None:
    tm = TestModel(call_tools=[], custom_output_args=_program_output_args())
    result = import_program_from_url("https://example.com", model=tm)
    assert isinstance(result, Program)
    assert result.name == "Test Program"
    assert result.platform == "HackerOne"


def test_import_program_from_url_deduplicates_urls() -> None:
    calls: list[str] = []

    def fake_fetch(url: str) -> tuple[str, int]:
        calls.append(url)
        return "content", 200

    with patch("noisegate.importer._fetch_page", side_effect=fake_fetch):
        tm = TestModel(call_tools="all", custom_output_args=_program_output_args())
        import_program_from_url("https://example.com", model=tm)

    assert len(calls) == len(set(calls))


def test_import_program_from_url_blocks_different_domain() -> None:
    """_fetch_page must never be called for a URL on a different host."""
    def fail_if_called(url: str) -> tuple[str, int]:
        raise AssertionError(f"_fetch_page should not be called for {url}")

    # FunctionModel lets us emit an exact tool call to a cross-domain URL,
    # then return structured output on the follow-up turn.
    turn = 0

    def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
        nonlocal turn
        turn += 1
        if turn == 1:
            return ModelResponse(parts=[ToolCallPart("fetch_page", {"url": "https://otherdomain.com/page"})])
        return ModelResponse(parts=[ToolCallPart("final_result", _program_output_args())])

    with patch("noisegate.importer._fetch_page", side_effect=fail_if_called):
        result = import_program_from_url("https://example.com", model=FunctionModel(model_fn))
    assert isinstance(result, Program)


def test_import_program_from_url_soft_fails_bad_url() -> None:
    with patch("noisegate.importer._fetch_page",
               side_effect=ProgramImportError("Failed to fetch https://bad.example.com")):
        tm = TestModel(call_tools="all", custom_output_args=_program_output_args())
        result = import_program_from_url("https://bad.example.com", model=tm)
    assert isinstance(result, Program)


# ---------------------------------------------------------------------------
# _read_file_as_text — unit tests
# ---------------------------------------------------------------------------

def test_read_file_as_text_plain(tmp_path: Path) -> None:
    f = tmp_path / "policy.txt"
    f.write_text("In scope: *.example.com")
    assert _read_file_as_text(f) == "In scope: *.example.com"


def test_read_file_as_text_html(tmp_path: Path) -> None:
    f = tmp_path / "policy.html"
    f.write_text("<h1>Scope</h1><p>*.example.com</p>")
    text = _read_file_as_text(f)
    assert "Scope" in text
    assert "example.com" in text


def test_read_file_as_text_missing(tmp_path: Path) -> None:
    with pytest.raises(ProgramImportError, match="Failed to read"):
        _read_file_as_text(tmp_path / "nonexistent.txt")


# ---------------------------------------------------------------------------
# import_program_from_file — unit tests
# ---------------------------------------------------------------------------

def test_import_program_from_file_returns_program(tmp_path: Path) -> None:
    f = tmp_path / "policy.txt"
    f.write_text("In scope: *.example.com")
    tm = TestModel(call_tools=[], custom_output_args=_program_output_args())
    result = import_program_from_file(f, model=tm)
    assert isinstance(result, Program)
    assert result.name == "Test Program"


def test_import_program_from_file_truncates_large_file(tmp_path: Path) -> None:
    f = tmp_path / "big.txt"
    f.write_text("X" * (_MAX_CHARS_PER_PAGE + 5000))
    captured: list[str] = []

    def fake_run_sync(msg: str) -> object:
        captured.append(msg)
        raise AssertionError("stop")

    tm = TestModel(call_tools=[], custom_output_args=_program_output_args())
    # Verify truncation by checking content length passed to the agent
    oversized_content = "X" * (_MAX_CHARS_PER_PAGE + 5000)
    with patch("noisegate.importer._read_file_as_text", return_value=oversized_content):
        result = import_program_from_file(f, model=tm)
    # Agent receives a message — the file content is capped at _MAX_CHARS_PER_PAGE
    assert result.name == "Test Program"


# ---------------------------------------------------------------------------
# CLI — program add
# ---------------------------------------------------------------------------

@pytest.fixture
def runner() -> CliRunner:
    return CliRunner()


def _make_program() -> Program:
    return Program(name="Acme Bug Bounty", platform="HackerOne",
                   source="https://example.com/security")


def test_cli_program_add_success(runner: CliRunner) -> None:
    with patch("noisegate.cli.import_program_from_url", return_value=_make_program()):
        result = runner.invoke(cli, ["program", "add", "acme",
                                     "--url", "https://example.com/security"])
    assert result.exit_code == 0
    assert "Program 'acme' saved." in result.output
    assert (config_module._PROGRAMS_DIR / "acme.yaml").exists()
    assert config_module.get_default_program_name() == "acme"



def test_cli_program_add_requires_url_or_file(runner: CliRunner) -> None:
    result = runner.invoke(cli, ["program", "add", "acme"])
    assert result.exit_code != 0


def test_cli_program_add_url_and_file_are_exclusive(runner: CliRunner, tmp_path: Path) -> None:
    f = tmp_path / "prog.yaml"
    f.write_text("")
    result = runner.invoke(cli, ["program", "add", "acme",
                                 "--url", "https://example.com",
                                 "--file", str(f)])
    assert result.exit_code != 0


def test_cli_program_add_from_file(runner: CliRunner, tmp_path: Path) -> None:
    src = tmp_path / "policy.html"
    src.write_text("<h1>Acme Bug Bounty</h1><p>In scope: *.acme.com</p>")
    with patch("noisegate.cli.import_program_from_file", return_value=_make_program()):
        result = runner.invoke(cli, ["program", "add", "acme", "--file", str(src)])
    assert result.exit_code == 0, result.output
    assert "Program 'acme' saved." in result.output
    assert (config_module._PROGRAMS_DIR / "acme.yaml").exists()
    assert config_module.get_default_program_name() == "acme"


def test_cli_program_add_error_exits_3(runner: CliRunner) -> None:
    with patch("noisegate.cli.import_program_from_url",
               side_effect=ProgramImportError("timeout")):
        result = runner.invoke(cli, ["program", "add", "acme",
                                     "--url", "https://example.com"])
    assert result.exit_code == 3
    assert "Error:" in result.output
