"""Tests for /api/ralph/* endpoints (dashboard live).

Acoperă:
- /api/ralph/status: list cards cu state + count + fetchedAt
- /api/ralph/<slug>/log: tail progress.txt
- /api/ralph/<slug>/prd: full prd.json
- _ralph_validate_slug: path traversal protection
- corrupt prd.json: graceful error (status='error', not 500)
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest

PROJECT_ROOT = Path(__file__).resolve().parents[1]
DASH = PROJECT_ROOT / "dashboard"
if str(DASH) not in sys.path:
    sys.path.insert(0, str(DASH))


@pytest.fixture(scope="module")
def ralph_module():
    from handlers import ralph as _r  # type: ignore
    return _r


@pytest.fixture
def handler(ralph_module, tmp_path, monkeypatch):
    """Build a stubbed handler with a temp WORKSPACE_DIR."""
    import constants  # type: ignore

    # Re-route WORKSPACE_DIR la tmp pentru izolare
    monkeypatch.setattr(constants, "WORKSPACE_DIR", tmp_path)

    class _Stub(ralph_module.RalphHandlers):
        def __init__(self):
            self.captured = None
            self.captured_code = None
            self.path = "/api/ralph/status"

        def send_json(self, data, code=200):
            self.captured = data
            self.captured_code = code

        def send_error(self, code):
            self.captured = {"error_code": code}
            self.captured_code = code

    return _Stub()


def _make_ralph_project(workspace: Path, slug: str, stories: list, progress: str = "init"):
    """Create a fake ralph project under workspace/<slug>/scripts/ralph/."""
    ralph_dir = workspace / slug / "scripts" / "ralph"
    ralph_dir.mkdir(parents=True, exist_ok=True)
    (ralph_dir / "prd.json").write_text(json.dumps({
        "projectName": slug,
        "branchName": f"ralph/{slug}",
        "userStories": stories,
    }), encoding="utf-8")
    (ralph_dir / "progress.txt").write_text(progress, encoding="utf-8")
    return ralph_dir


# ── /api/ralph/status ──────────────────────────────────────────


class TestStatus:
    def test_empty_workspace_returns_empty(self, handler):
        handler.handle_ralph_status()
        assert handler.captured_code == 200
        assert handler.captured["projects"] == []
        assert "fetchedAt" in handler.captured

    def test_status_skips_non_ralph_projects(self, handler, tmp_path):
        # Create a project WITHOUT scripts/ralph
        (tmp_path / "regular-proj").mkdir()
        handler.handle_ralph_status()
        assert handler.captured["projects"] == []

    def test_status_lists_ralph_projects(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "proj-a", [
            {"id": "US-001", "title": "a", "priority": 10, "passes": True,
             "failed": False, "blocked": False, "retries": 0, "tags": []},
            {"id": "US-002", "title": "b", "priority": 20, "passes": False,
             "failed": False, "blocked": False, "retries": 0, "tags": ["ui"]},
        ])
        handler.handle_ralph_status()
        projects = handler.captured["projects"]
        assert len(projects) == 1
        p = projects[0]
        assert p["slug"] == "proj-a"
        assert p["storiesTotal"] == 2
        assert p["storiesComplete"] == 1
        assert p["storiesFailed"] == 0
        assert p["storiesBlocked"] == 0
        assert p["status"] == "idle"  # not running (no .ralph.pid)

    def test_status_corrupt_prd_returns_error_not_500(self, handler, tmp_path):
        ralph_dir = tmp_path / "broken" / "scripts" / "ralph"
        ralph_dir.mkdir(parents=True)
        (ralph_dir / "prd.json").write_text("{not valid json", encoding="utf-8")
        handler.handle_ralph_status()
        assert handler.captured_code == 200
        assert any(p.get("status") == "error" for p in handler.captured["projects"])

    def test_status_count_matches_projects(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "p1", [])
        _make_ralph_project(tmp_path, "p2", [])
        handler.handle_ralph_status()
        assert handler.captured["count"] == 2

    def test_complete_status_when_all_pass(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "donezo", [
            {"id": "US-001", "passes": True, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "x", "priority": 10},
        ])
        handler.handle_ralph_status()
        p = handler.captured["projects"][0]
        assert p["status"] == "complete"

    def test_failed_status_propagation(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "broken-proj", [
            {"id": "US-001", "passes": False, "failed": True, "blocked": False,
             "retries": 3, "tags": [], "title": "x", "priority": 10,
             "failureReason": "max_retries"},
        ])
        handler.handle_ralph_status()
        p = handler.captured["projects"][0]
        assert p["status"] == "failed"
        assert p["storiesFailed"] == 1


# ── /api/ralph/<slug>/log ──────────────────────────────────────


class TestLog:
    def test_log_returns_progress_lines(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "p1a", [], progress="line1\nline2\nline3")
        handler.path = "/api/ralph/p1a/log"
        handler.handle_ralph_log("p1a")
        assert handler.captured_code == 200
        assert handler.captured["lines"] == ["line1", "line2", "line3"]
        assert handler.captured["total"] == 3

    def test_log_invalid_slug_400(self, handler):
        handler.handle_ralph_log("../etc/passwd")
        assert handler.captured_code == 400

    def test_log_path_traversal_blocked(self, handler):
        handler.handle_ralph_log("..")
        assert handler.captured_code == 400

    def test_log_missing_progress_returns_empty(self, handler, tmp_path):
        ralph_dir = tmp_path / "noprogress" / "scripts" / "ralph"
        ralph_dir.mkdir(parents=True)
        (ralph_dir / "prd.json").write_text("{}")  # no progress.txt
        handler.path = "/api/ralph/noprogress/log"
        handler.handle_ralph_log("noprogress")
        assert handler.captured_code == 200
        assert handler.captured["lines"] == []


# ── /api/ralph/<slug>/prd ──────────────────────────────────────


class TestPrd:
    def test_prd_returns_full_json(self, handler, tmp_path):
        stories = [{"id": "US-001", "passes": False, "title": "t", "priority": 10}]
        _make_ralph_project(tmp_path, "p1a", stories)
        handler.handle_ralph_prd("p1a")
        assert handler.captured_code == 200
        assert handler.captured["projectName"] == "p1a"
        assert len(handler.captured["userStories"]) == 1

    def test_prd_404_when_missing(self, handler, tmp_path):
        (tmp_path / "ghost").mkdir()  # exists, but no prd.json
        handler.handle_ralph_prd("ghost")
        assert handler.captured_code == 404

    def test_prd_invalid_slug_400(self, handler):
        handler.handle_ralph_prd("/etc/passwd")
        assert handler.captured_code == 400


# ── /api/ralph/usage ────────────────────────────────────────────


class TestUsageEndpoint:
    def test_usage_empty_workspace(self, handler):
        handler.path = "/api/ralph/usage"
        handler.handle_ralph_usage()
        assert handler.captured_code == 200
        assert handler.captured["today_runs"] == 0
        assert handler.captured["total_runs"] == 0
        assert handler.captured["by_project"] == {}

    def test_usage_aggregates_across_projects(self, handler, tmp_path):
        # Create two projects, each with usage.jsonl
        for slug, cost, ts in [("proj-a", 0.5, "2026-04-26T10:00:00+00:00"),
                                ("proj-b", 0.3, "2026-04-26T11:00:00+00:00")]:
            ralph_dir = tmp_path / slug / "scripts" / "ralph"
            ralph_dir.mkdir(parents=True)
            (ralph_dir / "usage.jsonl").write_text(
                json.dumps({"slug": slug, "ts": ts, "total_cost_usd": cost,
                            "input_tokens": 100, "output_tokens": 50, "cache_read": 0}) + "\n",
                encoding="utf-8",
            )
        handler.path = "/api/ralph/usage?days=30"
        handler.handle_ralph_usage()
        assert handler.captured_code == 200
        # Should have both projects
        assert "proj-a" in handler.captured["by_project"]
        assert "proj-b" in handler.captured["by_project"]
        assert handler.captured["total_runs"] == 2
        assert handler.captured["window_runs"] == 2

    def test_usage_invalid_days_falls_back(self, handler):
        handler.path = "/api/ralph/usage?days=abc"
        handler.handle_ralph_usage()
        assert handler.captured_code == 200
        assert handler.captured["window_days"] == 7

    def test_usage_handles_corrupt_jsonl(self, handler, tmp_path):
        # Project with corrupt usage.jsonl shouldn't 500
        ralph_dir = tmp_path / "broken" / "scripts" / "ralph"
        ralph_dir.mkdir(parents=True)
        (ralph_dir / "usage.jsonl").write_text("not json\n", encoding="utf-8")
        handler.path = "/api/ralph/usage"
        handler.handle_ralph_usage()
        assert handler.captured_code == 200


# ── _ralph_validate_slug ───────────────────────────────────────


class TestValidateSlug:
    def test_valid_slug_returns_path(self, handler, tmp_path):
        (tmp_path / "good-slug").mkdir()
        result = handler._ralph_validate_slug("good-slug")
        assert result is not None
        assert result.name == "good-slug"

    def test_slash_rejected(self, handler):
        assert handler._ralph_validate_slug("a/b") is None

    def test_dotdot_rejected(self, handler):
        assert handler._ralph_validate_slug("..") is None

    def test_empty_rejected(self, handler):
        assert handler._ralph_validate_slug("") is None

    def test_nonexistent_returns_none(self, handler):
        assert handler._ralph_validate_slug("does-not-exist") is None

    def test_underscore_allowed(self, handler, tmp_path):
        (tmp_path / "snake_case_slug").mkdir()
        result = handler._ralph_validate_slug("snake_case_slug")
        assert result is not None

    def test_too_long_rejected(self, handler):
        assert handler._ralph_validate_slug("a" * 65) is None

    def test_special_chars_rejected(self, handler):
        # Punctuaţie / spaţii / shell metachars — toate respinse de regex
        for bad in ("a b", "a;b", "a$b", "a.b", "a&b", "a|b", "a%2E"):
            assert handler._ralph_validate_slug(bad) is None, bad

    def test_backslash_rejected(self, handler):
        assert handler._ralph_validate_slug("a\\b") is None


# ── _ralph_collect_status / _ralph_signature (SSE helpers) ────


class TestCollectAndSignature:
    def test_collect_empty_when_no_workspace(self, handler):
        snap = handler._ralph_collect_status()
        assert snap == {"projects": [], "fetchedAt": snap["fetchedAt"], "count": 0}

    def test_collect_lists_projects(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "proj-x", [
            {"id": "US-001", "passes": True, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "x", "priority": 10},
        ])
        snap = handler._ralph_collect_status()
        assert snap["count"] == 1
        assert snap["projects"][0]["slug"] == "proj-x"

    def test_signature_stable_when_unchanged(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "p1", [])
        snap1 = handler._ralph_collect_status()
        snap2 = handler._ralph_collect_status()
        # fetchedAt diferă — semnătura ignoră asta intenţionat
        assert handler._ralph_signature(snap1) == handler._ralph_signature(snap2)

    def test_signature_changes_when_project_added(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "p1", [])
        sig1 = handler._ralph_signature(handler._ralph_collect_status())
        _make_ralph_project(tmp_path, "p2", [])
        sig2 = handler._ralph_signature(handler._ralph_collect_status())
        assert sig1 != sig2

    def test_signature_changes_when_passes_changes(self, handler, tmp_path):
        _make_ralph_project(tmp_path, "p1", [
            {"id": "US-001", "passes": False, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "a", "priority": 10},
        ])
        sig1 = handler._ralph_signature(handler._ralph_collect_status())
        # mutăm story la passes=True
        ralph_dir = tmp_path / "p1" / "scripts" / "ralph"
        prd = json.loads((ralph_dir / "prd.json").read_text())
        prd["userStories"][0]["passes"] = True
        (ralph_dir / "prd.json").write_text(json.dumps(prd))
        sig2 = handler._ralph_signature(handler._ralph_collect_status())
        assert sig1 != sig2


# ── /api/ralph/<slug>/rollback ─────────────────────────────────


def _git(cmd: list[str], cwd):
    """Run a git subcommand for test setup; raise if it fails."""
    import subprocess
    return subprocess.run(
        ["git"] + cmd, cwd=str(cwd), check=True,
        capture_output=True, text=True,
    )


def _init_repo_with_two_commits(project_dir):
    """Create a real git repo with two commits — needed for revert/reset tests."""
    project_dir.mkdir(parents=True, exist_ok=True)
    _git(["init", "-q", "-b", "main"], project_dir)
    _git(["config", "user.email", "test@example.com"], project_dir)
    _git(["config", "user.name", "Test"], project_dir)
    _git(["config", "commit.gpgsign", "false"], project_dir)
    (project_dir / "README.md").write_text("first")
    _git(["add", "README.md"], project_dir)
    _git(["commit", "-q", "-m", "first"], project_dir)
    (project_dir / "feature.txt").write_text("second commit content")
    _git(["add", "feature.txt"], project_dir)
    _git(["commit", "-q", "-m", "second"], project_dir)


class TestRollback:
    def test_invalid_slug_400(self, handler):
        handler.handle_ralph_rollback("../etc/passwd")
        assert handler.captured_code == 400
        assert handler.captured["success"] is False

    def test_path_traversal_blocked(self, handler):
        handler.handle_ralph_rollback("..")
        assert handler.captured_code == 400

    def test_not_a_git_repo_400(self, handler, tmp_path):
        # Project există dar nu e git repo
        _make_ralph_project(tmp_path, "no-git", [])
        handler.handle_ralph_rollback("no-git")
        assert handler.captured_code == 400
        assert "not a git" in handler.captured["message"].lower()

    def test_revert_success_with_story_decrement(self, handler, tmp_path):
        slug = "revert-ok"
        _make_ralph_project(tmp_path, slug, [
            {"id": "US-001", "passes": True, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "first", "priority": 10},
            {"id": "US-002", "passes": True, "failed": False, "blocked": False,
             "retries": 1, "tags": [], "title": "second", "priority": 20},
            {"id": "US-003", "passes": False, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "third", "priority": 30},
        ])
        _init_repo_with_two_commits(tmp_path / slug)
        head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip()

        handler.handle_ralph_rollback(slug)

        assert handler.captured_code == 200, handler.captured
        assert handler.captured["success"] is True
        assert handler.captured["reverted_commit"] == head
        assert handler.captured["method"] == "revert"
        # ultima story trecută (US-002) trebuie marcată incompletă
        assert handler.captured["story_reverted"] == "US-002"

        # Verify atomic write efect: prd.json reflectă passes=False pe US-002
        prd = json.loads(
            (tmp_path / slug / "scripts" / "ralph" / "prd.json").read_text()
        )
        assert prd["userStories"][1]["id"] == "US-002"
        assert prd["userStories"][1]["passes"] is False
        assert prd["userStories"][1]["retries"] == 0
        # US-001 rămâne neatins
        assert prd["userStories"][0]["passes"] is True

        # Verify git history: HEAD should be a new revert commit (not the old HEAD)
        new_head = _git(["rev-parse", "HEAD"], tmp_path / slug).stdout.strip()
        assert new_head != head

    def test_revert_with_no_passing_stories_succeeds_without_decrement(self, handler, tmp_path):
        slug = "no-stories"
        _make_ralph_project(tmp_path, slug, [
            {"id": "US-001", "passes": False, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "a", "priority": 10},
        ])
        _init_repo_with_two_commits(tmp_path / slug)
        handler.handle_ralph_rollback(slug)
        assert handler.captured_code == 200
        assert handler.captured["success"] is True
        # nimic de decrementat → story_reverted=None
        assert handler.captured["story_reverted"] is None

    def test_response_shape_contract(self, handler, tmp_path):
        """Răspunsul trebuie să aibă fix aceste keys ca să meargă în UI."""
        slug = "shape"
        _make_ralph_project(tmp_path, slug, [])
        _init_repo_with_two_commits(tmp_path / slug)
        handler.handle_ralph_rollback(slug)
        for k in ("success", "message", "reverted_commit", "story_reverted"):
            assert k in handler.captured, f"missing key: {k}"

    def test_decrement_helper_atomic_write(self, handler, tmp_path):
        """_ralph_decrement_last_pass: temp file nu trebuie să rămână în filesystem."""
        slug = "atomic"
        ralph_dir = _make_ralph_project(tmp_path, slug, [
            {"id": "US-001", "passes": True, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "x", "priority": 10},
        ])
        result = handler._ralph_decrement_last_pass(tmp_path / slug)
        assert result == "US-001"
        # tmp file curăţat
        assert not (ralph_dir / "prd.json.tmp").exists()
        # passes=False persistat
        prd = json.loads((ralph_dir / "prd.json").read_text())
        assert prd["userStories"][0]["passes"] is False

    def test_decrement_helper_no_passing_returns_none(self, handler, tmp_path):
        slug = "nothing-to-revert"
        _make_ralph_project(tmp_path, slug, [
            {"id": "US-001", "passes": False, "failed": False, "blocked": False,
             "retries": 0, "tags": [], "title": "x", "priority": 10},
        ])
        result = handler._ralph_decrement_last_pass(tmp_path / slug)
        assert result is None


# ── api.py routing ─────────────────────────────────────────────


class TestApiRouting:
    """Smoke test pentru ThreadingHTTPServer + dispatch /api/ralph/stream + rollback."""

    def test_threading_http_server_in_use(self):
        import api  # type: ignore
        # ThreadingHTTPServer este folosit pentru SSE non-blocking
        from http.server import ThreadingHTTPServer
        # Verify import doesn't reference deprecated HTTPServer at module level
        src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
        assert "ThreadingHTTPServer" in src

    def test_stream_route_dispatches_handler(self):
        """/api/ralph/stream trebuie să apeleze handle_ralph_stream."""
        src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
        assert "/api/ralph/stream" in src
        assert "handle_ralph_stream" in src

    def test_rollback_route_dispatches_handler(self):
        src = (PROJECT_ROOT / "dashboard" / "api.py").read_text()
        assert "handle_ralph_rollback" in src
