"""T1.2 记忆服务单测——确定性选择 + 渲染 + 组装（ARCH §3.4/§5.3）。

全部用内存 fake repo，无 DB；断言确定性、稳定块排序、无时间戳/UUID。
"""

from __future__ import annotations

import uuid
from dataclasses import dataclass, field
from typing import Any

from ww_core.domain.repositories import (
    CharacterView,
    DigestView,
    ForeshadowView,
    MemoryRepos,
    OutlineView,
    ProjectSpecView,
    RuleView,
    StyleView,
    WorldEntityView,
)
from ww_core.domain.review_repo import ReviewRepo, ReviewView
from ww_core.memory import (
    AssembledContext,
    EntityKind,
    SelectedEntity,
    SelectionTrace,
    assemble,
    render_cards,
    select_relevant_entities,
)

PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")


# ---- 内存 fake repositories ----


@dataclass
class FakeOutlineRepo:
    rows: dict[int, OutlineView] = field(default_factory=dict)

    async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
        return self.rows.get(chapter_no)

    async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
        return [self.rows[k] for k in sorted(self.rows)]


@dataclass
class FakeCharacterRepo:
    rows: list[CharacterView] = field(default_factory=list)

    async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
        return list(self.rows)


@dataclass
class FakeWorldEntityRepo:
    rows: list[WorldEntityView] = field(default_factory=list)

    async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
        return list(self.rows)


@dataclass
class FakeDigestRepo:
    rows: list[DigestView] = field(default_factory=list)

    async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
        return sorted(self.rows, key=lambda d: d.chapter_no, reverse=True)[:k]


@dataclass
class FakeForeshadowRepo:
    rows: list[ForeshadowView] = field(default_factory=list)

    async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
        wanted = set(codes)
        return [r for r in self.rows if r.code in wanted]


@dataclass
class FakeStyleRepo:
    row: StyleView | None = None

    async def latest(self, project_id: uuid.UUID) -> StyleView | None:
        return self.row


@dataclass
class FakeRulesRepo:
    rows: list[RuleView] = field(default_factory=list)

    async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
        return list(self.rows)


@dataclass
class FakeProjectSpecRepo:
    row: ProjectSpecView | None = None

    async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
        return self.row


@dataclass
class FakeReviewRepo:
    """内存审稿留痕 fake（灵感②冲突裁决反哺）——只需 list_for_chapter 供 assemble 反读。

    `queried` 记录被查询的章号（验证读的是上一章 chapter_no-1）；record/set_decisions
    只为满足 ReviewRepo Protocol 结构类型，assemble 反哺路径不用。
    """

    by_chapter: dict[int, list[ReviewView]] = field(default_factory=dict)
    queried: list[int] = field(default_factory=list)

    async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
        self.queried.append(chapter_no)
        return list(self.by_chapter.get(chapter_no, []))

    async def record(self, *args: Any, **kwargs: Any) -> ReviewView:  # pragma: no cover
        raise NotImplementedError

    async def set_decisions(self, *args: Any, **kwargs: Any) -> ReviewView:  # pragma: no cover
        raise NotImplementedError


def build_repos(
    *,
    outline: dict[int, OutlineView] | None = None,
    characters: list[CharacterView] | None = None,
    world: list[WorldEntityView] | None = None,
    digests: list[DigestView] | None = None,
    foreshadows: list[ForeshadowView] | None = None,
    style: StyleView | None = None,
    rules: list[RuleView] | None = None,
    spec: ProjectSpecView | None = None,
    review: ReviewRepo | None = None,
) -> MemoryRepos:
    return MemoryRepos(
        outline=FakeOutlineRepo(outline or {}),
        character=FakeCharacterRepo(characters or []),
        world_entity=FakeWorldEntityRepo(world or []),
        digest=FakeDigestRepo(digests or []),
        foreshadow=FakeForeshadowRepo(foreshadows or []),
        style=FakeStyleRepo(style),
        rules=FakeRulesRepo(rules or []),
        project=FakeProjectSpecRepo(spec),
        review=review,
    )


# ---- select_relevant_entities ----


def char(name: str, role: str | None = None, latest_state: str | None = None) -> CharacterView:
    return CharacterView(name=name, role=role, latest_state=latest_state)


def world(name: str, type_: str = "设定", latest_state: str | None = None) -> WorldEntityView:
    return WorldEntityView(type=type_, name=name, latest_state=latest_state)


def test_selects_main_character_even_when_not_in_beats() -> None:
    outline = OutlineView(volume=1, chapter_no=1, beats={"summary": "无人提及"})
    trace = select_relevant_entities(
        outline=outline,
        characters=[char("林动", role="主角"), char("路人甲", role="配角")],
        world_entities=[],
        recent_digests=[],
    )
    names = {e.name for e in trace.selected}
    assert "林动" in names
    assert "路人甲" not in names
    main = next(e for e in trace.selected if e.name == "林动")
    assert "main_character" in main.reasons


def test_selects_entity_named_in_beats() -> None:
    outline = OutlineView(volume=1, chapter_no=2, beats={"entities": ["青檀"], "text": "青檀出场"})
    trace = select_relevant_entities(
        outline=outline,
        characters=[char("青檀", role="配角")],
        world_entities=[],
        recent_digests=[],
    )
    assert any(e.name == "青檀" and "explicit_beat" in e.reasons for e in trace.selected)


def test_selects_recent_digest_entity() -> None:
    outline = OutlineView(volume=1, chapter_no=10, beats={})
    digests = [DigestView(chapter_no=9, facts={"entities": ["玄机阁"]})]
    trace = select_relevant_entities(
        outline=outline,
        characters=[],
        world_entities=[world("玄机阁")],
        recent_digests=digests,
    )
    assert any(e.name == "玄机阁" and "recent_digest" in e.reasons for e in trace.selected)


def test_union_dedups_reasons_sorted() -> None:
    outline = OutlineView(
        volume=1,
        chapter_no=3,
        beats={"entities": ["林动"]},
        foreshadow_windows=[{"code": "F1", "entities": ["林动"]}],
    )
    digests = [DigestView(chapter_no=2, facts={"entities": ["林动"]})]
    trace = select_relevant_entities(
        outline=outline,
        characters=[char("林动", role="主角")],
        world_entities=[],
        recent_digests=digests,
    )
    selected = [e for e in trace.selected if e.name == "林动"]
    assert len(selected) == 1  # 去重为一条
    # 四个理由全命中且按规范顺序排列
    assert selected[0].reasons == [
        "explicit_beat",
        "main_character",
        "recent_digest",
        "foreshadow_window",
    ]


# ---- author override: pin / exclude / recent_n (B0 可控版) ----


def test_pin_forces_inclusion_with_author_pin_reason() -> None:
    # 路人甲零自动理由——pin 强制纳入并加 author_pin。
    outline = OutlineView(volume=1, chapter_no=1, beats={})
    trace = select_relevant_entities(
        outline=outline,
        characters=[char("路人甲", role="配角")],
        world_entities=[],
        recent_digests=[],
        pinned=frozenset({("character", "路人甲")}),
    )
    selected = [e for e in trace.selected if e.name == "路人甲"]
    assert len(selected) == 1
    assert selected[0].reasons == ["author_pin"]


def test_pin_appends_author_pin_to_existing_reasons() -> None:
    outline = OutlineView(volume=1, chapter_no=1, beats={})
    trace = select_relevant_entities(
        outline=outline,
        characters=[char("林动", role="主角")],
        world_entities=[],
        recent_digests=[],
        pinned=frozenset({("character", "林动")}),
    )
    main = next(e for e in trace.selected if e.name == "林动")
    assert main.reasons == ["main_character", "author_pin"]  # 规范顺序，author_pin 末位


def test_exclude_removes_auto_selected_entity() -> None:
    # 主角林动本会自动入选——excluded 强制剔除。
    outline = OutlineView(volume=1, chapter_no=1, beats={})
    trace = select_relevant_entities(
        outline=outline,
        characters=[char("林动", role="主角")],
        world_entities=[],
        recent_digests=[],
        excluded=frozenset({("character", "林动")}),
    )
    assert all(e.name != "林动" for e in trace.selected)


def test_exclude_wins_over_pin() -> None:
    # 同一实体同时 pin + exclude（前端不会，但防御）：排除优先。
    outline = OutlineView(volume=1, chapter_no=1, beats={})
    trace = select_relevant_entities(
        outline=outline,
        characters=[char("林动", role="主角")],
        world_entities=[],
        recent_digests=[],
        pinned=frozenset({("character", "林动")}),
        excluded=frozenset({("character", "林动")}),
    )
    assert all(e.name != "林动" for e in trace.selected)


async def test_assemble_override_pins_and_excludes() -> None:
    from ww_core.domain.injection_repo import EntityRef, InjectionOverride

    repos = build_repos(
        characters=[char("林动", role="主角"), char("路人乙", role="龙套")],
        spec=ProjectSpecView(title="书"),
    )
    override = InjectionOverride(
        pinned=[EntityRef(kind="character", name="路人乙")],
        excluded=[EntityRef(kind="character", name="林动")],
    )
    ctx = await assemble(repos, PROJECT, 1, override=override)
    names = {e.name for e in ctx.selection.selected}
    assert "路人乙" in names  # pin 强制纳入
    assert "林动" not in names  # exclude 强制剔除


async def test_assemble_override_recent_n_limits_digests() -> None:
    from ww_core.domain.injection_repo import InjectionOverride

    digests = [DigestView(chapter_no=n, facts={"entities": [f"实体{n}"]}) for n in range(1, 6)]
    world = [WorldEntityView(type="设定", name=f"实体{n}") for n in range(1, 6)]
    repos = build_repos(
        outline={10: OutlineView(volume=1, chapter_no=10, beats={})},
        world=world,
        digests=digests,
        spec=ProjectSpecView(title="书"),
    )
    # recent_n=1 → 只回看最近一章（第5章）的实体5 命中 recent_digest。
    ctx = await assemble(repos, PROJECT, 10, override=InjectionOverride(recent_n=1))
    names = {e.name for e in ctx.selection.selected}
    assert "实体5" in names
    assert "实体1" not in names


async def test_assemble_directive_in_volatile_not_stable() -> None:
    repos = build_repos(
        characters=[char("林动", role="主角")],
        spec=ProjectSpecView(title="书"),
    )
    ctx = await assemble(repos, PROJECT, 1, directive="多写战斗")
    assert "本章指令" in ctx.volatile
    assert "多写战斗" in ctx.volatile
    assert "多写战斗" not in ctx.stable_core
    assert "本章指令" not in ctx.stable_core


# ---- render_cards ----


def test_render_cards_deterministic_and_sorted() -> None:
    selection = SelectionTrace(
        selected=[
            _selected("character", "乙"),
            _selected("character", "甲"),
        ]
    )
    chars = [char("乙", role="配角", latest_state="受伤"), char("甲", role="主角")]
    out1 = render_cards(selection, chars, [])
    out2 = render_cards(selection, chars, [])
    assert out1 == out2  # 确定性
    # 按名排序（Python 默认 codepoint 序：乙 U+4E59 < 甲 U+7532）
    assert out1.index("乙") < out1.index("甲")


# ---- assemble ----


async def test_assemble_deterministic_no_timestamps() -> None:
    repos = _full_repos()
    ctx1 = await assemble(repos, PROJECT, 5)
    ctx2 = await assemble(repos, PROJECT, 5)
    assert isinstance(ctx1, AssembledContext)
    assert ctx1.stable_core == ctx2.stable_core
    assert ctx1.volatile == ctx2.volatile
    # 无 UUID / 时间戳泄漏进缓存前缀
    assert str(PROJECT) not in ctx1.stable_core
    assert "T00:00" not in ctx1.stable_core


async def test_latest_state_in_volatile_not_stable() -> None:
    repos = _full_repos()
    ctx = await assemble(repos, PROJECT, 5)
    # latest_state 是易变态——必须在断点之后，不污染缓存前缀
    assert "断左臂" in ctx.volatile
    assert "断左臂" not in ctx.stable_core


async def test_rule_merge_precedence() -> None:
    rules = [
        RuleView(level="global", content="全局规则"),
        RuleView(level="project", content="作品规则"),
        RuleView(level="genre", content="题材规则"),
        RuleView(level="style", content="文风规则"),
    ]
    repos = build_repos(rules=rules)
    ctx = await assemble(repos, PROJECT, 1)
    # 四级都出现，且 global→genre→style→project 顺序（越具体越靠后/越优先）
    g = ctx.stable_core.index("全局规则")
    ge = ctx.stable_core.index("题材规则")
    s = ctx.stable_core.index("文风规则")
    p = ctx.stable_core.index("作品规则")
    assert g < ge < s < p


async def test_bare_project_premise_only_yields_nonempty_prompt() -> None:
    # 全新项目：只有书级蓝本（premise/logline/title），无世界观/角色/大纲/伏笔。
    spec = ProjectSpecView(title="无名之书", logline="一个开始", premise="末世孤舟漂流")
    repos = build_repos(spec=spec)  # 其余全空
    ctx = await assemble(repos, PROJECT, 1)
    # stable_core 非空且含书级蓝本
    assert ctx.stable_core != ""
    assert "末世孤舟漂流" in ctx.stable_core
    assert "无名之书" in ctx.stable_core
    # volatile 非空且含本章写作指令 → writer 的 user message 永不为空
    assert ctx.volatile != ""
    assert "请创作第 1 章的正文。" in ctx.volatile


async def test_chapter_directive_uses_chapter_no() -> None:
    repos = build_repos(spec=ProjectSpecView(title="书"))
    ctx = await assemble(repos, PROJECT, 7)
    assert "请创作第 7 章的正文。" in ctx.volatile


# ---- 灵感 F1：genre-aware craft 片段注入 ----


async def test_genre_fragment_injected_by_genre() -> None:
    from ww_config import GENRE_CRAFT

    repos = build_repos(spec=ProjectSpecView(title="书", genre="玄幻"))
    ctx = await assemble(repos, PROJECT, 5)
    # 玄幻题材片段进 stable_core（每书稳定，随书缓存），不进 volatile。
    assert GENRE_CRAFT["玄幻"] in ctx.stable_core
    assert GENRE_CRAFT["玄幻"] not in ctx.volatile


async def test_different_genre_yields_different_fragment() -> None:
    from ww_config import GENRE_CRAFT

    xuan = await assemble(build_repos(spec=ProjectSpecView(title="书", genre="玄幻")), PROJECT, 5)
    city = await assemble(build_repos(spec=ProjectSpecView(title="书", genre="都市")), PROJECT, 5)
    assert GENRE_CRAFT["玄幻"] in xuan.stable_core
    assert GENRE_CRAFT["都市"] in city.stable_core
    assert xuan.stable_core != city.stable_core


async def test_unknown_genre_injects_no_fragment() -> None:
    repos = build_repos(spec=ProjectSpecView(title="书", genre="不存在的题材"))
    ctx = await assemble(repos, PROJECT, 5)
    assert "本作题材写法" not in ctx.stable_core


async def test_no_genre_injects_no_fragment() -> None:
    repos = build_repos(spec=ProjectSpecView(title="书"))  # genre 默认 None
    ctx = await assemble(repos, PROJECT, 5)
    assert "本作题材写法" not in ctx.stable_core


# ---- 灵感④：立项基调/结局/视角是书级常量 → 进 stable_core（缓存前缀，不变量 #9） ----


async def test_build_stable_includes_tone_ending_pov() -> None:
    spec = ProjectSpecView(title="书", tone="热血", ending_type="HE", narrative_pov="第一人称")
    ctx = await assemble(build_repos(spec=spec), PROJECT, 5)
    assert "热血" in ctx.stable_core
    assert "HE" in ctx.stable_core
    assert "第一人称" in ctx.stable_core


async def test_tone_ending_pov_never_enter_volatile() -> None:
    # 书级常量随书稳定 → 只进缓存前缀，绝不进 volatile（章号易变区）。
    spec = ProjectSpecView(title="书", tone="热血", ending_type="HE", narrative_pov="第一人称")
    ctx = await assemble(build_repos(spec=spec), PROJECT, 5)
    assert "热血" not in ctx.volatile
    assert "第一人称" not in ctx.volatile


# ---- 灵感①/F1：开篇黄金三章特判（volatile，仅前 3 章） ----


async def test_opening_marker_injected_for_ch1to3_only() -> None:
    repos = build_repos(spec=ProjectSpecView(title="书"))
    for chapter_no in (1, 2, 3):
        ctx = await assemble(repos, PROJECT, chapter_no)
        assert "黄金三章" in ctx.volatile
        assert f"开篇第 {chapter_no} 章" in ctx.volatile
    # 第 4 章起不再注入开篇标记。
    ctx4 = await assemble(repos, PROJECT, 4)
    assert "黄金三章" not in ctx4.volatile


async def test_opening_marker_never_enters_stable_core() -> None:
    # 不变量#9：开篇标记含章号（每章易变）→ 只入 volatile，绝不进缓存前缀。
    repos = build_repos(spec=ProjectSpecView(title="书", genre="玄幻"))
    ctx = await assemble(repos, PROJECT, 1)
    assert "黄金三章" not in ctx.stable_core


async def test_no_per_chapter_varying_field_in_cached_blocks() -> None:
    # 不变量#9：同书不同章，stable_core（含 genre 片段）必须字节稳定；
    # 章号 / 开篇标记等每章易变项只在 volatile。
    repos = build_repos(spec=ProjectSpecView(title="书", genre="玄幻"))
    ch1 = await assemble(repos, PROJECT, 1)
    ch5 = await assemble(repos, PROJECT, 5)
    assert ch1.stable_core == ch5.stable_core  # genre 片段随书稳定，跨章不变
    assert "第 1 章" not in ch1.stable_core
    assert "第 5 章" not in ch5.stable_core


# ---- 灵感②/D3：上一已验收章 continuity 冲突裁决反哺（volatile，确定性模板） ----


def _accepted_review(
    *,
    chapter_no: int,
    conflicts: list[dict[str, Any]],
    items: list[dict[str, Any]],
) -> ReviewView:
    """构造一行"已验收"审稿留痕：decisions 非空（只在验收事务写入 → 视为已验收）。"""
    return ReviewView(
        id=uuid.UUID(int=chapter_no),
        project_id=PROJECT,
        chapter_no=chapter_no,
        conflicts=conflicts,
        decisions={"items": items, "conflict_count": len(conflicts)},
    )


def _conflict(type_: str, suggestion: str) -> dict[str, Any]:
    return {"type": type_, "where": "某段", "refs": [], "suggestion": suggestion}


def _review_repo_with(reviews: list[ReviewView]) -> FakeReviewRepo:
    by_chapter: dict[int, list[ReviewView]] = {}
    for r in reviews:
        by_chapter.setdefault(r.chapter_no, []).append(r)
    return FakeReviewRepo(by_chapter=by_chapter)


async def test_volatile_includes_prior_conflict_notes() -> None:
    review = _accepted_review(
        chapter_no=1,
        conflicts=[_conflict("设定冲突", "主角左臂已断，后文不得再用左手持剑")],
        items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
    )
    repos = build_repos(
        spec=ProjectSpecView(title="书"),
        review=_review_repo_with([review]),
    )
    ctx = await assemble(repos, PROJECT, 2)
    assert "上一章冲突提醒" in ctx.volatile
    assert "主角左臂已断，后文不得再用左手持剑" in ctx.volatile


async def test_prior_conflict_notes_never_enter_stable_core() -> None:
    # 不变量#9：反哺是每章易变输入（依赖上一章裁决）→ 只入 volatile，绝不进缓存前缀。
    review = _accepted_review(
        chapter_no=1,
        conflicts=[_conflict("设定冲突", "灵脉禁止跨界，后文守此规则")],
        items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
    )
    repos = build_repos(spec=ProjectSpecView(title="书"), review=_review_repo_with([review]))
    ctx = await assemble(repos, PROJECT, 2)
    assert "上一章冲突提醒" not in ctx.stable_core
    assert "灵脉禁止跨界，后文守此规则" not in ctx.stable_core


async def test_reads_previous_accepted_chapter() -> None:
    # 写第 5 章 → 只应读第 4 章（chapter_no-1）的审稿留痕。
    review = _accepted_review(
        chapter_no=4,
        conflicts=[_conflict("时间线倒错", "事件发生在雨夜，后文保持一致")],
        items=[{"conflict_index": 0, "verdict": "manual", "note": "已统一为雨夜"}],
    )
    fake = _review_repo_with([review])
    repos = build_repos(spec=ProjectSpecView(title="书"), review=fake)
    ctx = await assemble(repos, PROJECT, 5)
    assert fake.queried == [4]  # 只查上一章
    assert "已统一为雨夜" in ctx.volatile


async def test_skips_unaccepted_previous_chapter() -> None:
    # 上一章有审稿留痕但 decisions=None（尚未验收）→ 不反哺（守风险#5：不读未定稿）。
    unaccepted = ReviewView(
        id=uuid.UUID(int=99),
        project_id=PROJECT,
        chapter_no=1,
        conflicts=[_conflict("设定冲突", "不应注入的建议")],
        decisions=None,
    )
    repos = build_repos(
        spec=ProjectSpecView(title="书"),
        review=_review_repo_with([unaccepted]),
    )
    ctx = await assemble(repos, PROJECT, 2)
    assert "上一章冲突提醒" not in ctx.volatile
    assert "不应注入的建议" not in ctx.volatile


async def test_only_retained_conflict_decisions_injected() -> None:
    # 只反哺保留项 verdict∈{accept,manual}；ignore（作者判为非冲突）不反哺。
    review = _accepted_review(
        chapter_no=1,
        conflicts=[
            _conflict("设定冲突", "采纳项应注入"),
            _conflict("时间线倒错", "手改项应注入"),
            _conflict("地理矛盾", "忽略项不应注入"),
        ],
        items=[
            {"conflict_index": 0, "verdict": "accept", "note": None},
            {"conflict_index": 1, "verdict": "manual", "note": None},
            {"conflict_index": 2, "verdict": "ignore", "note": None},
        ],
    )
    repos = build_repos(spec=ProjectSpecView(title="书"), review=_review_repo_with([review]))
    ctx = await assemble(repos, PROJECT, 2)
    assert "采纳项应注入" in ctx.volatile
    assert "手改项应注入" in ctx.volatile
    assert "忽略项不应注入" not in ctx.volatile


async def test_prior_conflict_notes_bounded_deduped_sorted_and_no_llm_call() -> None:
    # 5 条保留裁决 + 1 条重复 → 去重后有界 top-3；两次组装字节完全一致（确定性、无 LLM）。
    conflicts = [_conflict("设定冲突", f"约束{i}") for i in range(5)]
    conflicts.append(_conflict("设定冲突", "约束0"))  # 与 index 0 内容重复
    items = [{"conflict_index": i, "verdict": "accept", "note": None} for i in range(6)]
    review = _accepted_review(chapter_no=1, conflicts=conflicts, items=items)
    repos = build_repos(spec=ProjectSpecView(title="书"), review=_review_repo_with([review]))

    ctx1 = await assemble(repos, PROJECT, 2)
    ctx2 = await assemble(repos, PROJECT, 2)
    # 字节稳定（无 LLM 的非确定性）——assemble 无网关入参，反哺全程纯字符串模板。
    assert ctx1.volatile == ctx2.volatile
    section = ctx1.volatile.split("## 上一章冲突提醒\n", 1)[1].split("\n\n", 1)[0]
    lines = [ln for ln in section.splitlines() if ln.startswith("- ")]
    assert len(lines) == 3  # 有界 top-3
    assert len(set(lines)) == len(lines)  # 去重
    assert "约束0" in section  # 去重后仍保留一条约束0


async def test_no_apology_or_selfref_in_prior_conflict_notes() -> None:
    review = _accepted_review(
        chapter_no=1,
        conflicts=[_conflict("设定冲突", "后文保持灵脉守恒")],
        items=[{"conflict_index": 0, "verdict": "accept", "note": None}],
    )
    repos = build_repos(spec=ProjectSpecView(title="书"), review=_review_repo_with([review]))
    ctx = await assemble(repos, PROJECT, 2)
    section = ctx.volatile.split("## 上一章冲突提醒\n", 1)[1].split("\n\n", 1)[0]
    # 前瞻正向约束——绝无道歉/自指措辞（守 #3/#4：不自评自改、不磨平文风）。
    for banned in ("抱歉", "道歉", "对不起", "如前所述", "更正", "笔误"):
        assert banned not in section


async def test_assemble_without_review_repo_gracefully_degrades() -> None:
    # review=None（默认）→ 优雅降级：不反哺、不崩（守风险#6）。
    repos = build_repos(spec=ProjectSpecView(title="书"))  # review 默认 None
    ctx = await assemble(repos, PROJECT, 5)
    assert "上一章冲突提醒" not in ctx.volatile


async def test_no_prior_conflict_notes_for_first_chapter() -> None:
    # 第 1 章无上一章 → 不查 review repo、不反哺。
    fake = FakeReviewRepo()
    repos = build_repos(spec=ProjectSpecView(title="书"), review=fake)
    ctx = await assemble(repos, PROJECT, 1)
    assert fake.queried == []  # 未查询（无上一章）
    assert "上一章冲突提醒" not in ctx.volatile


# ---- helpers ----


def _selected(kind: EntityKind, name: str) -> SelectedEntity:
    return SelectedEntity(kind=kind, name=name, reasons=["main_character"])


def _full_repos() -> MemoryRepos:
    return build_repos(
        outline={
            5: OutlineView(
                volume=1,
                chapter_no=5,
                beats={"entities": ["林动"], "text": "林动突破"},
                foreshadow_windows=[{"code": "F1", "entities": []}],
            )
        },
        characters=[char("林动", role="主角", latest_state="断左臂")],
        world=[world("元力", type_="力量体系")],
        digests=[DigestView(chapter_no=4, facts={"entities": ["元力"]})],
        foreshadows=[ForeshadowView(code="F1", title="神秘石符", status="OPEN", planted_at=1)],
        style=StyleView(dimensions={"句长": "短句为主"}),
        rules=[RuleView(level="global", content="全局规则")],
        spec=ProjectSpecView(title="大梦主", logline="少年逆天改命", premise="灵气复苏"),
    )
