"""Tests for PostHog identity stitching: anon → email alias on MemoryClient init.

Covers the four matrix cases (OSS-only, CLI-only, both, already-aliased) plus
failure modes: missing config, malformed JSON, read-only filesystem,
broken posthog client. Telemetry must never raise.
"""

import importlib
import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest


@pytest.fixture
def tmp_dejavu_dir(tmp_path, monkeypatch):
    """Point the dejavu setup module at a tempdir for the duration of the test."""
    monkeypatch.setenv("DEJAVU_DIR", str(tmp_path))
    # Reload setup so module-level dejavu_dir picks up the env var.
    import dejavu.memory.setup as setup_module

    importlib.reload(setup_module)
    yield tmp_path
    # Restore default state.
    monkeypatch.delenv("DEJAVU_DIR", raising=False)
    importlib.reload(setup_module)


def _write_config(tmp_path: Path, payload: dict) -> Path:
    config_path = tmp_path / "config.json"
    config_path.write_text(json.dumps(payload))
    return config_path


# ─── setup_config idempotency ────────────────────────────────────────────────


class TestSetupConfigIdempotent:
    def test_creates_config_when_missing(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        setup_module.setup_config()
        config = json.loads((tmp_dejavu_dir / "config.json").read_text())
        assert "user_id" in config and config["user_id"]

    def test_backfills_user_id_when_only_telemetry_present(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(
            tmp_dejavu_dir,
            {"telemetry": {"anonymous_id": "cli-anon-abc123"}},
        )
        setup_module.setup_config()
        config = json.loads((tmp_dejavu_dir / "config.json").read_text())
        assert config.get("user_id"), "user_id must be backfilled for CLI-first users"
        assert config["telemetry"]["anonymous_id"] == "cli-anon-abc123"

    def test_does_not_overwrite_existing_user_id(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(tmp_dejavu_dir, {"user_id": "existing-uuid"})
        setup_module.setup_config()
        config = json.loads((tmp_dejavu_dir / "config.json").read_text())
        assert config["user_id"] == "existing-uuid"

    def test_handles_malformed_json(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        (tmp_dejavu_dir / "config.json").write_text("{not json")
        setup_module.setup_config()  # must not raise
        config = json.loads((tmp_dejavu_dir / "config.json").read_text())
        assert "user_id" in config


# ─── read_anon_ids ───────────────────────────────────────────────────────────


class TestReadAnonIds:
    def test_returns_oss_only(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(tmp_dejavu_dir, {"user_id": "oss-uuid"})
        anon = setup_module.read_anon_ids()
        assert anon == {"oss": "oss-uuid", "cli": None, "aliased_pairs": []}

    def test_returns_cli_only(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(
            tmp_dejavu_dir,
            {"telemetry": {"anonymous_id": "cli-anon-123"}},
        )
        anon = setup_module.read_anon_ids()
        assert anon == {"oss": None, "cli": "cli-anon-123", "aliased_pairs": []}

    def test_returns_both(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(
            tmp_dejavu_dir,
            {
                "user_id": "oss-uuid",
                "telemetry": {"anonymous_id": "cli-anon-123", "aliased_pairs": ["pair-marker"]},
            },
        )
        anon = setup_module.read_anon_ids()
        assert anon == {
            "oss": "oss-uuid",
            "cli": "cli-anon-123",
            "aliased_pairs": ["pair-marker"],
        }

    def test_no_config_returns_all_none(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        anon = setup_module.read_anon_ids()
        assert anon == {"oss": None, "cli": None, "aliased_pairs": []}

    def test_malformed_json_does_not_raise(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        (tmp_dejavu_dir / "config.json").write_text("{not json")
        anon = setup_module.read_anon_ids()
        assert anon == {"oss": None, "cli": None, "aliased_pairs": []}


# ─── mark_aliased ────────────────────────────────────────────────────────────


class TestMarkAliased:
    def test_writes_aliased_pair_preserving_other_fields(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(
            tmp_dejavu_dir,
            {
                "user_id": "oss-uuid",
                "telemetry": {"anonymous_id": "cli-anon-123"},
            },
        )
        setup_module.mark_aliased("oss-uuid", "user@example.com")
        config = json.loads((tmp_dejavu_dir / "config.json").read_text())
        assert config["user_id"] == "oss-uuid"
        assert config["telemetry"]["anonymous_id"] == "cli-anon-123"
        assert len(config["telemetry"]["aliased_pairs"]) == 1
        assert setup_module.is_aliased("oss-uuid", "user@example.com")

    def test_creates_telemetry_section_when_missing(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(tmp_dejavu_dir, {"user_id": "oss-uuid"})
        setup_module.mark_aliased("oss-uuid", "user@example.com")
        config = json.loads((tmp_dejavu_dir / "config.json").read_text())
        assert len(config["telemetry"]["aliased_pairs"]) == 1

    def test_tracks_each_pair_independently(self, tmp_dejavu_dir):
        import dejavu.memory.setup as setup_module

        _write_config(tmp_dejavu_dir, {"user_id": "oss-uuid"})
        setup_module.mark_aliased("oss-uuid", "user@example.com")
        assert setup_module.is_aliased("oss-uuid", "user@example.com")
        assert not setup_module.is_aliased("new-uuid", "user@example.com")
        assert not setup_module.is_aliased("oss-uuid", "other@example.com")


# ─── capture_identify ────────────────────────────────────────────────────────


class TestCaptureIdentify:
    def test_fires_identify_with_anon_distinct_id(self):
        import dejavu.memory.telemetry as telemetry_module

        with patch.object(telemetry_module, "DEJAVU_TELEMETRY", True):
            with patch("dejavu.memory.telemetry.Posthog") as mock_posthog_cls:
                at = telemetry_module.AnonymousTelemetry()
                at.capture_identify("anon-123", "user@example.com")
                mock_ph = mock_posthog_cls.return_value
                mock_ph.capture.assert_called_once()
                _, kwargs = mock_ph.capture.call_args
                assert kwargs["distinct_id"] == "user@example.com"
                assert kwargs["event"] == "$identify"
                assert kwargs["properties"]["$anon_distinct_id"] == "anon-123"

    def test_skips_when_anon_equals_email(self):
        import dejavu.memory.telemetry as telemetry_module

        with patch.object(telemetry_module, "DEJAVU_TELEMETRY", True):
            with patch("dejavu.memory.telemetry.Posthog") as mock_posthog_cls:
                at = telemetry_module.AnonymousTelemetry()
                at.capture_identify("user@example.com", "user@example.com")
                mock_posthog_cls.return_value.capture.assert_not_called()

    def test_skips_when_inputs_empty(self):
        import dejavu.memory.telemetry as telemetry_module

        with patch.object(telemetry_module, "DEJAVU_TELEMETRY", True):
            with patch("dejavu.memory.telemetry.Posthog") as mock_posthog_cls:
                at = telemetry_module.AnonymousTelemetry()
                at.capture_identify("", "user@example.com")
                at.capture_identify("anon-123", "")
                mock_posthog_cls.return_value.capture.assert_not_called()

    def test_noop_when_telemetry_disabled(self):
        import dejavu.memory.telemetry as telemetry_module

        with patch.object(telemetry_module, "DEJAVU_TELEMETRY", False):
            at = telemetry_module.AnonymousTelemetry()
            at.capture_identify("anon-123", "user@example.com")  # must not raise
            assert at.posthog is None

    def test_does_not_raise_on_posthog_error(self):
        import dejavu.memory.telemetry as telemetry_module

        with patch.object(telemetry_module, "DEJAVU_TELEMETRY", True):
            with patch("dejavu.memory.telemetry.Posthog") as mock_posthog_cls:
                mock_posthog_cls.return_value.capture.side_effect = RuntimeError("boom")
                at = telemetry_module.AnonymousTelemetry()
                at.capture_identify("anon-123", "user@example.com")  # must not raise

    def test_identify_is_in_lifecycle_events(self):
        """$identify must bypass the 90% sampling drop."""
        import dejavu.memory.telemetry as telemetry_module

        assert "$identify" in telemetry_module._LIFECYCLE_EVENTS


# ─── _maybe_alias_anon_to_email integration ──────────────────────────────────


class TestMaybeAliasAnonToEmail:
    """Test the alias helper in isolation by mocking out the config readers
    and the telemetry client, since module-level setup_config() side effects
    make end-to-end fixturing awkward."""

    def test_fires_identify_for_oss_uuid(self):
        from dejavu.client import main as client_main

        with (
            patch.object(
                client_main,
                "read_anon_ids",
                return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
            ),
            patch.object(client_main, "is_aliased", return_value=False),
            patch.object(client_main, "mark_aliased") as mark,
            patch.object(client_main, "client_telemetry") as telemetry,
        ):
            telemetry.capture_identify.return_value = True
            client_main._maybe_alias_anon_to_email("user@example.com")
            telemetry.capture_identify.assert_called_once_with("oss-uuid", "user@example.com")
            mark.assert_called_once_with("oss-uuid", "user@example.com")

    def test_fires_identify_for_cli_anon(self):
        from dejavu.client import main as client_main

        with (
            patch.object(
                client_main,
                "read_anon_ids",
                return_value={"oss": None, "cli": "cli-anon-xyz", "aliased_pairs": []},
            ),
            patch.object(client_main, "is_aliased", return_value=False),
            patch.object(client_main, "mark_aliased"),
            patch.object(client_main, "client_telemetry") as telemetry,
        ):
            telemetry.capture_identify.return_value = True
            client_main._maybe_alias_anon_to_email("user@example.com")
            telemetry.capture_identify.assert_called_once_with("cli-anon-xyz", "user@example.com")

    def test_fires_identify_for_both_anon_ids(self):
        from dejavu.client import main as client_main

        with (
            patch.object(
                client_main,
                "read_anon_ids",
                return_value={"oss": "oss-uuid", "cli": "cli-anon", "aliased_pairs": []},
            ),
            patch.object(client_main, "is_aliased", return_value=False),
            patch.object(client_main, "mark_aliased"),
            patch.object(client_main, "client_telemetry") as telemetry,
        ):
            telemetry.capture_identify.return_value = True
            client_main._maybe_alias_anon_to_email("user@example.com")
            assert telemetry.capture_identify.call_count == 2
            calls = {c.args for c in telemetry.capture_identify.call_args_list}
            assert ("oss-uuid", "user@example.com") in calls
            assert ("cli-anon", "user@example.com") in calls

    def test_skips_when_pair_already_aliased(self):
        from dejavu.client import main as client_main

        with (
            patch.object(
                client_main,
                "read_anon_ids",
                return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": ["pair-marker"]},
            ),
            patch.object(client_main, "is_aliased", return_value=True),
            patch.object(client_main, "mark_aliased") as mark,
            patch.object(client_main, "client_telemetry") as telemetry,
        ):
            client_main._maybe_alias_anon_to_email("user@example.com")
            telemetry.capture_identify.assert_not_called()
            mark.assert_not_called()

    def test_skips_when_email_invalid(self):
        from dejavu.client import main as client_main

        with patch.object(client_main, "client_telemetry") as telemetry:
            client_main._maybe_alias_anon_to_email(None)
            client_main._maybe_alias_anon_to_email("")
            client_main._maybe_alias_anon_to_email("not-an-email")
            telemetry.capture_identify.assert_not_called()

    def test_skips_when_telemetry_disabled(self):
        """When client_telemetry.posthog is None (DEJAVU_TELEMETRY=false), do nothing —
        no fs read, no fs write, no event. Re-enabling telemetry later must still alias."""
        from dejavu.client import main as client_main

        disabled = MagicMock()
        disabled.posthog = None
        with (
            patch.object(client_main, "client_telemetry", disabled),
            patch.object(client_main, "read_anon_ids") as read,
            patch.object(client_main, "mark_aliased") as mark,
        ):
            client_main._maybe_alias_anon_to_email("user@example.com")
            read.assert_not_called()
            mark.assert_not_called()
            disabled.capture_identify.assert_not_called()

    def test_does_not_raise_on_telemetry_failure(self):
        from dejavu.client import main as client_main

        mock_telemetry = MagicMock()
        mock_telemetry.capture_identify.side_effect = RuntimeError("boom")
        with (
            patch.object(
                client_main,
                "read_anon_ids",
                return_value={"oss": "oss-uuid", "cli": None, "aliased_pairs": []},
            ),
            patch.object(client_main, "is_aliased", return_value=False),
            patch.object(client_main, "mark_aliased") as mark,
            patch.object(client_main, "client_telemetry", mock_telemetry),
        ):
            client_main._maybe_alias_anon_to_email("user@example.com")  # must not raise
            mark.assert_not_called()

    def test_skips_anon_id_equal_to_email(self):
        """Defensive: if the anon_id somehow already is the email, don't self-alias."""
        from dejavu.client import main as client_main

        with (
            patch.object(
                client_main,
                "read_anon_ids",
                return_value={"oss": "user@example.com", "cli": None, "aliased_pairs": []},
            ),
            patch.object(client_main, "is_aliased", return_value=False),
            patch.object(client_main, "mark_aliased"),
            patch.object(client_main, "client_telemetry") as telemetry,
        ):
            client_main._maybe_alias_anon_to_email("user@example.com")
            telemetry.capture_identify.assert_not_called()

    def test_does_not_raise_on_read_failure(self):
        """If read_anon_ids itself raises (e.g. IO error), helper must swallow it."""
        from dejavu.client import main as client_main

        with (
            patch.object(client_main, "read_anon_ids", side_effect=OSError("fs broken")),
            patch.object(client_main, "client_telemetry") as telemetry,
        ):
            client_main._maybe_alias_anon_to_email("user@example.com")  # must not raise
            telemetry.capture_identify.assert_not_called()


# ─── End-to-end idempotency through real config ──────────────────────────────


class TestEndToEndIdempotency:
    """Verify the real config flow: two consecutive _maybe_alias_anon_to_email
    calls fire $identify exactly once thanks to the persisted pair marker."""

    def test_second_call_is_noop_after_pair_marker_persisted(self, tmp_dejavu_dir):
        # Pre-populate config with an OSS user_id only.
        _write_config(tmp_dejavu_dir, {"user_id": "oss-uuid"})
        # Reload setup so it uses the tempdir, then reload client.main so it
        # picks up the freshly-loaded read_anon_ids/mark_aliased bindings.
        import dejavu.memory.setup as setup_module

        importlib.reload(setup_module)
        from dejavu.client import main as client_main

        importlib.reload(client_main)

        with patch.object(client_main, "client_telemetry") as telemetry:
            telemetry.capture_identify.return_value = True
            client_main._maybe_alias_anon_to_email("user@example.com")
            first_call_count = telemetry.capture_identify.call_count
            assert first_call_count >= 1

            # Second call should hit the aliased_pairs short-circuit.
            client_main._maybe_alias_anon_to_email("user@example.com")
            assert telemetry.capture_identify.call_count == first_call_count

        config = json.loads((tmp_dejavu_dir / "config.json").read_text())
        assert len(config["telemetry"]["aliased_pairs"]) == 1
