import pytest

from nextcloud_mcp_server.models.deck import (
    DeckACL,
    DeckBoard,
    DeckCard,
    DeckLabel,
    DeckPermissions,
    DeckStack,
    DeckUser,
)
from nextcloud_mcp_server.server.deck import (
    _apply_board_filters,
    _apply_card_filters,
    _apply_stack_filters,
    _truncate_card_descriptions,
    _validate_description_max_length,
)

pytestmark = pytest.mark.unit


# Fixtures ------------------------------------------------------------------


def _make_card(
    card_id: int,
    description: str | None = "desc",
    archived: bool = False,
) -> DeckCard:
    return DeckCard(
        id=card_id,
        title=f"Card {card_id}",
        stackId=1,
        type="plain",
        order=card_id,
        archived=archived,
        owner="testuser",
        description=description,
    )


def _make_user(uid: str = "testuser") -> DeckUser:
    return DeckUser(primaryKey=uid, uid=uid, displayname=uid)


def _make_board(
    board_id: int = 1,
    *,
    labels: list[DeckLabel] | None = None,
    acl: list[DeckACL] | None = None,
    users: list[DeckUser] | None = None,
) -> DeckBoard:
    return DeckBoard(
        id=board_id,
        title=f"Board {board_id}",
        owner=_make_user(),
        color="FF0000",
        archived=False,
        labels=labels
        if labels is not None
        else [DeckLabel(id=1, title="L1", color="00FF00")],
        acl=acl if acl is not None else [],
        permissions=DeckPermissions(
            PERMISSION_READ=True,
            PERMISSION_EDIT=True,
            PERMISSION_MANAGE=True,
            PERMISSION_SHARE=True,
        ),
        users=users if users is not None else [_make_user("alice"), _make_user("bob")],
        deletedAt=0,
    )


def _make_stack(
    stack_id: int = 1,
    *,
    cards: list[DeckCard] | None = None,
) -> DeckStack:
    return DeckStack(
        id=stack_id,
        title=f"Stack {stack_id}",
        boardId=1,
        order=stack_id,
        deletedAt=0,
        cards=cards,
    )


# _truncate_card_descriptions ----------------------------------------------


def test_truncate_card_descriptions_no_op_when_limit_is_none():
    """When description_max_length is None, descriptions are left untouched."""
    cards = [_make_card(1, "x" * 5000)]
    _truncate_card_descriptions(cards, None)
    assert cards[0].description is not None
    assert len(cards[0].description) == 5000


def test_truncate_card_descriptions_truncates_long_descriptions():
    """Descriptions over the limit are truncated and marked with an ellipsis."""
    cards = [_make_card(1, "x" * 5000), _make_card(2, "short")]
    _truncate_card_descriptions(cards, 100)
    assert cards[0].description is not None
    assert len(cards[0].description) == 101  # 100 chars + ellipsis
    assert cards[0].description.endswith("…")
    assert cards[1].description == "short"


def test_truncate_card_descriptions_handles_none_description():
    """Cards with no description are skipped without error."""
    cards = [_make_card(1, None)]
    _truncate_card_descriptions(cards, 100)
    assert cards[0].description is None


def test_truncate_card_descriptions_at_exact_boundary():
    """Descriptions at exactly the limit should not be truncated."""
    cards = [_make_card(1, "x" * 100)]
    _truncate_card_descriptions(cards, 100)
    assert cards[0].description == "x" * 100


def test_truncate_card_descriptions_one_over_limit():
    """A description one character over the limit triggers truncation."""
    cards = [_make_card(1, "x" * 101)]
    _truncate_card_descriptions(cards, 100)
    assert cards[0].description is not None
    assert len(cards[0].description) == 101  # 100 chars + ellipsis
    assert cards[0].description.endswith("…")


def test_truncate_card_descriptions_shorter_than_limit_no_ellipsis():
    """A description shorter than the limit must not have an ellipsis appended."""
    cards = [_make_card(1, "hello")]
    _truncate_card_descriptions(cards, 1000)
    assert cards[0].description == "hello"


# _validate_description_max_length ----------------------------------------


def test_validate_description_max_length_accepts_none():
    """None is the documented sentinel for "no truncation"."""
    _validate_description_max_length(None)


def test_validate_description_max_length_accepts_positive():
    """Positive values pass through silently."""
    _validate_description_max_length(1)
    _validate_description_max_length(1000)


def test_validate_description_max_length_rejects_zero():
    """Zero would wipe descriptions to a single ellipsis — reject at the boundary."""
    with pytest.raises(ValueError, match="must be positive"):
        _validate_description_max_length(0)


def test_validate_description_max_length_rejects_negative():
    """Negative values produce surprising slice semantics — reject at the boundary."""
    with pytest.raises(ValueError, match="must be positive"):
        _validate_description_max_length(-10)


# _apply_board_filters ------------------------------------------------------


def test_apply_board_filters_defaults_preserve_fields():
    """With all include_* flags True, no fields are cleared."""
    board = _make_board()
    result = _apply_board_filters(
        board, include_acl=True, include_users=True, include_labels=True
    )
    assert len(result.labels) == 1
    assert len(result.users) == 2


def test_apply_board_filters_excludes_acl():
    """include_acl=False clears the acl list."""
    board = _make_board(
        acl=[
            DeckACL(
                id=1,
                participant=_make_user("alice"),
                type=0,
                boardId=1,
                permissionEdit=True,
                permissionShare=True,
                permissionManage=False,
                owner=False,
            )
        ]
    )
    result = _apply_board_filters(
        board, include_acl=False, include_users=True, include_labels=True
    )
    assert result.acl == []


def test_apply_board_filters_excludes_users():
    """include_users=False clears the users list."""
    board = _make_board()
    result = _apply_board_filters(
        board, include_acl=True, include_users=False, include_labels=True
    )
    assert result.users == []


def test_apply_board_filters_excludes_labels():
    """include_labels=False clears the labels list."""
    board = _make_board()
    result = _apply_board_filters(
        board, include_acl=True, include_users=True, include_labels=False
    )
    assert result.labels == []


def test_apply_board_filters_excludes_all():
    """All include_* flags False clears every filterable list."""
    board = _make_board()
    result = _apply_board_filters(
        board, include_acl=False, include_users=False, include_labels=False
    )
    assert result.acl == []
    assert result.users == []
    assert result.labels == []


# _apply_stack_filters ------------------------------------------------------


def test_apply_stack_filters_include_cards_false_strips_cards():
    """include_cards=False sets cards to None regardless of other flags."""
    stack = _make_stack(cards=[_make_card(1), _make_card(2, archived=True)])
    result = _apply_stack_filters(
        stack,
        include_cards=False,
        include_archived_cards=True,
        description_max_length=None,
    )
    assert result.cards is None


def test_apply_stack_filters_excludes_archived_by_default():
    """include_archived_cards=False filters out archived cards."""
    stack = _make_stack(
        cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
    )
    result = _apply_stack_filters(
        stack,
        include_cards=True,
        include_archived_cards=False,
        description_max_length=None,
    )
    assert result.cards is not None
    assert [c.id for c in result.cards] == [1]


def test_apply_stack_filters_keeps_archived_when_requested():
    """include_archived_cards=True retains archived cards."""
    stack = _make_stack(
        cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
    )
    result = _apply_stack_filters(
        stack,
        include_cards=True,
        include_archived_cards=True,
        description_max_length=None,
    )
    assert result.cards is not None
    assert [c.id for c in result.cards] == [1, 2]


def test_apply_stack_filters_truncates_descriptions_after_archive_filter():
    """Truncation runs on the post-archive-filter card set."""
    stack = _make_stack(
        cards=[
            _make_card(1, description="x" * 50, archived=False),
            _make_card(2, description="y" * 50, archived=True),
        ]
    )
    result = _apply_stack_filters(
        stack,
        include_cards=True,
        include_archived_cards=False,
        description_max_length=10,
    )
    assert result.cards is not None
    assert len(result.cards) == 1
    assert result.cards[0].description is not None
    assert result.cards[0].description.endswith("…")


def test_apply_stack_filters_handles_none_cards():
    """A stack with no cards (cards=None) is left untouched."""
    stack = _make_stack(cards=None)
    result = _apply_stack_filters(
        stack,
        include_cards=True,
        include_archived_cards=False,
        description_max_length=10,
    )
    assert result.cards is None


def test_apply_stack_filters_all_archived_yields_empty_list_not_none():
    """A stack whose cards are all archived yields cards == [], not None.

    Pin the contract: include_cards=True with all cards filtered out
    means "the stack was loaded but had nothing to show", which is
    semantically distinct from include_cards=False (cards=None,
    "explicitly suppressed"). Callers checking ``stack.cards is None``
    can use that to distinguish the two states.
    """
    stack = _make_stack(
        cards=[_make_card(1, archived=True), _make_card(2, archived=True)]
    )
    result = _apply_stack_filters(
        stack,
        include_cards=True,
        include_archived_cards=False,
        description_max_length=None,
    )
    assert result.cards == []
    assert result.cards is not None


# _apply_card_filters -------------------------------------------------------


def test_apply_card_filters_excludes_archived_by_default():
    """include_archived_cards=False filters archived cards out of the flat list."""
    cards = [
        _make_card(1, archived=False),
        _make_card(2, archived=True),
        _make_card(3, archived=False),
    ]
    result = _apply_card_filters(
        cards, include_archived_cards=False, description_max_length=None
    )
    assert [c.id for c in result] == [1, 3]


def test_apply_card_filters_keeps_archived_when_requested():
    """include_archived_cards=True retains archived cards."""
    cards = [_make_card(1, archived=False), _make_card(2, archived=True)]
    result = _apply_card_filters(
        cards, include_archived_cards=True, description_max_length=None
    )
    assert [c.id for c in result] == [1, 2]


def test_apply_card_filters_truncates_descriptions():
    """description_max_length is honored on the returned cards."""
    cards = [_make_card(1, description="x" * 50)]
    result = _apply_card_filters(
        cards, include_archived_cards=True, description_max_length=10
    )
    assert result[0].description is not None
    assert result[0].description.endswith("…")


def test_apply_card_filters_empty_list_is_noop():
    """An empty input returns an empty output."""
    result = _apply_card_filters(
        [], include_archived_cards=False, description_max_length=10
    )
    assert result == []
