from __future__ import annotations

import json
from http.cookiejar import Cookie
from unittest.mock import patch

import httpx
import respx
from click.testing import CliRunner
from conftest import (
    INITIATE_SEARCH_RESPONSE,
    SAMPLE_ROUTE_JSON,
    SAMPLE_ROUTE_NONE_DURATION,
    SAMPLE_ROUTE_NONSTOP,
    async_sse_stream,
    sse_bytes,
)

from point_me.cli import main

BASE_URL = "https://api.point.me/api"

_INIT_JSON = json.loads(INITIATE_SEARCH_RESPONSE)


def _mock_search(respx_mock, *events):
    """Set up both initiateSearch POST and subscribeToSearch GET mocks."""
    respx_mock.post(f"{BASE_URL}/initiateSearch").mock(
        return_value=httpx.Response(200, json=_INIT_JSON)
    )
    respx_mock.get(f"{BASE_URL}/subscribeToSearch").mock(
        return_value=httpx.Response(
            200,
            headers={"content-type": "text/event-stream"},
            stream=async_sse_stream(sse_bytes(*events)),
        )
    )


@respx.mock(assert_all_called=False)
def test_search_table_output(respx_mock):
    """CLI-01, CLI-02: table output on stdout with correct columns and values."""
    _mock_search(respx_mock, ("SEARCH", SAMPLE_ROUTE_JSON), ("COMPLETE", ""))
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    assert "Award Flights" in result.stdout
    assert "Avios" in result.stdout
    assert "60,000" in result.stdout
    assert "$200.00" in result.stdout
    assert "JFK" in result.stdout
    assert "LHR" in result.stdout
    assert "8h 0m" in result.stdout
    assert "1" in result.stdout  # stops


@respx.mock(assert_all_called=False)
def test_search_json_output(respx_mock):
    """CLI-03: --json flag produces valid JSON on stdout with no Rich markup."""
    _mock_search(respx_mock, ("SEARCH", SAMPLE_ROUTE_JSON), ("COMPLETE", ""))
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--json"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    data = json.loads(result.stdout)
    assert isinstance(data, list)
    assert len(data) >= 1
    assert data[0]["program"] == "Avios"
    assert data[0]["miles"] == 60000


@respx.mock(assert_all_called=False)
def test_search_token_flag(respx_mock):
    """CLI-04: --token flag works without POINTME_TOKEN env var."""
    _mock_search(respx_mock, ("SEARCH", SAMPLE_ROUTE_JSON), ("COMPLETE", ""))
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--token", "fakeTok"],
        env={},
    )
    assert result.exit_code == 0
    assert "Avios" in result.stdout


def test_search_no_results():
    """CLI-01: No results prints message to stderr and exits 0."""
    with respx.mock(assert_all_called=False) as respx_mock:
        _mock_search(respx_mock, ("COMPLETE", ""))
        runner = CliRunner()
        result = runner.invoke(
            main,
            ["search", "JFK", "LHR", "2026-05-01", "business"],
            env={"POINTME_TOKEN": "t"},
        )
    assert result.exit_code == 0
    assert "No routes found." in result.stderr


def test_search_auth_error():
    """CLI-04: No token, no env var, no cookies triggers error, exits 1."""
    with patch("point_me.cli.browser_cookie3") as mock_bc3:
        mock_bc3.firefox.return_value = []
        runner = CliRunner()
        result = runner.invoke(
            main,
            ["search", "JFK", "LHR", "2026-05-01", "business"],
            env={},
        )
    assert result.exit_code == 1
    assert "No point.me session cookie found" in result.stderr
    assert "Auth error" in result.stderr


@respx.mock(assert_all_called=False)
def test_search_spinner_stderr(respx_mock):
    """CLI-05: Table content goes to stdout, not mixed with spinner output on stderr."""
    _mock_search(respx_mock, ("SEARCH", SAMPLE_ROUTE_JSON), ("COMPLETE", ""))
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Table content goes to stdout
    assert "Avios" in result.stdout
    # "No routes found." should NOT be in stdout
    assert "No routes found" not in result.stdout


# --- Filter and sort tests ---


@respx.mock(assert_all_called=False)
def test_search_filter_max_stops(respx_mock):
    """CLI-06: --max-stops 0 keeps only nonstop routes."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--max-stops", "0"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Only the nonstop route survives; SAMPLE_ROUTE_NONSTOP has Aeroplan and Avios fares
    # SAMPLE_ROUTE_JSON (1-stop) is filtered out entirely
    assert "Air Canada Aeroplan" in result.stdout
    # Verify the 1-stop route's Avios fare (60000) is NOT shown —
    # only the nonstop Avios fare (70000) could appear
    # SAMPLE_ROUTE_JSON's 60000-mile Avios filtered out
    assert "60,000" not in result.stdout
    assert "40,000" in result.stdout  # SAMPLE_ROUTE_NONSTOP's Aeroplan fare survives


@respx.mock(assert_all_called=False)
def test_search_filter_max_miles(respx_mock):
    """CLI-06: --max-miles 50000 returns only fares at or under 50000 miles."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--max-miles", "50000"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Aeroplan is 40000, Avios is 70000 — only Aeroplan should appear
    assert "Air Canada Aeroplan" in result.stdout
    assert "Avios" not in result.stdout


@respx.mock(assert_all_called=False)
def test_search_filter_program(respx_mock):
    """CLI-06: --program 'aero' matches 'Air Canada Aeroplan' but not 'Avios'."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--program", "aero"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    assert "Air Canada Aeroplan" in result.stdout
    assert "Avios" not in result.stdout


@respx.mock(assert_all_called=False)
def test_search_filter_combined(respx_mock):
    """CLI-06: Combined --max-stops 1 --program 'air canada' filters correctly."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        [
            "search",
            "JFK",
            "LHR",
            "2026-05-01",
            "business",
            "--max-stops",
            "1",
            "--program",
            "air canada",
        ],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Only Air Canada Aeroplan matches; Avios is filtered by program
    assert "Air Canada Aeroplan" in result.stdout
    assert "Avios" not in result.stdout


@respx.mock(assert_all_called=False)
def test_search_filter_all_removed(respx_mock):
    """CLI-06: All results filtered out prints no-routes."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--max-miles", "1"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    assert "No routes found." in result.stderr


@respx.mock(assert_all_called=False)
def test_search_filter_json(respx_mock):
    """CLI-06: --max-miles 50000 --json returns filtered JSON output."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        [
            "search",
            "JFK",
            "LHR",
            "2026-05-01",
            "business",
            "--max-miles",
            "50000",
            "--json",
        ],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    data = json.loads(result.stdout)
    assert all(row["miles"] <= 50000 for row in data)
    program_names = [row["program"] for row in data]
    assert "Air Canada Aeroplan" in program_names
    assert "Avios" not in program_names


@respx.mock(assert_all_called=False)
def test_search_sort_miles(respx_mock):
    """CLI-07: --sort miles returns fares in ascending miles order."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--sort", "miles"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Aeroplan=40000 < Avios=60000 < Avios=70000
    aeroplan_pos = result.stdout.index("Air Canada Aeroplan")
    first_avios_pos = result.stdout.index("Avios")
    assert aeroplan_pos < first_avios_pos


@respx.mock(assert_all_called=False)
def test_search_sort_stops(respx_mock):
    """CLI-07: --sort stops returns routes in ascending stops order."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--sort", "stops"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Nonstop (0 stops) should come before 1-stop route
    aeroplan_pos = result.stdout.index("Air Canada Aeroplan")
    avios_pos = result.stdout.index("Avios")
    # Nonstop Aeroplan (0 stops) before 1-stop Avios
    assert aeroplan_pos < avios_pos


@respx.mock(assert_all_called=False)
def test_search_sort_duration(respx_mock):
    """CLI-07: --sort duration returns routes in ascending duration order."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--sort", "duration"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Nonstop=300min before 1-stop=480min
    aeroplan_pos = result.stdout.index("Air Canada Aeroplan")
    avios_pos = result.stdout.index("Avios")
    assert aeroplan_pos < avios_pos


@respx.mock(assert_all_called=False)
def test_search_sort_none_last(respx_mock):
    """CLI-07: None duration sorts last with --sort duration."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("SEARCH", SAMPLE_ROUTE_NONE_DURATION),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--sort", "duration"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    # Nonstop=300min should appear before None-duration route
    aeroplan_pos = result.stdout.index("Air Canada Aeroplan")
    ana_pos = result.stdout.index("ANA Mileage Club")
    assert aeroplan_pos < ana_pos


@respx.mock(assert_all_called=False)
def test_search_sort_with_filter_json(respx_mock):
    """CLI-07: filter + sort + json combined."""
    _mock_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("SEARCH", SAMPLE_ROUTE_NONSTOP),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        [
            "search",
            "JFK",
            "LHR",
            "2026-05-01",
            "business",
            "--max-miles",
            "80000",
            "--sort",
            "miles",
            "--json",
        ],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    data = json.loads(result.stdout)
    # All miles <= 80000 (90000 ANA filtered out if present)
    assert all(row["miles"] <= 80000 for row in data)
    # Sort places the route with the lowest fare first (Aeroplan 40000)
    assert data[0]["miles"] == 40000
    assert data[0]["program"] == "Air Canada Aeroplan"


def test_search_sort_invalid():
    """CLI-07: --sort with invalid value is rejected by Click with exit code 2."""
    runner = CliRunner()
    result = runner.invoke(
        main,
        ["search", "JFK", "LHR", "2026-05-01", "business", "--sort", "invalid"],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 2
    assert "Invalid value" in result.output


# --- Roundtrip tests ---


def _mock_roundtrip_search(respx_mock, *events):
    """Mock for roundtrip: returns fresh SSE stream per call."""
    respx_mock.post(f"{BASE_URL}/initiateSearch").mock(
        return_value=httpx.Response(200, json=_INIT_JSON)
    )

    def _fresh_sse(request):
        return httpx.Response(
            200,
            headers={"content-type": "text/event-stream"},
            stream=async_sse_stream(sse_bytes(*events)),
        )
    respx_mock.get(f"{BASE_URL}/subscribeToSearch").mock(
        side_effect=_fresh_sse
    )


@respx.mock(assert_all_called=False)
def test_search_roundtrip_table(respx_mock):
    """--return-date renders two tables for outbound and return."""
    _mock_roundtrip_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        [
            "search", "JFK", "LHR", "2026-05-01", "business",
            "--return-date", "2026-05-15",
        ],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    assert "Outbound Flights" in result.stdout
    assert "Return Flights" in result.stdout


@respx.mock(assert_all_called=False)
def test_search_roundtrip_json(respx_mock):
    """--return-date --json produces dict with outbound and inbound keys."""
    _mock_roundtrip_search(
        respx_mock,
        ("SEARCH", SAMPLE_ROUTE_JSON),
        ("COMPLETE", ""),
    )
    runner = CliRunner()
    result = runner.invoke(
        main,
        [
            "search", "JFK", "LHR", "2026-05-01", "business",
            "--return-date", "2026-05-15", "--json",
        ],
        env={"POINTME_TOKEN": "t"},
    )
    assert result.exit_code == 0
    data = json.loads(result.stdout)
    assert "outbound" in data
    assert "inbound" in data
    assert isinstance(data["outbound"], list)
    assert isinstance(data["inbound"], list)


# --- Cookie-based auth tests ---


def _make_cookie(name, value, domain):
    """Create a http.cookiejar.Cookie for testing."""
    return Cookie(
        version=0, name=name, value=value, port=None, port_specified=False,
        domain=domain, domain_specified=True, domain_initial_dot=False,
        path="/", path_specified=True, secure=True, expires=None,
        discard=True, comment=None, comment_url=None, rest={},
    )


@respx.mock(assert_all_called=False)
def test_cookie_auth_amex_preferred(respx_mock):
    """When both amex and www cookies exist, amex is preferred."""
    _mock_search(
        respx_mock, ("SEARCH", SAMPLE_ROUTE_JSON), ("COMPLETE", "")
    )
    respx_mock.get("https://amex.point.me/api/auth/session").mock(
        return_value=httpx.Response(
            200, json={"accessToken": "jwt-from-cookie"}
        )
    )
    cookies = [
        _make_cookie(
            "__Secure-next-auth.session-token", "amex-sess", "amex.point.me"
        ),
        _make_cookie(
            "__Secure-next-auth.session-token", "www-sess", "www.point.me"
        ),
    ]
    with patch("point_me.cli.browser_cookie3") as mock_bc3:
        mock_bc3.firefox.return_value = cookies
        runner = CliRunner()
        result = runner.invoke(
            main,
            ["search", "JFK", "LHR", "2026-05-01", "business"],
            env={},
        )
    assert result.exit_code == 0
    assert "Avios" in result.stdout


@respx.mock(assert_all_called=False)
def test_cookie_auth_www_fallback(respx_mock):
    """When only www cookie exists, it's used with www site."""
    _mock_search(
        respx_mock, ("SEARCH", SAMPLE_ROUTE_JSON), ("COMPLETE", "")
    )
    respx_mock.get("https://www.point.me/api/auth/session").mock(
        return_value=httpx.Response(
            200, json={"accessToken": "jwt-www"}
        )
    )
    cookies = [
        _make_cookie(
            "__Secure-next-auth.session-token", "www-sess", "www.point.me"
        ),
    ]
    with patch("point_me.cli.browser_cookie3") as mock_bc3:
        mock_bc3.firefox.return_value = cookies
        runner = CliRunner()
        result = runner.invoke(
            main,
            ["search", "JFK", "LHR", "2026-05-01", "business"],
            env={},
        )
    assert result.exit_code == 0


def test_cookie_auth_no_cookies_found():
    """No session cookies prints helpful error."""
    with patch("point_me.cli.browser_cookie3") as mock_bc3:
        mock_bc3.firefox.return_value = []
        runner = CliRunner()
        result = runner.invoke(
            main,
            ["search", "JFK", "LHR", "2026-05-01", "business"],
            env={},
        )
    assert result.exit_code == 1
    assert "Log in to point.me" in result.stderr


def test_cookie_auth_browser_flag():
    """--browser chrome uses chrome function instead of firefox."""
    with patch("point_me.cli.browser_cookie3") as mock_bc3:
        mock_bc3.chrome.return_value = []
        runner = CliRunner()
        result = runner.invoke(
            main,
            [
                "search", "JFK", "LHR", "2026-05-01", "business",
                "--browser", "chrome",
            ],
            env={},
        )
    mock_bc3.chrome.assert_called_once()
    mock_bc3.firefox.assert_not_called()
    assert result.exit_code == 1


def test_cookie_auth_browser_exception():
    """Browser cookie read failure shows warning and falls through."""
    with patch("point_me.cli.browser_cookie3") as mock_bc3:
        mock_bc3.firefox.side_effect = PermissionError("no access")
        runner = CliRunner()
        result = runner.invoke(
            main,
            ["search", "JFK", "LHR", "2026-05-01", "business"],
            env={},
        )
    assert result.exit_code == 1
    assert "Could not read firefox cookies" in result.stderr


@respx.mock(assert_all_called=False)
def test_cookie_auth_skipped_when_token_provided(respx_mock):
    """When --token is provided, cookie fetching is skipped entirely."""
    _mock_search(
        respx_mock, ("SEARCH", SAMPLE_ROUTE_JSON), ("COMPLETE", "")
    )
    with patch("point_me.cli.browser_cookie3") as mock_bc3:
        runner = CliRunner()
        result = runner.invoke(
            main,
            [
                "search", "JFK", "LHR", "2026-05-01", "business",
                "--token", "explicit-token",
            ],
            env={},
        )
    mock_bc3.firefox.assert_not_called()
    assert result.exit_code == 0


