"""Tests for aablocks.cli module."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest
import requests
from click.testing import CliRunner

from aablocks.cli import main
from aablocks.client import DataBlock

from .conftest import SAMPLE_BLOCK_DICT


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


def _make_block(**overrides) -> DataBlock:
    data = {**SAMPLE_BLOCK_DICT, **overrides}
    return DataBlock.from_dict(data)


# ---------------------------------------------------------------------------
# Version
# ---------------------------------------------------------------------------
class TestVersion:
    def test_version_flag(self, runner):
        """--version prints the package version and exits cleanly."""
        result = runner.invoke(main, ["--version"])
        assert result.exit_code == 0
        assert "version" in result.output


# ---------------------------------------------------------------------------
# Login / Logout
# ---------------------------------------------------------------------------
class TestLoginCmd:
    @patch("aablocks.cli.login")
    def test_login_success(self, mock_login, runner):
        """'login' command calls the auth login function with default options."""
        result = runner.invoke(main, ["login"])
        assert result.exit_code == 0
        mock_login.assert_called_once_with(no_callback=False)

    @patch("aablocks.cli.login")
    def test_login_no_callback(self, mock_login, runner):
        """'login --no-callback' passes no_callback=True for headless environments."""
        result = runner.invoke(main, ["login", "--no-callback"])
        assert result.exit_code == 0
        mock_login.assert_called_once_with(no_callback=True)

    @patch("aablocks.cli.login", side_effect=RuntimeError("Auth failed"))
    @patch("aablocks.cli.log_error")
    def test_login_error(self, mock_log, mock_login, runner):
        """Login failures are caught, logged, and shown as a friendly error."""
        result = runner.invoke(main, ["login"])
        assert result.exit_code == 1
        assert "Auth failed" in result.output


class TestLogoutCmd:
    @patch("aablocks.cli.logout")
    def test_logout(self, mock_logout, runner):
        """'logout' command calls the auth logout function."""
        result = runner.invoke(main, ["logout"])
        assert result.exit_code == 0
        mock_logout.assert_called_once()


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
class TestConfigCmd:
    def test_show_all(self, runner, tmp_aab_dir):
        """'config' with no args shows all configuration keys and values."""
        result = runner.invoke(main, ["config"])
        assert result.exit_code == 0
        assert "base_url:" in result.output
        assert "api_format:" in result.output
        assert "cli_format:" in result.output
        assert "progress:" in result.output

    def test_get_key(self, runner, tmp_aab_dir):
        """'config <key>' shows the current value for that key."""
        result = runner.invoke(main, ["config", "api_format"])
        assert result.exit_code == 0
        assert "pandas" in result.output

    @patch("aablocks.cli.set_config")
    def test_set_api_format(self, mock_set, runner, tmp_aab_dir):
        """'config api_format csv' sets the api_format to csv."""
        result = runner.invoke(main, ["config", "api_format", "csv"])
        assert result.exit_code == 0
        assert "Set api_format to csv" in result.output
        mock_set.assert_called_once_with("api_format", "csv")

    def test_set_invalid_api_format(self, runner, tmp_aab_dir):
        """An invalid api_format value is rejected with an error message."""
        result = runner.invoke(main, ["config", "api_format", "xml"])
        assert result.exit_code == 0
        assert "Invalid" in result.output

    @patch("aablocks.cli.set_config")
    def test_set_cli_format(self, mock_set, runner, tmp_aab_dir):
        """'config cli_format table' sets the CLI output format to table."""
        result = runner.invoke(main, ["config", "cli_format", "table"])
        assert result.exit_code == 0
        mock_set.assert_called_once_with("cli_format", "table")

    def test_set_invalid_cli_format(self, runner, tmp_aab_dir):
        """An invalid cli_format value is rejected with an error message."""
        result = runner.invoke(main, ["config", "cli_format", "json"])
        assert "Invalid" in result.output

    @patch("aablocks.cli.set_config")
    def test_set_progress(self, mock_set, runner, tmp_aab_dir):
        """'config progress false' sets progress to boolean False."""
        result = runner.invoke(main, ["config", "progress", "false"])
        assert result.exit_code == 0
        mock_set.assert_called_once_with("progress", False)

    def test_set_invalid_progress(self, runner, tmp_aab_dir):
        """A non-boolean progress value is rejected with an error message."""
        result = runner.invoke(main, ["config", "progress", "maybe"])
        assert "Invalid" in result.output

    @patch("aablocks.cli.set_config")
    def test_set_base_url(self, mock_set, runner, tmp_aab_dir):
        """'config base_url <https url>' updates the API endpoint."""
        result = runner.invoke(
            main, ["config", "base_url", "https://data-api-test.aalphabio.tools/api/v1"]
        )
        assert result.exit_code == 0
        mock_set.assert_called_once()

    def test_set_invalid_base_url(self, runner, tmp_aab_dir):
        """A non-HTTPS base_url is rejected with a validation error."""
        result = runner.invoke(main, ["config", "base_url", "http://insecure.com"])
        assert "Invalid" in result.output or "Must be" in result.output

    def test_unknown_key_get(self, runner, tmp_aab_dir):
        """Getting an unrecognized config key shows an 'Unknown' message."""
        result = runner.invoke(main, ["config", "nonexistent"])
        assert "Unknown" in result.output

    def test_unknown_key_set(self, runner, tmp_aab_dir):
        """Setting an unrecognized config key shows an 'Unknown' message."""
        result = runner.invoke(main, ["config", "nonexistent", "value"])
        assert "Unknown" in result.output

    @patch("aablocks.cli.set_config")
    def test_set_cli_verbose(self, mock_set, runner, tmp_aab_dir):
        """'config cli_verbose true' sets cli_verbose to boolean True."""
        result = runner.invoke(main, ["config", "cli_verbose", "true"])
        assert result.exit_code == 0
        mock_set.assert_called_once_with("cli_verbose", True)

    @patch("aablocks.cli.set_config")
    def test_set_api_verbose(self, mock_set, runner, tmp_aab_dir):
        """'config api_verbose true' sets api_verbose to boolean True."""
        result = runner.invoke(main, ["config", "api_verbose", "true"])
        assert result.exit_code == 0
        mock_set.assert_called_once_with("api_verbose", True)


# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
class TestListCmd:
    @patch("aablocks.cli.list_blocks")
    def test_table_format(self, mock_list, runner):
        """Default table format shows a header row and block IDs."""
        mock_list.return_value = [
            _make_block(),
            _make_block(id="ab1002", name="Other"),
        ]
        result = runner.invoke(main, ["list"])
        assert result.exit_code == 0
        assert "ab1001" in result.output
        assert "ab1002" in result.output
        assert "ID" in result.output

    @patch("aablocks.cli.list_blocks")
    def test_csv_format(self, mock_list, runner):
        """'-f csv' outputs comma-separated values with a header row."""
        mock_list.return_value = [_make_block()]
        result = runner.invoke(main, ["list", "-f", "csv"])
        assert result.exit_code == 0
        assert "ab1001" in result.output
        assert "id," in result.output

    @patch("aablocks.cli.list_blocks")
    def test_json_format(self, mock_list, runner):
        """'-f json' outputs valid JSON containing data block objects."""
        mock_list.return_value = [_make_block()]
        result = runner.invoke(main, ["list", "-f", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert len(data) == 1
        assert data[0]["id"] == "ab1001"

    @patch("aablocks.cli.list_blocks")
    def test_empty_table(self, mock_list, runner):
        """An empty block list shows 'No blocks found' instead of a table."""
        mock_list.return_value = []
        result = runner.invoke(main, ["list"])
        assert result.exit_code == 0
        assert "No blocks found" in result.output

    @patch("aablocks.cli.list_blocks")
    def test_all_versions_flag(self, mock_list, runner):
        """'--all-versions' flag is forwarded to list_blocks."""
        mock_list.return_value = []
        runner.invoke(main, ["list", "--all-versions"])
        mock_list.assert_called_once_with(all_versions=True, format="list")

    @patch("aablocks.cli.list_blocks", side_effect=Exception("API error"))
    @patch("aablocks.cli.log_error")
    def test_error_handling(self, mock_log, mock_list, runner):
        """API errors are caught, logged, and shown as friendly error messages."""
        result = runner.invoke(main, ["list"])
        assert result.exit_code == 1
        assert "Error" in result.output


# ---------------------------------------------------------------------------
# Details
# ---------------------------------------------------------------------------
class TestDetailsCmd:
    @patch("aablocks.cli.get_details")
    def test_details_output(self, mock_details, runner):
        """'details <id>' shows ID, name, version, and other metadata fields."""
        mock_details.return_value = _make_block()
        result = runner.invoke(main, ["details", "ab1001"])
        assert result.exit_code == 0
        assert "ab1001" in result.output
        assert "Test Data Block" in result.output
        assert "Version:" in result.output

    @patch("aablocks.cli.get_details")
    def test_details_with_version(self, mock_details, runner):
        """'-v <version>' is forwarded as the version parameter."""
        mock_details.return_value = _make_block()
        runner.invoke(main, ["details", "ab1001", "-v", "2"])
        mock_details.assert_called_once_with("ab1001", version="2")

    @patch("aablocks.cli.get_details", side_effect=Exception("Not found"))
    @patch("aablocks.cli.log_error")
    def test_details_error(self, mock_log, mock_details, runner):
        """Errors from get_details are caught and exit with code 1."""
        result = runner.invoke(main, ["details", "nonexistent"])
        assert result.exit_code == 1


# ---------------------------------------------------------------------------
# Datacard
# ---------------------------------------------------------------------------
class TestDatacardCmd:
    @patch("aablocks.cli.get_datacard")
    def test_datacard_raw(self, mock_datacard, runner):
        """'--raw' outputs the raw JSON body."""
        mock_datacard.return_value = {
            "exec": {
                "scientific_value": "the value",
                "use_cases": [{"title": "UC1", "description": "d"}],
            },
        }
        result = runner.invoke(main, ["datacard", "ab1001", "--raw"])
        assert result.exit_code == 0
        assert '"scientific_value": "the value"' in result.output
        assert '"title": "UC1"' in result.output

    @patch("aablocks.cli.get_datacard")
    def test_datacard_formatted(self, mock_datacard, runner):
        """Default output renders sections as plain text."""
        mock_datacard.return_value = {
            "exec": {
                "scientific_value": "Best dataset ever",
                "use_cases": [{"title": "Lead gen", "description": "Find hits"}],
            },
            "bio": {"considerations": ["No negatives"]},
        }
        result = runner.invoke(main, ["datacard", "ab1001"])
        assert result.exit_code == 0
        assert "Scientific value" in result.output
        assert "Best dataset ever" in result.output
        assert "Use cases" in result.output
        assert "Lead gen: Find hits" in result.output
        assert "Considerations" in result.output
        assert "No negatives" in result.output

    @patch("aablocks.cli.get_datacard")
    def test_datacard_formatted_all_sections(self, mock_datacard, runner):
        """A fully-populated payload renders every optional section."""
        mock_datacard.return_value = {
            "exec": {
                "scientific_value": "VP text",
                "stat_subs": {"a_size": "10", "alpha_size": "20", "ppi": "30"},
                "use_cases": [{"title": "UC", "description": "ucdesc"}],
                "differentiations": [{"title": "D1", "body": "dbody"}],
            },
            "ml": {
                "recommended_splits": "Split guidance",
                "schema_columns": [
                    {"name": "col", "type": "int", "description": "coldesc"}
                ],
                "noise_signals": [
                    {"label": "NS1", "value": "v1", "sub": "sublabel"},
                    {"label": "NS2", "value": "v2"},
                ],
                "data_views": [{"name": "DV", "description": "dvdesc"}],
            },
            "bio": {
                "biological_system": "Bio context",
                "figures": [
                    {"title": "F", "filename": "f.svg", "caption": "fcap"},
                    {"title": "F2", "filename": "f2.svg"},
                ],
                "findings": [{"title": "Finding", "body": "fbody"}],
                "considerations": ["c1", "c2"],
            },
        }
        result = runner.invoke(main, ["datacard", "ab1001"])
        assert result.exit_code == 0
        for expected in (
            "Scientific value",
            "VP text",
            "Recommended splits",
            "Split guidance",
            "Biological system",
            "Bio context",
            "Stats",
            "a_size: 10",
            "alpha_size: 20",
            "ppi: 30",
            "Use cases",
            "UC: ucdesc",
            "Differentiations",
            "D1: dbody",
            "Schema (key columns)",
            "col (int): coldesc",
            "Noise & signal",
            "NS1: v1 — sublabel",
            "NS2: v2",
            "Data views",
            "DV: dvdesc",
            "Figures",
            "F [f.svg] — fcap",
            "F2 [f2.svg]",
            "Key findings",
            "Finding: fbody",
            "Considerations",
            "c1",
            "c2",
        ):
            assert expected in result.output, f"missing: {expected!r}"

    @patch("aablocks.cli.get_datacard")
    def test_datacard_formatted_skips_empty_sections(self, mock_datacard, runner):
        """Sections with no content don't print a header."""
        mock_datacard.return_value = {
            "exec": {"scientific_value": "Only this is set", "use_cases": []},
            "ml": {"recommended_splits": ""},
        }
        result = runner.invoke(main, ["datacard", "ab1001"])
        assert result.exit_code == 0
        assert "Scientific value" in result.output
        assert "Only this is set" in result.output
        for omitted in (
            "Recommended splits",
            "Biological system",
            "Stats",
            "Use cases",
            "Differentiations",
            "Schema (key columns)",
            "Noise & signal",
            "Data views",
            "Figures",
            "Key findings",
            "Considerations",
        ):
            assert omitted not in result.output, f"unexpected section: {omitted!r}"

    @patch("aablocks.cli.get_datacard")
    def test_datacard_formatted_tolerates_missing_keys(self, mock_datacard, runner):
        """Partial list items don't raise KeyError — fields default to empty strings."""
        mock_datacard.return_value = {
            "exec": {"use_cases": [{"title": "only-title"}]},
            "bio": {"figures": [{"title": "F"}]},  # missing filename
        }
        result = runner.invoke(main, ["datacard", "ab1001"])
        assert result.exit_code == 0
        assert "only-title" in result.output

    @patch("aablocks.cli.get_datacard")
    def test_datacard_with_version(self, mock_datacard, runner):
        """'-v <version>' is forwarded as the version parameter."""
        mock_datacard.return_value = {"exec": {"scientific_value": "v1"}}
        runner.invoke(main, ["datacard", "ab1001", "-v", "1", "--raw"])
        mock_datacard.assert_called_once_with("ab1001", version="1")

    @patch("aablocks.cli.get_datacard")
    def test_datacard_non_json_response_is_friendly(self, mock_datacard, runner):
        """A non-JSON API response surfaces as a friendly error, not a traceback."""
        mock_datacard.side_effect = RuntimeError(
            "Datacard response was not valid JSON for ab1001"
        )
        result = runner.invoke(main, ["datacard", "ab1001", "--raw"])
        assert result.exit_code == 1
        assert "Error:" in result.output
        assert "not valid JSON" in result.output


# ---------------------------------------------------------------------------
# Get
# ---------------------------------------------------------------------------
class TestGetCmd:
    def test_gz_without_output_errors(self, runner):
        """'-f gz' without '-o' exits with an error requiring an output path."""
        result = runner.invoke(main, ["get", "ab1001", "-f", "gz"])
        assert result.exit_code == 1
        assert "requires -o" in result.output

    @patch("aablocks.cli.get_data")
    def test_get_csv_stdout(self, mock_get, runner):
        """Default get prints CSV data to stdout."""
        mock_get.return_value = "col1,col2\na,b\n"
        result = runner.invoke(main, ["get", "ab1001"])
        assert result.exit_code == 0
        assert "col1,col2" in result.output

    @patch("aablocks.cli.get_data")
    def test_get_with_output_file(self, mock_get, runner, tmp_path):
        """'-o <path>' writes data to a file and confirms with a message."""
        mock_get.return_value = None
        out = tmp_path / "data.csv"
        result = runner.invoke(main, ["get", "ab1001", "-o", str(out)])
        assert result.exit_code == 0
        assert "Data written to" in result.output
        mock_get.assert_called_once()
        call_kwargs = mock_get.call_args[1]
        assert call_kwargs["output_path"] == str(out)

    @patch("aablocks.cli.get_data")
    def test_get_with_gz_output(self, mock_get, runner, tmp_path):
        """'-f gz -o <path>' sets output_compressed=True for gzipped output."""
        mock_get.return_value = None
        out = tmp_path / "data.csv.gz"
        result = runner.invoke(main, ["get", "ab1001", "-f", "gz", "-o", str(out)])
        assert result.exit_code == 0
        call_kwargs = mock_get.call_args[1]
        assert call_kwargs["output_compressed"] is True

    @patch("aablocks.cli.get_data")
    def test_get_with_options(self, mock_get, runner):
        """Version and mode options are forwarded to get_data."""
        mock_get.return_value = "data"
        runner.invoke(main, ["get", "ab1001", "-v", "2", "-m", "ml"])
        call_kwargs = mock_get.call_args[1]
        assert call_kwargs["version"] == "2"
        assert call_kwargs["mode"] == "ml"

    @patch("aablocks.cli.get_data", side_effect=Exception("Download failed"))
    @patch("aablocks.cli.log_error")
    def test_get_error(self, mock_log, mock_get, runner):
        """Download errors are caught, logged, and shown as friendly error messages."""
        result = runner.invoke(main, ["get", "ab1001"])
        assert result.exit_code == 1
        assert "Error" in result.output


# ---------------------------------------------------------------------------
# Error formatting
# ---------------------------------------------------------------------------
class TestFormatError:
    def test_connection_error(self, runner):
        """ConnectionError produces a string message (not a traceback)."""
        from aablocks.cli import _format_error

        err = ConnectionError("refused")
        msg = _format_error(err)
        assert isinstance(msg, str)

    def test_http_401(self):
        """HTTP 401 is formatted as an authentication/login hint."""
        from aablocks.cli import _format_error

        resp = MagicMock()
        resp.status_code = 401
        err = requests.exceptions.HTTPError(response=resp)
        msg = _format_error(err)
        assert "Authentication" in msg or "login" in msg

    def test_http_403(self):
        """HTTP 403 is formatted as an access denied message."""
        from aablocks.cli import _format_error

        resp = MagicMock()
        resp.status_code = 403
        err = requests.exceptions.HTTPError(response=resp)
        msg = _format_error(err)
        assert "Access denied" in msg or "permission" in msg

    def test_http_404(self):
        """HTTP 404 is formatted as a not-found message."""
        from aablocks.cli import _format_error

        resp = MagicMock()
        resp.status_code = 404
        err = requests.exceptions.HTTPError(response=resp)
        msg = _format_error(err)
        assert "Not found" in msg

    def test_http_500(self):
        """HTTP 500 is formatted as a server error message."""
        from aablocks.cli import _format_error

        resp = MagicMock()
        resp.status_code = 500
        err = requests.exceptions.HTTPError(response=resp)
        msg = _format_error(err)
        assert "Server error" in msg

    def test_value_error(self):
        """ValueError is formatted using its message string directly."""
        from aablocks.cli import _format_error

        msg = _format_error(ValueError("bad value"))
        assert msg == "bad value"

    def test_generic_error(self):
        """Unknown exception types include the class name in the message."""
        from aablocks.cli import _format_error

        msg = _format_error(TypeError("oops"))
        assert "TypeError" in msg


# ---------------------------------------------------------------------------
# Structures
# ---------------------------------------------------------------------------
class TestStructuresCmd:
    @patch("aablocks.cli.download_structures")
    def test_download_all(self, mock_dl, runner):
        """Default structures command downloads the zip archive."""
        mock_dl.return_value = "ab1614_structures.zip"
        result = runner.invoke(main, ["structures", "ab1614"])
        assert result.exit_code == 0
        assert "Structures written to" in result.output
        mock_dl.assert_called_once()

    @patch("aablocks.cli.download_structure")
    def test_download_single_file_stdout(self, mock_dl, runner):
        """'--file' without '-o' prints file content to stdout."""
        mock_dl.return_value = "HEADER structure data\nATOM 1 CA\n"
        result = runner.invoke(main, ["structures", "ab1614", "-f", "structure.cif"])
        assert result.exit_code == 0
        assert "HEADER structure data" in result.output
        mock_dl.assert_called_once_with(
            "ab1614",
            "structure.cif",
            version=None,
        )

    @patch("aablocks.cli.download_structure")
    def test_download_single_file_with_output_flag(self, mock_dl, runner):
        """'--file' with '-o' (no value) writes to the filename."""
        mock_dl.return_value = "s.cif"
        result = runner.invoke(main, ["structures", "ab1614", "-f", "s.cif", "-o"])
        assert result.exit_code == 0
        assert "Structure written to" in result.output
        mock_dl.assert_called_once_with(
            "ab1614",
            "s.cif",
            version=None,
            output_path="s.cif",
        )

    @patch("aablocks.cli.download_structure")
    def test_download_single_file_with_output_path(self, mock_dl, runner, tmp_path):
        """'--file' with '-o <path>' writes to the specified path."""
        out = tmp_path / "custom.cif"
        mock_dl.return_value = str(out)
        result = runner.invoke(
            main, ["structures", "ab1614", "-f", "s.cif", "-o", str(out)]
        )
        assert result.exit_code == 0
        assert "Structure written to" in result.output
        mock_dl.assert_called_once_with(
            "ab1614",
            "s.cif",
            version=None,
            output_path=str(out),
        )

    @patch("aablocks.cli.download_structure")
    def test_download_single_file_with_version(self, mock_dl, runner):
        """'--file' with '-v' forwards the version parameter."""
        mock_dl.return_value = "s.cif"
        runner.invoke(main, ["structures", "ab1614", "-f", "s.cif", "-v", "2"])
        mock_dl.assert_called_once_with(
            "ab1614",
            "s.cif",
            version="2",
        )
