from pathlib import Path

from goodseed import sync


class _FakeEvent:
    def __init__(self) -> None:
        self._set = False

    def set(self) -> None:
        self._set = True

    def clear(self) -> None:
        self._set = False

    def is_set(self) -> bool:
        return self._set

    def wait(self, timeout: float | None = None) -> bool:
        return self._set


class _FakeThread:
    def __init__(self, target=None, args=None, name=None, daemon=None) -> None:
        self.target = target
        self.args = args or ()
        self.name = name
        self.daemon = daemon
        self.started = False
        self.joined = False
        self.alive = True

    def start(self) -> None:
        self.started = True

    def is_alive(self) -> bool:
        return self.started and self.alive and not self.joined

    def join(self) -> None:
        self.joined = True


class _FakeThreadFactory:
    def __init__(self) -> None:
        self.threads = []

    def Thread(self, target=None, args=None, name=None, daemon=None) -> _FakeThread:
        thread = _FakeThread(target=target, args=args, name=name, daemon=daemon)
        self.threads.append(thread)
        return thread


class _FakeStorage:
    def __init__(self, db_path: Path, counts: list[int]) -> None:
        self.db_path = db_path
        self._counts = list(counts)
        self.closed = False
        self.calls = 0

    def count_unuploaded(self) -> int:
        self.calls += 1
        if self._counts:
            return self._counts.pop(0)
        return 0

    def close(self) -> None:
        self.closed = True


def test_sync_process_builds_thread_worker(monkeypatch, tmp_path):
    fake_factory = _FakeThreadFactory()
    monkeypatch.setattr(sync.threading, "Thread", fake_factory.Thread)

    proc = sync.SyncProcess(
        db_path=Path(tmp_path / "run.sqlite"),
        api_key="api-key",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
        experiment_name="exp",
    )

    assert len(fake_factory.threads) == 1
    thread = fake_factory.threads[0]
    assert thread.target is sync._sync_worker
    assert thread.args[0] == str(tmp_path / "run.sqlite")
    assert thread.args[1] == "api-key"
    assert thread.args[2] == "ws"
    assert thread.args[3] == "proj"
    assert thread.args[4] == "run-1"
    assert thread.args[5] == "exp"
    assert thread.daemon is True
    assert thread.name == "goodseed-sync-run-1"


def test_sync_process_start_sync_close_lifecycle(monkeypatch, tmp_path):
    fake_factory = _FakeThreadFactory()
    monkeypatch.setattr(sync.threading, "Thread", fake_factory.Thread)

    proc = sync.SyncProcess(
        db_path=Path(tmp_path / "run.sqlite"),
        api_key="api-key",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
    )

    # Should not raise before start.
    proc.sync()
    proc.wait(timeout=0.01)
    proc.close()

    thread = fake_factory.threads[0]
    assert thread.started is False
    assert thread.joined is False

    proc.start()
    proc.sync()
    assert proc._flush.is_set() is True
    assert thread.started is True

    proc.close()
    assert thread.joined is True


def test_wait_returns_when_queue_drains(monkeypatch, tmp_path):
    fake_factory = _FakeThreadFactory()
    storage = _FakeStorage(Path(tmp_path / "run.sqlite"), counts=[2, 0])

    monkeypatch.setattr(sync.threading, "Thread", fake_factory.Thread)
    monkeypatch.setattr(sync, "LocalStorage", lambda _: storage)
    monkeypatch.setattr("time.sleep", lambda _: None)

    proc = sync.SyncProcess(
        db_path=Path(tmp_path / "run.sqlite"),
        api_key="api-key",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
    )
    proc.start()
    proc.wait(timeout=1.0)

    assert storage.calls >= 2
    assert storage.closed is True


def test_wait_returns_on_timeout(monkeypatch, tmp_path):
    fake_factory = _FakeThreadFactory()
    storage = _FakeStorage(Path(tmp_path / "run.sqlite"), counts=[10, 10, 10, 10])
    clock = iter([100.0, 100.3, 100.7, 101.2])

    monkeypatch.setattr(sync.threading, "Thread", fake_factory.Thread)
    monkeypatch.setattr(sync, "LocalStorage", lambda _: storage)
    monkeypatch.setattr("time.sleep", lambda _: None)
    monkeypatch.setattr("time.monotonic", lambda: next(clock))

    proc = sync.SyncProcess(
        db_path=Path(tmp_path / "run.sqlite"),
        api_key="api-key",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
    )
    proc.start()
    proc.wait(timeout=0.5)

    assert storage.calls >= 1
    assert storage.closed is True


def test_wait_returns_if_worker_stops(monkeypatch, tmp_path):
    fake_factory = _FakeThreadFactory()
    storage = _FakeStorage(Path(tmp_path / "run.sqlite"), counts=[5, 5])

    monkeypatch.setattr(sync.threading, "Thread", fake_factory.Thread)
    monkeypatch.setattr(sync, "LocalStorage", lambda _: storage)
    monkeypatch.setattr("time.sleep", lambda _: None)

    proc = sync.SyncProcess(
        db_path=Path(tmp_path / "run.sqlite"),
        api_key="api-key",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
    )
    proc.start()
    fake_factory.threads[0].alive = False
    proc.wait(timeout=5.0)

    assert storage.calls == 1
    assert storage.closed is True


def test_sync_worker_drains_on_shutdown_and_sends_status(monkeypatch, tmp_path):
    class WorkerStorage:
        def __init__(self, _db_path: Path):
            self.remaining = [2, 0]
            self.closed = False

        def count_unuploaded(self) -> int:
            return self.remaining.pop(0) if self.remaining else 0

        def get_meta(self, key: str) -> str | None:
            if key == "status":
                return "finished"
            return None

        def close(self) -> None:
            self.closed = True

    calls = {"cycles": 0, "status": None}

    def fake_sync_cycle(storage, api_key, remote_id):
        calls["cycles"] += 1
        return 1

    def fake_send_status(api_key, remote_id, status):
        calls["status"] = (api_key, remote_id, status)

    shutdown = _FakeEvent()
    shutdown.set()
    flush = _FakeEvent()

    monkeypatch.setattr(sync, "LocalStorage", WorkerStorage)
    monkeypatch.setattr(sync, "_ensure_run", lambda *args, **kwargs: "remote-1")
    monkeypatch.setattr(sync, "_sync_cycle", fake_sync_cycle)
    monkeypatch.setattr(sync, "_send_status", fake_send_status)

    sync._sync_worker(
        db_path=str(tmp_path / "run.sqlite"),
        api_key="api-key",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
        experiment_name="exp",
        shutdown_event=shutdown,
        flush_event=flush,
    )

    assert calls["cycles"] >= 1
    assert calls["status"] == ("api-key", "remote-1", "finished")


def test_ensure_run_once_returns_non_retryable_for_already_exists_conflict(monkeypatch):
    monkeypatch.setattr(sync, "API_BASE", "http://api.example")

    def fake_post(*args, **kwargs):
        return 409, b'{"error_code":"Run.AlreadyExists","detail":"already exists"}'

    monkeypatch.setattr(sync, "_api_post", fake_post)

    remote_id, error_message, retryable = sync._ensure_run_once(
        api_key="k",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
        experiment_name="name",
        log_errors=False,
    )

    assert remote_id is None
    assert "already exists" in (error_message or "")
    assert retryable is False


def test_ensure_run_once_does_not_mask_non_idempotent_conflict(monkeypatch):
    monkeypatch.setattr(sync, "API_BASE", "http://api.example")

    def fake_post(*args, **kwargs):
        return 409, b'{"error_code":"Conflict.Other","detail":"quota conflict"}'

    called = {"list_runs": False}

    def fake_get_json(path, *, api_key, params=None):
        called["list_runs"] = True
        return 200, [{"id": "uuid-1", "run_id": "run-1"}]

    monkeypatch.setattr(sync, "_api_post", fake_post)
    monkeypatch.setattr(sync, "api_get_json", fake_get_json)

    remote_id, error_message, retryable = sync._ensure_run_once(
        api_key="k",
        workspace="ws",
        project_name="proj",
        run_id="run-1",
        experiment_name="name",
        log_errors=False,
    )

    assert remote_id is None
    assert retryable is False
    assert called["list_runs"] is False
    assert "quota conflict" in (error_message or "")
