"""Tests for markitect.analysis.fca."""

import pytest

from markitect.analysis.fca import (
    FormalContext,
    FormalConcept,
    ConceptLattice,
    find_gap_concepts,
    find_empty_cells,
)


# ── Test data ────────────────────────────────────────────────────────


def _animal_context():
    """Classic FCA example: animals × properties.

    Context:
        | animal    | legs | wings | feathers | fur |
        |-----------|------|-------|----------|-----|
        | dog       |  x   |       |          |  x  |
        | cat       |  x   |       |          |  x  |
        | eagle     |  x   |   x   |    x     |     |
        | sparrow   |  x   |   x   |    x     |     |
        | penguin   |  x   |       |    x     |     |
    """
    return FormalContext(
        objects=["dog", "cat", "eagle", "sparrow", "penguin"],
        attributes=["legs", "wings", "feathers", "fur"],
        incidence={
            "dog":     {"legs", "fur"},
            "cat":     {"legs", "fur"},
            "eagle":   {"legs", "wings", "feathers"},
            "sparrow": {"legs", "wings", "feathers"},
            "penguin": {"legs", "feathers"},
        },
    )


def _infospace_context():
    """Simplified infospace-style context: entities × {domain, vsm_system}.

    Entities with domain and VSM classification, including a gap:
    no entity has both domain:Exchange and vsm:S3.
    """
    return FormalContext.from_dict({
        "division-of-labour":   {"domain:Production", "vsm:S1"},
        "pin-factory":          {"domain:Production", "vsm:S1"},
        "market-extent":        {"domain:Exchange", "vsm:S4"},
        "wage-determination":   {"domain:Distribution", "vsm:S3"},
        "rent-theory":          {"domain:Distribution", "vsm:S5"},
        "capital-accumulation": {"domain:Production", "vsm:S3"},
    })


def _empty_context():
    """Context with no objects."""
    return FormalContext([], ["a", "b"], {})


def _single_entity():
    """Context with one object."""
    return FormalContext(["only"], ["x", "y"], {"only": {"x", "y"}})


# ── FormalContext ────────────────────────────────────────────────────


class TestFormalContext:
    def test_objects_sorted(self):
        ctx = _animal_context()
        assert ctx.objects == sorted(ctx.objects)

    def test_attributes_sorted(self):
        ctx = _animal_context()
        assert ctx.attributes == sorted(ctx.attributes)

    def test_object_count(self):
        assert _animal_context().object_count == 5

    def test_attribute_count(self):
        assert _animal_context().attribute_count == 4

    def test_extent_single_attr(self):
        ctx = _animal_context()
        assert ctx.extent(["fur"]) == frozenset({"dog", "cat"})

    def test_extent_multiple_attrs(self):
        ctx = _animal_context()
        assert ctx.extent(["wings", "feathers"]) == frozenset({"eagle", "sparrow"})

    def test_extent_empty_returns_all(self):
        ctx = _animal_context()
        assert ctx.extent([]) == frozenset(ctx.objects)

    def test_extent_no_match(self):
        ctx = _animal_context()
        assert ctx.extent(["fur", "feathers"]) == frozenset()

    def test_intent_single_obj(self):
        ctx = _animal_context()
        assert ctx.intent(["penguin"]) == frozenset({"legs", "feathers"})

    def test_intent_multiple_objs(self):
        ctx = _animal_context()
        # dog and cat share: legs, fur
        assert ctx.intent(["dog", "cat"]) == frozenset({"legs", "fur"})

    def test_intent_empty_returns_all(self):
        ctx = _animal_context()
        assert ctx.intent([]) == frozenset(ctx.attributes)

    def test_closure_is_idempotent(self):
        ctx = _animal_context()
        c1 = ctx.closure({"fur"})
        c2 = ctx.closure(c1)
        assert c1 == c2

    def test_closure_expands(self):
        ctx = _animal_context()
        # fur → {dog, cat} → {legs, fur} (both have legs too)
        assert ctx.closure({"fur"}) == frozenset({"legs", "fur"})

    def test_has_attribute(self):
        ctx = _animal_context()
        assert ctx.has_attribute("dog", "legs") is True
        assert ctx.has_attribute("dog", "wings") is False

    def test_density(self):
        ctx = _animal_context()
        # 5 objects × 4 attributes = 20 cells
        # dog:2, cat:2, eagle:3, sparrow:3, penguin:2 = 12 filled
        assert ctx.density() == pytest.approx(12 / 20)

    def test_density_empty(self):
        assert FormalContext([], [], {}).density() == 0.0

    def test_from_dict(self):
        ctx = FormalContext.from_dict({
            "a": {"x", "y"},
            "b": {"y", "z"},
        })
        assert ctx.object_count == 2
        assert ctx.attribute_count == 3

    def test_unknown_attributes_ignored(self):
        ctx = FormalContext(
            ["a"], ["x"], {"a": {"x", "unknown"}}
        )
        assert ctx.intent(["a"]) == frozenset({"x"})


# ── ConceptLattice ──────────────────────────────────────────────────


class TestConceptLattice:
    def test_animal_concept_count(self):
        ctx = _animal_context()
        lattice = ConceptLattice.from_context(ctx)
        # Known: the animal context produces exactly 7 formal concepts
        # Top: ({all}, {legs}), Bottom: ({}, {all 4}),
        # plus intermediate concepts
        assert lattice.size >= 5

    def test_top_has_all_objects(self):
        ctx = _animal_context()
        lattice = ConceptLattice.from_context(ctx)
        top = lattice.top
        assert top is not None
        assert top.extent == frozenset(ctx.objects)

    def test_top_intent_is_common_attributes(self):
        ctx = _animal_context()
        lattice = ConceptLattice.from_context(ctx)
        top = lattice.top
        # All animals have "legs"
        assert "legs" in top.intent

    def test_bottom_has_all_attributes(self):
        ctx = _animal_context()
        lattice = ConceptLattice.from_context(ctx)
        bottom = lattice.bottom
        assert bottom is not None
        assert bottom.intent == frozenset(ctx.attributes)

    def test_bottom_extent_empty_when_no_universal_object(self):
        ctx = _animal_context()
        lattice = ConceptLattice.from_context(ctx)
        bottom = lattice.bottom
        # No animal has all 4 attributes
        assert bottom.extent_size == 0

    def test_all_concepts_are_closed(self):
        ctx = _animal_context()
        lattice = ConceptLattice.from_context(ctx)
        for concept in lattice.concepts:
            # intent should be closed: closure(intent) == intent
            assert ctx.closure(concept.intent) == concept.intent
            # extent' should equal intent
            assert ctx.intent(concept.extent) == concept.intent
            # intent' should equal extent
            assert ctx.extent(concept.intent) == concept.extent

    def test_empty_context(self):
        ctx = _empty_context()
        lattice = ConceptLattice.from_context(ctx)
        # Empty context → gap concepts for all attribute combinations
        assert lattice.size >= 1

    def test_single_entity(self):
        ctx = _single_entity()
        lattice = ConceptLattice.from_context(ctx)
        # At least 1 concept containing the single entity
        has_entity = any(
            "only" in c.extent for c in lattice.concepts
        )
        assert has_entity

    def test_no_attributes_produces_one_concept(self):
        ctx = FormalContext(["a", "b"], [], {})
        lattice = ConceptLattice.from_context(ctx)
        assert lattice.size == 1
        assert lattice.concepts[0].extent == frozenset({"a", "b"})

    def test_depth(self):
        ctx = _animal_context()
        lattice = ConceptLattice.from_context(ctx)
        d = lattice.depth()
        # At least 2 levels (top → bottom)
        assert d >= 2

    def test_depth_empty(self):
        lattice = ConceptLattice(concepts=[])
        assert lattice.depth() == 0


# ── Gap concepts ────────────────────────────────────────────────────


class TestGapConcepts:
    def test_animal_has_gap(self):
        ctx = _animal_context()
        gaps = find_gap_concepts(ctx)
        # {fur, feathers} has no animal → gap concept
        fur_feathers_gap = any(
            {"fur", "feathers"} <= c.intent for c in gaps
        )
        assert fur_feathers_gap

    def test_gap_extents_are_empty(self):
        ctx = _animal_context()
        gaps = find_gap_concepts(ctx)
        for gap in gaps:
            assert gap.extent_size == 0

    def test_no_gaps_when_all_combinations_covered(self):
        # Every attribute combination has at least one object
        ctx = FormalContext.from_dict({
            "obj1": {"a", "b"},
            "obj2": {"a"},
            "obj3": {"b"},
        })
        lattice = ConceptLattice.from_context(ctx)
        gaps = find_gap_concepts(ctx, lattice)
        assert len(gaps) == 0

    def test_sorted_by_intent_size(self):
        ctx = _animal_context()
        gaps = find_gap_concepts(ctx)
        sizes = [g.intent_size for g in gaps]
        assert sizes == sorted(sizes)

    def test_infospace_gap(self):
        ctx = _infospace_context()
        gaps = find_gap_concepts(ctx)
        # domain:Exchange + vsm:S1 has no entity → should appear as gap
        gap_intents = [g.intent for g in gaps]
        exchange_s1_covered = any(
            {"domain:Exchange", "vsm:S1"} <= intent for intent in gap_intents
        )
        assert exchange_s1_covered


# ── Empty cells (cross-tab) ─────────────────────────────────────────


class TestFindEmptyCells:
    def test_finds_empty_cells(self):
        ctx = _infospace_context()
        domains = ["domain:Production", "domain:Distribution", "domain:Exchange"]
        vsm_systems = ["vsm:S1", "vsm:S3", "vsm:S4", "vsm:S5"]
        empty = find_empty_cells(ctx, domains, vsm_systems)
        # domain:Exchange + vsm:S1 should be empty
        assert ("domain:Exchange", "vsm:S1") in empty
        # domain:Production + vsm:S1 should NOT be empty (division-of-labour)
        assert ("domain:Production", "vsm:S1") not in empty

    def test_all_filled_returns_empty_list(self):
        ctx = FormalContext.from_dict({
            "a": {"x", "y"},
            "b": {"x", "z"},
            "c": {"y", "z"},
            "d": {"x", "y", "z"},
        })
        empty = find_empty_cells(ctx, ["x", "y"], ["z"])
        assert empty == []

    def test_empty_context_all_cells_empty(self):
        ctx = FormalContext([], ["a", "b", "c"], {})
        empty = find_empty_cells(ctx, ["a"], ["b", "c"])
        assert len(empty) == 2
