# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

import copy
import json
from pathlib import Path

import frontmatter
import pytest
from jsonschema import Draft202012Validator

from solstone.think.activities import DEFAULT_ACTIVITIES
from solstone.think.prompts import _resolve_facets, reset_identity_vars_cache
from solstone.think.talent import (
    RUNTIME_FACETS_SENTINEL,
    get_talent,
    hydrate_runtime_enums,
)

SENSE_PATH = Path(__file__).resolve().parents[1] / "solstone" / "talent" / "sense.md"
SENSE_SCHEMA_PATH = SENSE_PATH.with_suffix(".schema.json")
FACET_NAMING_PATH = (
    Path(__file__).resolve().parents[1]
    / "solstone"
    / "think"
    / "templates"
    / "facet_naming.md"
)


def _section(text: str, start: str, end: str | None = None) -> str:
    section_start = text.index(start)
    if end is None:
        return text[section_start:]
    section_end = text.index(end, section_start)
    return text[section_start:section_end]


def _facet_naming_template() -> str:
    return FACET_NAMING_PATH.read_text(encoding="utf-8").strip()


def _valid_sense_payload() -> dict:
    return {
        "density": "active",
        "content_type": "coding",
        "activity_summary": "Writing tests.",
        "entities": [],
        "facets": [
            {
                "facet": RUNTIME_FACETS_SENTINEL,
                "activity": "Writing tests.",
                "level": "low",
            }
        ],
        "speculative_facet": "test-planning",
        "meeting_detected": False,
        "speakers": [],
        "recommend": {
            "screen_record": False,
            "speaker_attribution": False,
        },
        "emotional_register": "focused",
    }


def _sense_entity(index: int) -> dict:
    return {
        "type": "Person",
        "name": f"Person {index}",
        "role": "mentioned",
        "source": "screen",
        "context": "visible in the workspace",
        "level": "low",
    }


def _sense_facet(index: int) -> dict:
    return {
        "facet": RUNTIME_FACETS_SENTINEL,
        "activity": f"Activity {index}",
        "level": "low",
    }


def _write_prompt_journal(tmp_path: Path, *, with_facet: bool) -> None:
    config_dir = tmp_path / "config"
    config_dir.mkdir(parents=True)
    (config_dir / "journal.json").write_text(
        json.dumps({"identity": {"name": "Test User", "preferred": "Tester"}}),
        encoding="utf-8",
    )
    if with_facet:
        facet_dir = tmp_path / "facets" / "steady"
        facet_dir.mkdir(parents=True)
        (facet_dir / "facet.json").write_text(
            json.dumps({"title": "Steady Facet", "description": "Prompt testing"}),
            encoding="utf-8",
        )


def _render_sense_for_tmp_journal(
    tmp_path: Path, monkeypatch, *, with_facet: bool
) -> str:
    _write_prompt_journal(tmp_path, with_facet=with_facet)
    monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
    reset_identity_vars_cache()
    return get_talent("sense")["user_instruction"]


def test_sense_prompt_parses_and_documents_role_and_source():
    post = frontmatter.load(SENSE_PATH)

    assert "tier" not in post.metadata

    output_schema = _section(
        post.content, "## Output Schema", "## Field-by-Field Instructions"
    )
    entities = _section(post.content, "### entities", "### facets")
    entity_props = get_talent("sense")["json_schema"]["properties"]["entities"][
        "items"
    ]["properties"]

    assert post.metadata["schema"] == "sense.schema.json"
    assert "Authoritative schema: `sense.schema.json`." in output_schema
    assert set(entity_props["role"]["enum"]) == {"attendee", "mentioned"}
    assert set(entity_props["source"]["enum"]) == {
        "voice",
        "speaker_label",
        "transcript",
        "screen",
        "other",
    }
    assert "#### role" in entities
    assert "#### source" in entities


def test_sense_loaded_json_schema_matches_on_disk_schema():
    on_disk = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8"))

    assert get_talent("sense")["json_schema"] == on_disk


def test_content_type_enum_matches_default_activities_drift_detector():
    schedule_only = "Scheduled events emitted by talent/schedule.md"
    expected = [
        a["id"]
        for a in DEFAULT_ACTIVITIES
        if schedule_only not in a.get("instructions", "")
    ] + ["idle"]
    schema = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8"))

    assert schema["properties"]["content_type"]["enum"] == expected


def test_sense_schema_facet_uses_runtime_sentinel_constant():
    schema = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8"))
    facet_schema = schema["properties"]["facets"]["items"]["properties"]["facet"]

    assert facet_schema["enum"] == [RUNTIME_FACETS_SENTINEL]


def test_sense_schema_speculative_facet_nullable_and_required():
    schema = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8"))
    validator = Draft202012Validator(schema)
    valid = _valid_sense_payload()

    assert schema["properties"]["speculative_facet"] == {"type": ["string", "null"]}
    assert "speculative_facet" in schema["required"]
    assert list(validator.iter_errors(valid)) == []

    valid_null = dict(valid)
    valid_null["speculative_facet"] = None
    assert list(validator.iter_errors(valid_null)) == []

    missing = dict(valid)
    del missing["speculative_facet"]
    assert list(validator.iter_errors(missing))


@pytest.mark.parametrize(
    ("collection", "ok_count", "bad_count", "factory"),
    [
        ("entities", 96, 97, _sense_entity),
        ("facets", 16, 17, _sense_facet),
        ("speakers", 16, 17, lambda index: f"Speaker {index}"),
    ],
)
def test_sense_schema_collection_bounds_independently(
    collection,
    ok_count,
    bad_count,
    factory,
):
    schema = json.loads(SENSE_SCHEMA_PATH.read_text(encoding="utf-8"))
    validator = Draft202012Validator(schema)
    payload = _valid_sense_payload()

    payload[collection] = [factory(index) for index in range(ok_count)]
    assert list(validator.iter_errors(payload)) == []

    payload[collection] = [factory(index) for index in range(bad_count)]
    errors = list(validator.iter_errors(payload))

    assert any(
        error.validator == "maxItems" and list(error.path) == [collection]
        for error in errors
    )


def test_sense_prompt_renders_speculative_facet_instruction_in_steady_state(
    tmp_path, monkeypatch
):
    rendered = _render_sense_for_tmp_journal(tmp_path, monkeypatch, with_facet=True)
    speculative_section = _section(
        rendered, "### speculative_facet", "### meeting_detected"
    )

    assert "### speculative_facet" in rendered
    assert "Propose a name for a NEW facet" in speculative_section
    assert "level: low" in speculative_section
    assert _facet_naming_template() in speculative_section


def test_sense_prompt_keeps_forced_configured_facet_routing(tmp_path, monkeypatch):
    rendered = _render_sense_for_tmp_journal(tmp_path, monkeypatch, with_facet=True)
    facets_section = _section(rendered, "### facets", "### speculative_facet")

    assert "Always include at least one facet" in facets_section
    assert "MUST be one of the configured facets listed in the input" in facets_section
    assert "`facets` always has at least one entry" in rendered


def test_sense_prompt_suppresses_speculative_facet_when_configured_match_fits(
    tmp_path, monkeypatch
):
    rendered = _render_sense_for_tmp_journal(tmp_path, monkeypatch, with_facet=True)
    speculative_section = _section(
        rendered, "### speculative_facet", "### meeting_detected"
    )

    assert "level: medium" in speculative_section
    assert "level: high" in speculative_section
    assert "emit `null`" in speculative_section


def test_resolve_facets_zero_facet_discovery_embeds_facet_naming(tmp_path, monkeypatch):
    _write_prompt_journal(tmp_path, with_facet=False)
    monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))

    resolved = _resolve_facets(None)

    assert "No facets are defined yet. You are in discovery mode." in resolved
    assert (
        "These names will be used to suggest journal organization to the user."
        in resolved
    )
    assert _facet_naming_template() in resolved


def test_hydrate_runtime_enums_replaces_facet_sentinel(monkeypatch):
    monkeypatch.setattr(
        "solstone.think.talent.get_facets",
        lambda: {"alpha": {}, "Beta": {}, "weird,name": {}, "valid_one": {}},
    )
    schema = {
        "properties": {"facet": {"type": "string", "enum": [RUNTIME_FACETS_SENTINEL]}}
    }

    hydrated = hydrate_runtime_enums(schema)

    assert hydrated["properties"]["facet"]["enum"] == ["alpha", "valid_one"]


def test_hydrate_runtime_enums_preserves_facet_minItems_when_facets_exist(
    monkeypatch,
):
    monkeypatch.setattr(
        "solstone.think.talent.get_facets",
        lambda: {"alpha": {}, "Beta": {}, "weird,name": {}, "valid_one": {}},
    )
    schema = {
        "type": "object",
        "properties": {
            "facets": {
                "type": "array",
                "minItems": 1,
                "items": {
                    "type": "object",
                    "properties": {
                        "facet": {
                            "type": "string",
                            "enum": [RUNTIME_FACETS_SENTINEL],
                        }
                    },
                },
            }
        },
    }

    hydrated = hydrate_runtime_enums(schema)
    facets_node = hydrated["properties"]["facets"]
    facet_schema = facets_node["items"]["properties"]["facet"]

    assert facets_node["minItems"] == 1
    assert facet_schema["enum"] == ["alpha", "valid_one"]
    Draft202012Validator.check_schema(hydrated)


def test_hydrate_runtime_enums_empty_facets_fallback(monkeypatch):
    monkeypatch.setattr("solstone.think.talent.get_facets", lambda: {})
    schema = {
        "type": "object",
        "properties": {"facet": {"type": "string", "enum": [RUNTIME_FACETS_SENTINEL]}},
    }

    hydrated = hydrate_runtime_enums(schema)
    facet_schema = hydrated["properties"]["facet"]

    assert facet_schema == {"type": "string"}
    Draft202012Validator.check_schema(hydrated)


def test_hydrate_runtime_enums_keeps_portable_facet_shape_on_empty_facets_fallback(
    monkeypatch,
):
    monkeypatch.setattr("solstone.think.talent.get_facets", lambda: {})
    schema = {
        "type": "object",
        "properties": {
            "facets": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "facet": {
                            "type": "string",
                            "enum": [RUNTIME_FACETS_SENTINEL],
                        }
                    },
                },
            }
        },
    }

    hydrated = hydrate_runtime_enums(schema)
    facets_node = hydrated["properties"]["facets"]
    facet_schema = facets_node["items"]["properties"]["facet"]

    assert "minItems" not in facets_node
    assert facet_schema == {"type": "string"}
    Draft202012Validator.check_schema(hydrated)


def test_hydrate_runtime_enums_idempotent_and_pure(monkeypatch):
    monkeypatch.setattr("solstone.think.talent.get_facets", lambda: {})
    original = {"type": "object", "properties": {"x": {"type": "string"}}}
    saved_copy = copy.deepcopy(original)

    hydrated = hydrate_runtime_enums(original)

    assert hydrated == original
    assert original == saved_copy
    assert hydrated is not original
    assert hydrate_runtime_enums(hydrated) == hydrated


def test_hydrate_runtime_enums_none_passthrough():
    assert hydrate_runtime_enums(None) is None


def test_role_and_source_do_not_leak_into_other_sense_sections():
    content = frontmatter.load(SENSE_PATH).content

    sections = [
        _section(content, "### density", "### content_type"),
        _section(content, "### content_type", "### activity_summary"),
        _section(content, "### activity_summary", "### entities"),
        _section(content, "### facets", "### speculative_facet"),
        _section(content, "### speculative_facet", "### meeting_detected"),
        _section(content, "### meeting_detected", "### speakers"),
        _section(content, "### speakers", "### recommend"),
        _section(content, "### recommend", "### emotional_register"),
        _section(content, "### emotional_register", "## Rules"),
        _section(content, "## Rules"),
    ]

    for section in sections:
        assert "attendee|mentioned" not in section
        assert "voice|speaker_label|transcript|screen|other" not in section
        assert "#### role" not in section
        assert "#### source" not in section
