"""ACL gating and field handling for services/design_systems.py (unit, mocked).

Mirrors tests/test_services_systems.py — same mocked-session shape, because the
question is the same one: does the service refuse before it touches a row?
"""
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from scribe.services.design_systems import DesignSystemCycle
from tests.helpers import make_mock_session


# --- creating ---------------------------------------------------------------

@pytest.mark.asyncio
async def test_create_with_a_parent_is_denied_without_write_on_that_parent():
    """Parenting to someone else's system would let their delete or re-parent
    silently restyle your app, so the chain may only be built from systems you
    can write — which, per the ACL, means ones you own."""
    with patch("scribe.services.design_systems.access") as acc:
        acc.can_write_design_system = AsyncMock(return_value=False)
        from scribe.services.design_systems import create_design_system
        result = await create_design_system(user_id=1, title="Scribe", parent_id=7)
    assert result is None


@pytest.mark.asyncio
async def test_create_without_a_parent_never_consults_the_parent_acl():
    """A family system has no parent, and creating one must not be gated on a
    permission check for a system that does not exist."""
    mock_session = make_mock_session()
    captured = {}
    mock_session.add = MagicMock(
        side_effect=lambda obj: captured.update(
            title=obj.title, parent_id=obj.parent_id
        )
    )
    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc:
        acc.can_write_design_system = AsyncMock(return_value=False)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import create_design_system
        await create_design_system(user_id=1, title="  FabledSword  ")
    assert captured["title"] == "FabledSword"   # stripped
    assert captured["parent_id"] is None
    acc.can_write_design_system.assert_not_awaited()


# --- the cycle guard, through the service -----------------------------------

@pytest.mark.asyncio
async def test_reparenting_into_a_loop_raises_rather_than_returning_none():
    """None already means "not found, or not yours". A caller that conflated the
    two would report "no such design system" for what is really "that parent is
    one of its own descendants", so the cycle gets its own exception type."""
    mock_session = make_mock_session()
    mock_session.get = AsyncMock(return_value=MagicMock(deleted_at=None, parent_id=None))

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc, \
         patch("scribe.services.design_systems._parent_map",
               AsyncMock(return_value={1: None, 2: 1})):
        acc.can_write_design_system = AsyncMock(return_value=True)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import update_design_system
        with pytest.raises(DesignSystemCycle):
            await update_design_system(user_id=1, design_system_id=1, parent_id=2)

    mock_session.commit.assert_not_awaited()  # refused BEFORE the write


@pytest.mark.asyncio
async def test_clearing_the_parent_is_allowed_and_is_not_read_as_no_change():
    """`parent_id=None` means "make this a root" — the one field where None is a
    value rather than "leave alone", which is why it is handled apart from the
    others."""
    system = MagicMock(deleted_at=None, parent_id=5)
    mock_session = make_mock_session()
    mock_session.get = AsyncMock(return_value=system)

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc, \
         patch("scribe.services.design_systems._parent_map",
               AsyncMock(return_value={1: 5, 5: None})):
        acc.can_write_design_system = AsyncMock(return_value=True)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import update_design_system
        await update_design_system(user_id=1, design_system_id=1, parent_id=None)

    assert system.parent_id is None


# --- tokens -----------------------------------------------------------------

@pytest.mark.asyncio
async def test_create_token_denied_without_write_on_the_system():
    with patch("scribe.services.design_systems.access") as acc:
        acc.can_write_design_system = AsyncMock(return_value=False)
        from scribe.services.design_systems import create_token
        result = await create_token(user_id=1, design_system_id=3, name="--fs-obsidian")
    assert result is None


@pytest.mark.asyncio
async def test_token_values_default_to_an_empty_map_not_json_null():
    """The column is NOT NULL so that absence has exactly ONE spelling. Passing
    None straight through would store JSON null and hand every reader back the
    second empty state the schema was shaped to remove."""
    mock_session = make_mock_session()
    captured = {}
    mock_session.add = MagicMock(
        side_effect=lambda obj: captured.update(value_by_mode=obj.value_by_mode)
    )

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc:
        acc.can_write_design_system = AsyncMock(return_value=True)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import create_token
        await create_token(
            user_id=1, design_system_id=3, name="--fs-radius-md", value_by_mode=None
        )

    assert captured["value_by_mode"] == {}


@pytest.mark.asyncio
async def test_list_tokens_denied_returns_empty():
    with patch("scribe.services.design_systems.access") as acc:
        acc.can_read_design_system = AsyncMock(return_value=False)
        from scribe.services.design_systems import list_tokens
        assert await list_tokens(user_id=1, design_system_id=3) == []


# --- the project pointer ----------------------------------------------------

@pytest.mark.asyncio
async def test_pointing_a_project_at_a_system_needs_write_on_the_project():
    with patch("scribe.services.design_systems.access") as acc:
        acc.can_write_project = AsyncMock(return_value=False)
        acc.can_read_design_system = AsyncMock(return_value=True)
        from scribe.services.design_systems import set_project_design_system
        assert await set_project_design_system(1, project_id=5, design_system_id=3) is False


@pytest.mark.asyncio
async def test_pointing_a_project_needs_only_READ_on_the_system():
    """Consuming a design system is not changing it, so a system reachable
    through another project is a legitimate choice here. Requiring write would
    make a shared family style unusable by the people it was shared with."""
    project = MagicMock(deleted_at=None, design_system_id=None)
    mock_session = make_mock_session()
    mock_session.get = AsyncMock(return_value=project)

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc:
        acc.can_write_project = AsyncMock(return_value=True)
        acc.can_read_design_system = AsyncMock(return_value=True)
        acc.can_write_design_system = AsyncMock(return_value=False)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import set_project_design_system
        assert await set_project_design_system(1, project_id=5, design_system_id=3) is True

    assert project.design_system_id == 3


@pytest.mark.asyncio
async def test_clearing_a_projects_design_system_skips_the_system_acl():
    """Un-styling a project must not require permission on the system it is
    letting go of — including one that has since been deleted."""
    project = MagicMock(deleted_at=None, design_system_id=3)
    mock_session = make_mock_session()
    mock_session.get = AsyncMock(return_value=project)

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc:
        acc.can_write_project = AsyncMock(return_value=True)
        acc.can_read_design_system = AsyncMock(return_value=False)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import set_project_design_system
        assert await set_project_design_system(1, project_id=5, design_system_id=None) is True

    assert project.design_system_id is None
    acc.can_read_design_system.assert_not_awaited()


# --- resolution (step 2) ----------------------------------------------------

@pytest.mark.asyncio
async def test_resolve_denied_returns_none_not_an_empty_set():
    """None and [] mean different things here: "you may not see this" versus
    "this chain genuinely holds no tokens". A caller that got [] for a denial
    would render an empty design system as though it were real."""
    with patch("scribe.services.design_systems.access") as acc:
        acc.can_read_design_system = AsyncMock(return_value=False)
        from scribe.services.design_systems import resolve_design_system
        assert await resolve_design_system(user_id=1, design_system_id=3) is None


@pytest.mark.asyncio
async def test_resolve_scopes_the_hierarchy_to_the_systems_OWNER_not_the_caller():
    """LOAD-BEARING for shared projects. A caller reading through a project
    shared with them owns no link in the chain, so a caller-scoped parent map
    returns an empty forest and the cascade truncates to one system — a page
    that renders with plausible wrong values and no error anywhere.

    The ACL already grants read on the whole chain (reachability propagates
    upward); this is the loading side keeping that promise.
    """
    owner, caller = 42, 7
    system = MagicMock(deleted_at=None, owner_user_id=owner)
    mock_session = make_mock_session()
    mock_session.get = AsyncMock(return_value=system)
    mock_session.execute = AsyncMock(
        return_value=MagicMock(scalars=MagicMock(return_value=MagicMock(all=lambda: [])))
    )
    parent_map = AsyncMock(return_value={3: None})

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems._parent_map", parent_map), \
         patch("scribe.services.design_systems.access") as acc:
        acc.can_read_design_system = AsyncMock(return_value=True)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import resolve_design_system
        result = await resolve_design_system(user_id=caller, design_system_id=3)

    assert result == []                                   # empty chain, not None
    assert parent_map.await_args.args[1] == owner         # NOT `caller`


# --- supersedes (step 6's reframe) ------------------------------------------

@pytest.mark.asyncio
async def test_create_token_supersedes_defaults_to_an_empty_list_not_json_null():
    """Same NOT NULL reasoning as value_by_mode: absence gets one spelling."""
    mock_session = make_mock_session()
    captured = {}
    mock_session.add = MagicMock(
        side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
    )
    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc:
        acc.can_write_design_system = AsyncMock(return_value=True)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import create_token
        await create_token(user_id=1, design_system_id=3, name="--fs-x", supersedes=None)
    assert captured["supersedes"] == []


@pytest.mark.asyncio
async def test_create_token_records_the_literals_it_replaces():
    mock_session = make_mock_session()
    captured = {}
    mock_session.add = MagicMock(
        side_effect=lambda obj: captured.update(supersedes=obj.supersedes)
    )
    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems.access") as acc:
        acc.can_write_design_system = AsyncMock(return_value=True)
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import create_token
        await create_token(
            user_id=1, design_system_id=3, name="--fs-text-on-action",
            value_by_mode={"base": "#e8e4d8"}, supersedes=["#fff", "#ffffff"],
        )
    assert captured["supersedes"] == ["#fff", "#ffffff"]


# --- the master sheet -------------------------------------------------------

@pytest.mark.asyncio
async def test_stylesheet_denied_returns_none():
    with patch("scribe.services.design_systems.resolve_design_system",
               AsyncMock(return_value=None)):
        from scribe.services.design_systems import stylesheet_for_system
        assert await stylesheet_for_system(1, 3) is None


@pytest.mark.asyncio
async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself():
    """The CSS alone hides two things a reviewer needs: which tokens are still
    valueless, and which values are declared twice. Both ride alongside rather
    than being left for the reader to derive from the text."""
    from scribe.services.design_cascade import Contribution, ResolvedToken

    def _resolved(name, base=None):
        return ResolvedToken(
            name=name,
            contributions=(
                {"base": (Contribution(system_id=1, value=base),)} if base else {}
            ),
            group_name=None, purpose=None, rationale=None,
            supersedes=(), order_index=0,
        )

    system = MagicMock()
    system.title = "FabledSword"
    with patch("scribe.services.design_systems.resolve_design_system",
               AsyncMock(return_value=[
                   _resolved("--fs-moss", "#4a5d3f"),
                   _resolved("--fs-success", "#4a5d3f"),
                   _resolved("--fs-radius-sm"),
               ])), \
         patch("scribe.services.design_systems.get_design_system",
               AsyncMock(return_value=system)):
        from scribe.services.design_systems import stylesheet_for_system
        result = await stylesheet_for_system(1, 3)

    assert result["token_count"] == 3
    assert result["valueless"] == ["--fs-radius-sm"]
    assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
    assert "--fs-moss: #4a5d3f;" in result["css"]
    assert "FabledSword" in result["css"]


# --- design_context: the delivery side --------------------------------------

def _ctx_token(name: str, group: str | None):
    from scribe.services.design_cascade import ResolvedToken
    return ResolvedToken(
        name=name, contributions={}, group_name=group,
        purpose=None, rationale=None, supersedes=(), order_index=0,
    )


@pytest.mark.asyncio
async def test_design_context_merges_guidance_ANCESTOR_FIRST():
    """LOAD-BEARING. A child system holds only what it CHANGES, so its own
    guidance describes a departure from a house style it never restates. An
    agent handed the leaf alone builds against a fragment with no signal that
    the rest exists — which is exactly the failure retiring the design rulebook
    would otherwise have caused.
    """
    family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
                       description="the house", guidance="Dark-mode-first.")
    family.title = "House"
    app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
                    description="one app", guidance="Accent on the wordmark.")
    app.title = "App"

    mock_session = make_mock_session()
    mock_session.get = AsyncMock(return_value=app)
    mock_session.execute = AsyncMock(return_value=MagicMock(
        scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
    ))

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems._parent_map",
               AsyncMock(return_value={3: 1, 1: None})), \
         patch("scribe.services.design_systems.resolve_design_system",
               AsyncMock(return_value=[
                   _ctx_token("--a", "surface"), _ctx_token("--b", "type"),
                   _ctx_token("--c", "surface"), _ctx_token("--d", None),
               ])):
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import design_context
        out = await design_context(user_id=42, design_system_id=3)

    assert [g["title"] for g in out["guidance"]] == ["House", "App"]
    assert out["inherits_from"] == ["House"]
    assert out["token_count"] == 4
    # Group names deduped and sorted; an ungrouped token contributes nothing.
    assert out["token_groups"] == ["surface", "type"]


@pytest.mark.asyncio
async def test_design_context_denied_returns_none():
    """It rides on resolve_design_system's ACL rather than re-deriving one —
    a second permission path is a second thing to get wrong."""
    with patch("scribe.services.design_systems.resolve_design_system",
               AsyncMock(return_value=None)):
        from scribe.services.design_systems import design_context
        assert await design_context(user_id=1, design_system_id=3) is None


@pytest.mark.asyncio
async def test_design_context_omits_systems_with_no_guidance():
    """A system that overrides one token and says nothing about it should not
    contribute an empty section — a heading with nothing under it reads as
    missing content rather than as an absence of content."""
    family = MagicMock(id=1, deleted_at=None, owner_user_id=42,
                       description="", guidance="Dark-mode-first.")
    family.title = "House"
    app = MagicMock(id=3, deleted_at=None, owner_user_id=42,
                    description="", guidance="   ")
    app.title = "App"

    mock_session = make_mock_session()
    mock_session.get = AsyncMock(return_value=app)
    mock_session.execute = AsyncMock(return_value=MagicMock(
        scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family]))
    ))

    with patch("scribe.services.design_systems.async_session") as mock_cls, \
         patch("scribe.services.design_systems._parent_map",
               AsyncMock(return_value={3: 1, 1: None})), \
         patch("scribe.services.design_systems.resolve_design_system",
               AsyncMock(return_value=[])):
        mock_cls.return_value = mock_session
        from scribe.services.design_systems import design_context
        out = await design_context(user_id=42, design_system_id=3)

    assert [g["title"] for g in out["guidance"]] == ["House"]
    assert out["token_count"] == 0
