from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
import tempfile
import os
import pytest
import signal

# We need to set the environment variable before importing the app
os.environ["API_KEY"] = "test-secret"
dummy_tab = tempfile.mktemp()
with open(dummy_tab, "w") as f:
    f.write("")
os.environ["TABFILE"] = dummy_tab

from api.main import app  # noqa: E402

client = TestClient(app)


def test_is_ringing_false_on_pid_reuse():
    """isRinging returns False when the PID file points to a non-wecker process."""
    from api.schema import is_wecker_ringing

    fake_pid = 9999
    with patch("api.schema.PID_FILE", "/tmp/fake_reuse.pid"), \
         patch("api.schema.os.path.exists", return_value=True), \
         patch("api.schema.os.kill") as mock_kill, \
         patch("builtins.open") as mock_open:
        mock_kill.return_value = None

        pid_file_mock = MagicMock()
        pid_file_mock.read.return_value = str(fake_pid)
        cmdline_mock = MagicMock()
        cmdline_mock.read.return_value = b"systemd-journald"

        def open_side_effect(path, *args, **kwargs):
            if str(path).endswith("fake_reuse.pid"):
                return pid_file_mock
            if str(path) == f"/proc/{fake_pid}/cmdline":
                return cmdline_mock
            raise FileNotFoundError(path)

        mock_open.side_effect = open_side_effect
        assert is_wecker_ringing() is False


def test_is_ringing_returns_false_when_not_running():
    """Test that isRinging returns False when no wecker process is running."""
    headers = {"X-API-Key": "test-secret"}
    query = """
    query {
      isRinging
    }
    """
    res = client.post("/graphql", json={"query": query}, headers=headers)
    assert res.status_code == 200
    assert res.json()["data"]["isRinging"] is False


@patch("api.schema.is_wecker_ringing", return_value=True)
def test_is_ringing_returns_true_when_running(mock_is_ringing):
    """Test that isRinging returns True when a wecker process is running."""
    headers = {"X-API-Key": "test-secret"}
    query = """
    query {
      isRinging
    }
    """
    res = client.post("/graphql", json={"query": query}, headers=headers)
    assert res.status_code == 200
    assert res.json()["data"]["isRinging"] is True


def test_auth_missing():
    query = """
    query {
      getAlarms {
        id
      }
    }
    """
    response = client.post("/graphql", json={"query": query})
    assert response.status_code == 401


def test_auth_invalid():
    query = """
    query {
      getAlarms {
        id
      }
    }
    """
    response = client.post(
        "/graphql", json={"query": query}, headers={"X-API-Key": "wrong"}
    )
    assert response.status_code == 401


def test_bugfix_default_command_uses_append_for_logs():
    """
    Test for bugfix: Ensure the default command appends (>>) to wecker.log
    instead of overwriting (>) it.
    """
    from api.schema import Mutation
    from api.crontab_manager import CrontabManager

    mutation = Mutation()
    # Call the resolver directly without command to trigger default command generation
    alarm = mutation.set_alarm(cron_expression="0 9 * * *")

    manager = CrontabManager(tabfile=os.environ["TABFILE"])
    alarms = manager.get_alarms()
    assert len(alarms) == 1
    command = alarms[0]["command"]
    assert ">> wecker.log 2>&1" in command, (
        f"Command must use append '>>' syntax. Got: {command}"
    )
    assert "> wecker.log 2>&1" not in command.replace(">> wecker.log", "REPLACED"), (
        "Command must not use overwrite '>'"
    )

    # Cleanup
    mutation.delete_alarm(id=alarm.id)


def test_set_alarm_default_command_append():
    headers = {"X-API-Key": "test-secret"}
    mutation = """
    mutation {
      setAlarm(cronExpression: "0 9 * * *") {
        id
      }
    }
    """
    res = client.post("/graphql", json={"query": mutation}, headers=headers)
    assert res.status_code == 200
    alarm_id = res.json()["data"]["setAlarm"]["id"]

    from api.crontab_manager import CrontabManager
    manager = CrontabManager(tabfile=os.environ["TABFILE"])
    alarms = manager.get_alarms()
    assert len(alarms) == 1
    command = alarms[0]["command"]

    # Assert the command contains the correct append syntax (>>) and not just overwrite (>)
    assert ">> wecker.log 2>&1" in command
    assert "> wecker.log 2>&1" not in command.replace(">> wecker.log", "REPLACED")

    # Cleanup so we don't break subsequent tests
    mutation_delete = f"""
    mutation {{
      deleteAlarm(id: "{alarm_id}")
    }}
    """
    client.post("/graphql", json={"query": mutation_delete}, headers=headers)


def test_set_alarm_rejects_custom_command():
    """Custom command argument must be rejected to prevent crontab injection."""
    headers = {"X-API-Key": "test-secret"}
    mutation = """
    mutation {
      setAlarm(cronExpression: "0 9 * * *", command: "rm -rf /") {
        id
      }
    }
    """
    res = client.post("/graphql", json={"query": mutation}, headers=headers)
    assert res.status_code == 200
    assert "errors" in res.json()
    assert "command" in str(res.json()["errors"])


def test_set_alarm_rejects_invalid_cron_expression():
    """Invalid cron expressions must be rejected at the API boundary."""
    headers = {"X-API-Key": "test-secret"}
    mutation = """
    mutation {
      setAlarm(cronExpression: "definitely not valid") {
        id
      }
    }
    """
    res = client.post("/graphql", json={"query": mutation}, headers=headers)
    assert res.status_code == 200
    assert "errors" in res.json()
    assert "Invalid cron expression" in str(res.json()["errors"])


def test_graphql_workflow():
    headers = {"X-API-Key": "test-secret"}

    # 1. Get empty alarms
    query_get = """
    query {
      getAlarms {
        id
      }
    }
    """
    res = client.post("/graphql", json={"query": query_get}, headers=headers)
    assert res.status_code == 200
    assert res.json()["data"]["getAlarms"] == []

    # 2. Set alarm
    mutation_set = """
    mutation {
      setAlarm(cronExpression: "30 7 * * *", isEnabled: true) {
        id
        cronExpression
        isEnabled
      }
    }
    """
    res = client.post("/graphql", json={"query": mutation_set}, headers=headers)
    assert res.status_code == 200
    alarm = res.json()["data"]["setAlarm"]
    assert alarm["cronExpression"] == "30 7 * * *"
    assert alarm["isEnabled"] is True
    alarm_id = alarm["id"]

    # 3. Get alarms lists it
    res = client.post("/graphql", json={"query": query_get}, headers=headers)
    assert len(res.json()["data"]["getAlarms"]) == 1
    assert res.json()["data"]["getAlarms"][0]["id"] == alarm_id

    # 4. Get specific alarm
    query_one = f"""
    query {{
      getAlarm(id: "{alarm_id}") {{
        id
        cronExpression
      }}
    }}
    """
    res = client.post("/graphql", json={"query": query_one}, headers=headers)
    assert res.json()["data"]["getAlarm"]["id"] == alarm_id
    assert res.json()["data"]["getAlarm"]["cronExpression"] == "30 7 * * *"

    # 5. Update alarm
    mutation_update = f"""
    mutation {{
      setAlarm(id: "{alarm_id}", cronExpression: "0 8 * * *", isEnabled: false) {{
        id
        cronExpression
        isEnabled
      }}
    }}
    """
    res = client.post("/graphql", json={"query": mutation_update}, headers=headers)
    alarm_updated = res.json()["data"]["setAlarm"]
    assert alarm_updated["id"] == alarm_id
    assert alarm_updated["cronExpression"] == "0 8 * * *"
    assert alarm_updated["isEnabled"] is False

    # 6. Delete alarm
    mutation_delete = f"""
    mutation {{
      deleteAlarm(id: "{alarm_id}")
    }}
    """
    res = client.post("/graphql", json={"query": mutation_delete}, headers=headers)
    assert res.json()["data"]["deleteAlarm"] is True

    # 7. List again is empty
    res = client.post("/graphql", json={"query": query_get}, headers=headers)
    assert res.json()["data"]["getAlarms"] == []


def test_start_ringing_starts_process_when_not_ringing():
    """startRinging returns True and spawns wecker.py when not already ringing."""
    from api.schema import Mutation

    mutation = Mutation()

    with patch("api.schema.is_wecker_ringing", return_value=False), \
         patch("subprocess.Popen") as mock_popen:
        mock_proc = mock_popen.return_value
        mock_proc.pid = 9999
        result = mutation.start_ringing()
        assert result is True
        mock_popen.assert_called_once()
        args, kwargs = mock_popen.call_args
        assert any("wecker.py" in str(arg) for arg in args[0])
        assert kwargs.get("shell") is not True
        assert kwargs.get("cwd") is not None
        assert kwargs.get("start_new_session") is True


def test_start_ringing_ignores_when_already_ringing():
    """startRinging returns False when already ringing."""
    from api.schema import Mutation

    mutation = Mutation()

    with patch("api.schema.is_wecker_ringing", return_value=True), \
         patch("subprocess.Popen") as mock_popen:
        result = mutation.start_ringing()
        assert result is False
        mock_popen.assert_not_called()


def test_stop_ringing_sends_sigterm_when_ringing():
    """stopRinging sends SIGTERM and cleans up the PID file."""
    from api.schema import Mutation

    mutation = Mutation()

    with patch("api.schema.is_wecker_ringing", return_value=True), \
         patch("api.schema.os.kill") as mock_kill, \
         patch("api.schema.os.path.exists", return_value=True), \
         patch("api.schema.os.remove") as mock_remove, \
         patch("common.PID_FILE", "/tmp/fake_wecker.pid"), \
         patch("builtins.open") as mock_open:
        mock_f = mock_open.return_value.__enter__.return_value
        mock_f.read.return_value = "1234"
        mock_kill.side_effect = [None, ProcessLookupError]
        result = mutation.stop_ringing()
        assert result is True
        mock_kill.assert_any_call(1234, signal.SIGTERM)
        mock_remove.assert_called_once()


def test_stop_ringing_falls_back_to_sigkill():
    """stopRinging escalates to SIGKILL if SIGTERM does not terminate the process."""
    from api.schema import Mutation

    mutation = Mutation()

    with patch("api.schema.is_wecker_ringing", return_value=True), \
         patch("api.schema.os.kill") as mock_kill, \
         patch("api.schema.time.sleep"), \
         patch("api.schema.os.path.exists", return_value=True), \
         patch("api.schema.os.remove"), \
         patch("common.PID_FILE", "/tmp/fake_wecker.pid"), \
         patch("builtins.open") as mock_open:
        mock_f = mock_open.return_value.__enter__.return_value
        mock_f.read.return_value = "1234"
        mock_kill.return_value = None
        result = mutation.stop_ringing()
        assert result is True
        signals_sent = [call_args[0][1] for call_args in mock_kill.call_args_list]
        assert signal.SIGTERM in signals_sent
        assert signal.SIGKILL in signals_sent


def test_stop_ringing_does_nothing_when_not_ringing():
    """stopRinging returns False when not ringing."""
    from api.schema import Mutation

    mutation = Mutation()

    with patch("api.schema.is_wecker_ringing", return_value=False), \
         patch("api.schema.os.kill") as mock_kill:
        result = mutation.stop_ringing()
        assert result is False
        mock_kill.assert_not_called()


def test_start_ringing_graphql_endpoint():
    """GraphQL mutation startRinging works via the API."""
    headers = {"X-API-Key": "test-secret"}
    mutation_str = """
    mutation {
      startRinging
    }
    """

    with patch("api.schema.is_wecker_ringing", return_value=False), \
         patch("subprocess.Popen") as mock_popen:
        mock_proc = mock_popen.return_value
        mock_proc.pid = 9999
        res = client.post("/graphql", json={"query": mutation_str}, headers=headers)
        assert res.status_code == 200
        assert res.json()["data"]["startRinging"] is True


def test_stop_ringing_graphql_endpoint():
    """GraphQL mutation stopRinging works via the API."""
    headers = {"X-API-Key": "test-secret"}
    mutation_str = """
    mutation {
      stopRinging
    }
    """

    with patch("api.schema.is_wecker_ringing", return_value=True), \
         patch("api.schema.os.kill") as mock_kill, \
         patch("builtins.open") as mock_open:
        mock_f = mock_open.return_value.__enter__.return_value
        mock_f.read.return_value = "1234"
        mock_kill.return_value = None
        res = client.post("/graphql", json={"query": mutation_str}, headers=headers)
        assert res.status_code == 200
        assert res.json()["data"]["stopRinging"] is True


def test_pid_file_same_shared_constant_in_api():
    """Ensure api.schema uses the same PID_FILE from common, not a redefinition."""
    from api import schema as api_schema
    import common

    assert api_schema.PID_FILE is common.PID_FILE, (
        "api.schema.PID_FILE must reference common.PID_FILE, not redefine it"
    )


def _cleanup_alarm(alarm_id):
    headers = {"X-API-Key": "test-secret"}
    client.post(
        "/graphql",
        json={"query": f'mutation {{ deleteAlarm(id: "{alarm_id}") }}'},
        headers=headers,
    )


def test_set_alarm_accepts_and_returns_style():
    headers = {"X-API-Key": "test-secret"}
    mutation = '''
    mutation {
      setAlarm(cronExpression: "0 9 * * *", style: "blink") {
        id
        style
      }
    }
    '''
    res = client.post("/graphql", json={"query": mutation}, headers=headers)
    assert res.status_code == 200
    alarm = res.json()["data"]["setAlarm"]
    assert alarm["style"] == "blink"
    _cleanup_alarm(alarm["id"])


def test_set_alarm_default_style_is_simple():
    headers = {"X-API-Key": "test-secret"}
    mutation = '''
    mutation {
      setAlarm(cronExpression: "0 9 * * *") { id style }
    }
    '''
    res = client.post("/graphql", json={"query": mutation}, headers=headers)
    assert res.status_code == 200
    alarm = res.json()["data"]["setAlarm"]
    assert alarm["style"] == "simple"
    _cleanup_alarm(alarm["id"])


def test_set_alarm_rejects_unknown_style():
    headers = {"X-API-Key": "test-secret"}
    mutation = '''
    mutation {
      setAlarm(cronExpression: "0 9 * * *", style: "nope") { id }
    }
    '''
    res = client.post("/graphql", json={"query": mutation}, headers=headers)
    assert res.status_code == 200
    assert "errors" in res.json()
    assert "Unknown alarm style" in str(res.json()["errors"])


def test_get_alarms_includes_style():
    headers = {"X-API-Key": "test-secret"}
    set_mut = '''
    mutation { setAlarm(cronExpression: "0 9 * * *", style: "blink") { id } }
    '''
    res = client.post("/graphql", json={"query": set_mut}, headers=headers)
    alarm_id = res.json()["data"]["setAlarm"]["id"]

    res = client.post(
        "/graphql", json={"query": "{ getAlarms { id style } }"}, headers=headers
    )
    alarms = res.json()["data"]["getAlarms"]
    assert any(a["id"] == alarm_id and a["style"] == "blink" for a in alarms)
    _cleanup_alarm(alarm_id)


def test_start_ringing_passes_style():
    from api.schema import Mutation

    mutation = Mutation()
    with patch("api.schema.is_wecker_ringing", return_value=False), \
         patch("subprocess.Popen") as mock_popen:
        mock_popen.return_value.pid = 9999
        assert mutation.start_ringing(style="blink") is True
    argv = mock_popen.call_args[0][0]
    assert "--style" in argv and "blink" in argv


def test_start_ringing_default_style_is_simple():
    from api.schema import Mutation

    mutation = Mutation()
    with patch("api.schema.is_wecker_ringing", return_value=False), \
         patch("subprocess.Popen") as mock_popen:
        mock_popen.return_value.pid = 9999
        mutation.start_ringing()
    argv = mock_popen.call_args[0][0]
    assert "simple" in argv


def test_start_ringing_rejects_unknown_style():
    from api.schema import Mutation

    mutation = Mutation()
    with patch("api.schema.is_wecker_ringing", return_value=False), \
         patch("subprocess.Popen") as mock_popen:
        with pytest.raises(ValueError, match="Unknown alarm style"):
            mutation.start_ringing(style="nope")
        mock_popen.assert_not_called()
