use std::{fs, path::Path};

use nana_domain::{
    ActionSuggestion, AppInfo, BeatKind, DOMAIN_SCHEMA_VERSION, DemoPackSummary, HistoryNodeView,
    PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot,
    Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension,
    ResourceBundle, RuntimeState, StateDelta, StateOp, StoryNode, TurnFailure, TurnFailureCode,
    TurnIntent, TurnRequest, TurnResult, ValidationIssue, VisualDirective, stable_json_hash,
    validate_bundle,
};
use nana_engine::{
    SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
};
use nana_runtime::{ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector};
use nana_store::{SqliteStoryStore, StoreError, StoryStore};
use serde::Serialize;
use tauri::Manager;

const DEMO_PLAYER_VIEW: &str = include_str!("../../fixtures/player-view/initial.json");
const DEMO_RUNTIME_STATE: &str = include_str!("../../fixtures/runtime-state/initial.json");
const DEMO_BUNDLE: &str = include_str!("../../content/nana-demo/bundle.json");
const DEMO_STORY_ID: &str = "story_nana_demo";
const DEMO_BRANCH_ID: &str = "branch_main";
const PLAYER_ID: &str = "player";
const CHARACTER_ID: &str = "nana";

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandError {
    code: String,
    message: String,
    retryable: bool,
    issues: Vec<ValidationIssue>,
}

impl CommandError {
    fn parse(target: &str, error: &serde_json::Error) -> Self {
        Self {
            code: "invalid_embedded_json".to_owned(),
            message: format!("failed to parse embedded {target}: {error}"),
            retryable: false,
            issues: Vec::new(),
        }
    }

    fn storage(_error: &StoreError) -> Self {
        Self {
            code: "storage_unavailable".to_owned(),
            message: "故事存档暂时不可用。".to_owned(),
            retryable: true,
            issues: Vec::new(),
        }
    }

    fn turn(failure: TurnFailure) -> Self {
        Self {
            code: turn_failure_code(&failure.code).to_owned(),
            message: failure.message,
            retryable: failure.retryable,
            issues: Vec::new(),
        }
    }
}

struct DemoAppState {
    store: SqliteStoryStore,
    bundle: ResourceBundle,
}

impl DemoAppState {
    fn open(path: impl AsRef<Path>) -> Result<Self, CommandError> {
        let store = SqliteStoryStore::open(path).map_err(|error| CommandError::storage(&error))?;
        Self::from_store(store)
    }

    #[cfg(test)]
    fn open_in_memory() -> Result<Self, CommandError> {
        let store =
            SqliteStoryStore::open_in_memory().map_err(|error| CommandError::storage(&error))?;
        Self::from_store(store)
    }

    fn from_store(store: SqliteStoryStore) -> Result<Self, CommandError> {
        let bundle: ResourceBundle = serde_json::from_str(DEMO_BUNDLE)
            .map_err(|error| CommandError::parse("content bundle", &error))?;
        let report = validate_bundle(&bundle);
        if !report.is_valid() {
            return Err(CommandError {
                code: "invalid_content_bundle".to_owned(),
                message: "the embedded content bundle failed domain validation".to_owned(),
                retryable: false,
                issues: report.issues,
            });
        }

        match store.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) {
            Ok(state) => {
                store
                    .load_node(&state.story_id, &state.current_node)
                    .map_err(|error| CommandError::storage(&error))?;
            }
            Err(StoreError::StoryNotFound(_)) => seed_demo_story(&store)?,
            Err(error) => return Err(CommandError::storage(&error)),
        }

        Ok(Self { store, bundle })
    }

    fn pack_summary(&self) -> DemoPackSummary {
        DemoPackSummary {
            id: self.bundle.id.clone(),
            display_name: self.bundle.display_name.clone(),
            revision: self.bundle.revision.clone(),
            characters: self.bundle.characters.len(),
            world_books: self.bundle.world_books.len(),
            personas: self.bundle.personas.len(),
            plot_modules: self.bundle.plot_modules.len(),
            item_specs: self.bundle.item_specs.len(),
        }
    }

    fn current_player_view(&self) -> Result<PlayerView, CommandError> {
        let state = self
            .store
            .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
            .map_err(|error| CommandError::storage(&error))?;
        let node = self
            .store
            .load_node(&state.story_id, &state.current_node)
            .map_err(|error| CommandError::storage(&error))?;
        let lineage = self
            .load_lineage(&node)
            .map_err(|error| CommandError::storage(&error))?;
        Ok(self.project_view_with_lineage(&state, &node, &lineage))
    }

    fn submit_turn(&self, request: &TurnRequest) -> Result<TurnResult, CommandError> {
        let provider = DemoPlanProvider;
        let projector = DemoProjector { app: self };
        let mut engine = TurnEngine::new(&self.store, provider, projector);
        engine.submit_turn(request).map_err(CommandError::turn)
    }

    fn project_view(&self, state: &RuntimeState, node: &StoryNode) -> PlayerView {
        let lineage = self
            .load_lineage(node)
            .unwrap_or_else(|_| vec![node.clone()]);
        self.project_view_with_lineage(state, node, &lineage)
    }

    fn project_view_with_lineage(
        &self,
        state: &RuntimeState,
        node: &StoryNode,
        lineage: &[StoryNode],
    ) -> PlayerView {
        let history = history_for(lineage, node);
        let relationship_updated_at_node = last_player_relationship_update(lineage);
        let character_name = self
            .bundle
            .characters
            .iter()
            .find(|character| character.header.id == self.bundle.entry_character)
            .map_or("角色", |character| character.name.as_str());
        project_story_node_player_view(
            state,
            node,
            &StoryNodePlayerViewProjectionContext {
                player_id: PLAYER_ID,
                relationship_character_id: CHARACTER_ID,
                relationship_updated_at_node,
                item_specs: &self.bundle.item_specs,
                history: &history,
                legacy_scene: SceneMetadata {
                    scene_id: "old_station_platform",
                    scene_title: "旧青川站",
                    character_name,
                },
            },
        )
    }

    fn load_lineage(&self, current: &StoryNode) -> Result<Vec<StoryNode>, StoreError> {
        let mut lineage = vec![current.clone()];
        let mut cursor = current.parent_id.clone();

        while let Some(ref node_id) = cursor {
            if lineage.len() >= 200 {
                return Err(StoreError::StateMismatch(
                    "story lineage exceeds the projection limit",
                ));
            }
            let node = self.store.load_node(&current.story_id, node_id)?;
            cursor.clone_from(&node.parent_id);
            lineage.push(node);
        }

        lineage.reverse();
        Ok(lineage)
    }
}

struct DemoPlanProvider;

impl TurnPlanProvider for DemoPlanProvider {
    fn plan_turn(
        &mut self,
        request: &TurnRequest,
        state: &RuntimeState,
    ) -> Result<TurnPlan, ProviderError> {
        let committed_node_id = node_id_for_action(request);
        let accepted_promise = is_return_promise(&request.input)
            && !state.promises.iter().any(|promise| {
                promise.promiser == PLAYER_ID
                    && promise.promisee == CHARACTER_ID
                    && matches!(
                        promise.status,
                        PromiseStatus::Accepted | PromiseStatus::Fulfilled
                    )
            });

        let (beats, delta) = if accepted_promise {
            promise_turn(request, &committed_node_id)
        } else if request.intent == TurnIntent::Continue {
            continue_turn(request, state)
        } else {
            regular_turn(request)
        };

        Ok(TurnPlan {
            committed_node_id,
            presentation: demo_presentation(beats),
            delta,
        })
    }
}

struct DemoProjector<'a> {
    app: &'a DemoAppState,
}

impl TurnProjector for DemoProjector<'_> {
    fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView {
        self.app.project_view(state, node)
    }
}

fn seed_demo_story(store: &SqliteStoryStore) -> Result<(), CommandError> {
    let state: RuntimeState = serde_json::from_str(DEMO_RUNTIME_STATE)
        .map_err(|error| CommandError::parse("RuntimeState fixture", &error))?;
    let view: PlayerView = serde_json::from_str(DEMO_PLAYER_VIEW)
        .map_err(|error| CommandError::parse("PlayerView fixture", &error))?;
    let state_bytes = serde_json::to_vec(&state)
        .map_err(|error| CommandError::parse("RuntimeState fixture", &error))?;
    let root = StoryNode {
        id: state.current_node.clone(),
        story_id: state.story_id.clone(),
        branch_id: state.current_branch.clone(),
        parent_id: None,
        action_id: "story_created".to_owned(),
        user_input: String::new(),
        presentation: PresentationSnapshot {
            scene: PresentationScene {
                id: view.scene_id,
                title: view.scene_title,
            },
            character: PresentationCharacter {
                id: CHARACTER_ID.to_owned(),
                name: view.character_name,
                expression: view.character_expression,
                pose: view.character_pose,
            },
            beats: view.beats,
            suggestions: view.suggestions,
            can_continue: view.can_continue,
        },
        delta: StateDelta { ops: Vec::new() },
        state_hash: stable_json_hash(&state_bytes),
    };
    store
        .append_node(&root, &state)
        .map_err(|error| CommandError::storage(&error))
}

fn promise_turn(request: &TurnRequest, node_id: &str) -> (Vec<PresentationBeat>, StateDelta) {
    let beats = vec![
        player_action_beat(request),
        PresentationBeat {
            id: format!("{}_nana", request.action_id),
            kind: BeatKind::Dialogue,
            speaker: Some("娜娜".to_owned()),
            text: "娜娜看了你一会儿，终于松开攥紧外套的手。“好。我等你到天亮。”".to_owned(),
            visual: Some(VisualDirective {
                character: Some(CHARACTER_ID.to_owned()),
                expression: Some("relieved".to_owned()),
                pose: Some("holding_coat".to_owned()),
                scene: None,
            }),
        },
    ];
    let delta = StateDelta {
        ops: vec![
            StateOp::CreatePromise {
                promise: Promise {
                    id: format!("promise_return_before_dawn_{}", request.action_id),
                    promiser: PLAYER_ID.to_owned(),
                    promisee: CHARACTER_ID.to_owned(),
                    content: "天亮前一定回来".to_owned(),
                    status: PromiseStatus::Accepted,
                    weight: PromiseWeight::Major,
                    created_at: node_id.to_owned(),
                    accepted_at: Some(node_id.to_owned()),
                    resolved_at: None,
                },
            },
            StateOp::AdjustRelationship {
                from: CHARACTER_ID.to_owned(),
                to: PLAYER_ID.to_owned(),
                adjustment: RelationshipAdjustment {
                    dimension: RelationshipDimension::Hope,
                    delta: 4,
                    cause: "娜娜接受了玩家天亮前归来的许诺".to_owned(),
                    judgment_rule: Some("nana.promises.accepted".to_owned()),
                },
            },
        ],
    };
    (beats, delta)
}

fn continue_turn(
    request: &TurnRequest,
    state: &RuntimeState,
) -> (Vec<PresentationBeat>, StateDelta) {
    let beats = vec![
        PresentationBeat {
            id: format!("{}_rain", request.action_id),
            kind: BeatKind::Narration,
            speaker: None,
            text: "一阵风越过站台，雨点敲在铁棚上，短暂盖过了远处的水声。".to_owned(),
            visual: None,
        },
        PresentationBeat {
            id: format!("{}_nana", request.action_id),
            kind: BeatKind::Dialogue,
            speaker: Some("娜娜".to_owned()),
            text: "如果你还没想好，就先听一会儿雨吧。".to_owned(),
            visual: Some(VisualDirective {
                character: Some(CHARACTER_ID.to_owned()),
                expression: Some("guarded".to_owned()),
                pose: Some("holding_coat".to_owned()),
                scene: None,
            }),
        },
    ];
    let ops = state
        .clocks
        .iter()
        .any(|clock| clock.id == "clock_dawn")
        .then(|| StateOp::AdvanceClock {
            clock_id: "clock_dawn".to_owned(),
            delta: 1,
        })
        .into_iter()
        .collect();
    (beats, StateDelta { ops })
}

fn regular_turn(request: &TurnRequest) -> (Vec<PresentationBeat>, StateDelta) {
    (
        vec![
            player_action_beat(request),
            PresentationBeat {
                id: format!("{}_nana", request.action_id),
                kind: BeatKind::Dialogue,
                speaker: Some("娜娜".to_owned()),
                text: "娜娜静静听完，攥着外套的手稍微松开了一些。“我知道了。”".to_owned(),
                visual: Some(VisualDirective {
                    character: Some(CHARACTER_ID.to_owned()),
                    expression: Some("uneasy".to_owned()),
                    pose: Some("holding_coat".to_owned()),
                    scene: None,
                }),
            },
        ],
        StateDelta { ops: Vec::new() },
    )
}

fn player_action_beat(request: &TurnRequest) -> PresentationBeat {
    PresentationBeat {
        id: format!("{}_player", request.action_id),
        kind: BeatKind::Action,
        speaker: Some("你".to_owned()),
        text: request.input.clone(),
        visual: None,
    }
}

fn demo_presentation(beats: Vec<PresentationBeat>) -> PresentationSnapshot {
    let scene_id = beats
        .iter()
        .filter_map(|beat| beat.visual.as_ref())
        .filter_map(|visual| visual.scene.as_deref())
        .next_back()
        .unwrap_or("old_station_platform")
        .to_owned();
    let expression = beats
        .iter()
        .filter_map(|beat| beat.visual.as_ref())
        .filter_map(|visual| visual.expression.clone())
        .next_back();
    let pose = beats
        .iter()
        .filter_map(|beat| beat.visual.as_ref())
        .filter_map(|visual| visual.pose.clone())
        .next_back();

    PresentationSnapshot {
        scene: PresentationScene {
            id: scene_id,
            title: "旧青川站".to_owned(),
        },
        character: PresentationCharacter {
            id: CHARACTER_ID.to_owned(),
            name: "娜娜".to_owned(),
            expression,
            pose,
        },
        beats,
        suggestions: default_suggestions(),
        can_continue: true,
    }
}

fn default_suggestions() -> Vec<ActionSuggestion> {
    vec![
        ActionSuggestion {
            id: "suggestion_promise".to_owned(),
            label: "认真答应她".to_owned(),
            draft: "我答应你，天亮前一定回来。".to_owned(),
        },
        ActionSuggestion {
            id: "suggestion_ask".to_owned(),
            label: "追问原因".to_owned(),
            draft: "你为什么这么在意天亮之前？".to_owned(),
        },
        ActionSuggestion {
            id: "suggestion_observe".to_owned(),
            label: "先观察她".to_owned(),
            draft: "我没有立刻回答，先看了看她攥紧外套的手。".to_owned(),
        },
    ]
}

fn node_id_for_action(request: &TurnRequest) -> String {
    let identity = format!(
        "{}\0{}\0{}",
        request.story_id, request.branch_id, request.action_id
    );
    format!(
        "node_{}",
        stable_json_hash(identity.as_bytes()).trim_start_matches("sha256:")
    )
}

fn is_return_promise(input: &str) -> bool {
    input.contains("天亮前") && input.contains("回来")
}

fn history_label(node: &StoryNode) -> String {
    if node.parent_id.is_none() {
        return "雨夜车站".to_owned();
    }
    if node.delta.ops.iter().any(|op| {
        matches!(
            op,
            StateOp::CreatePromise { promise }
                if is_player_visible_promise(promise)
        )
    }) {
        return "天亮前的许诺".to_owned();
    }
    if node.user_input.is_empty() {
        return "雨声中的停顿".to_owned();
    }
    let summary = node.user_input.chars().take(12).collect::<String>();
    format!("回应：{summary}")
}

fn history_for(lineage: &[StoryNode], current: &StoryNode) -> Vec<HistoryNodeView> {
    lineage
        .iter()
        .map(|node| HistoryNodeView {
            id: node.id.clone(),
            parent_id: node.parent_id.clone(),
            branch_id: node.branch_id.clone(),
            label: history_label(node),
            is_current: node.id == current.id,
        })
        .collect()
}

fn last_player_relationship_update(lineage: &[StoryNode]) -> Option<&str> {
    lineage.iter().rev().find_map(|node| {
        node.delta
            .ops
            .iter()
            .any(|op| {
                matches!(
                    op,
                    StateOp::AdjustRelationship { from, to, .. }
                        if from == CHARACTER_ID && to == PLAYER_ID
                )
            })
            .then_some(node.id.as_str())
    })
}

fn is_player_visible_promise(promise: &Promise) -> bool {
    (promise.promiser == PLAYER_ID || promise.promisee == PLAYER_ID)
        && matches!(
            promise.status,
            PromiseStatus::Accepted
                | PromiseStatus::Fulfilled
                | PromiseStatus::Broken
                | PromiseStatus::Released
                | PromiseStatus::Impossible
        )
}

const fn turn_failure_code(code: &TurnFailureCode) -> &'static str {
    match code {
        TurnFailureCode::StaleNode => "stale_node",
        TurnFailureCode::InvalidInput => "invalid_input",
        TurnFailureCode::InvalidModelOutput => "invalid_model_output",
        TurnFailureCode::ProviderUnavailable => "provider_unavailable",
        TurnFailureCode::Cancelled => "cancelled",
        TurnFailureCode::TimedOut => "timed_out",
        TurnFailureCode::Internal => "internal",
    }
}

#[tauri::command]
fn get_app_info() -> AppInfo {
    AppInfo {
        name: "听娜娜讲故事".to_owned(),
        version: env!("CARGO_PKG_VERSION").to_owned(),
        contract_version: DOMAIN_SCHEMA_VERSION,
    }
}

#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
fn get_demo_player_view(state: tauri::State<'_, DemoAppState>) -> Result<PlayerView, CommandError> {
    state.current_player_view()
}

#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
fn get_demo_pack_summary(state: tauri::State<'_, DemoAppState>) -> DemoPackSummary {
    state.pack_summary()
}

#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn submit_turn(
    state: tauri::State<'_, DemoAppState>,
    request: TurnRequest,
) -> Result<TurnResult, CommandError> {
    state.submit_turn(&request)
}

/// Starts the desktop application and opens its local story database.
///
/// # Panics
///
/// Panics when the Tauri runtime cannot be started.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let app_data = app.path().app_data_dir()?;
            fs::create_dir_all(&app_data)?;
            let demo = DemoAppState::open(app_data.join("nana-story.sqlite3"))
                .map_err(|error| std::io::Error::other(error.message))?;
            app.manage(demo);
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            get_app_info,
            get_demo_pack_summary,
            get_demo_player_view,
            submit_turn
        ])
        .run(tauri::generate_context!())
        .expect("failed to run nana-story");
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        path::{Path, PathBuf},
        time::{SystemTime, UNIX_EPOCH},
    };

    use nana_domain::{
        PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
        RelationshipBand, RelationshipDimension, StateDelta, StateOp, StoryNode, TurnIntent,
        TurnRequest,
    };
    use nana_store::StoryStore;

    use super::{
        DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, history_label, last_player_relationship_update,
    };

    struct TemporaryDatabase {
        path: PathBuf,
    }

    impl TemporaryDatabase {
        fn new() -> Self {
            let nonce = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_nanos();
            Self {
                path: std::env::temp_dir().join(format!(
                    "nana-story-app-{}-{nonce}.sqlite3",
                    std::process::id()
                )),
            }
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for TemporaryDatabase {
        fn drop(&mut self) {
            if self.path.exists() {
                fs::remove_file(&self.path).expect("remove temporary app database");
            }
        }
    }

    fn promise_request(expected_node_id: &str) -> TurnRequest {
        TurnRequest {
            story_id: DEMO_STORY_ID.to_owned(),
            branch_id: DEMO_BRANCH_ID.to_owned(),
            expected_node_id: expected_node_id.to_owned(),
            action_id: "action_promise_test".to_owned(),
            intent: TurnIntent::SpeakOrAct,
            input: "我答应你，天亮前一定回来。".to_owned(),
        }
    }

    fn node(id: &str, parent_id: Option<&str>, ops: Vec<StateOp>) -> StoryNode {
        StoryNode {
            id: id.to_owned(),
            story_id: DEMO_STORY_ID.to_owned(),
            branch_id: DEMO_BRANCH_ID.to_owned(),
            parent_id: parent_id.map(str::to_owned),
            action_id: format!("action_{id}"),
            user_input: "继续前进".to_owned(),
            presentation: PresentationSnapshot::default(),
            delta: StateDelta { ops },
            state_hash: format!("hash_{id}"),
        }
    }

    #[test]
    fn embedded_demo_is_seeded_and_projected_from_runtime_state() {
        let app = DemoAppState::open_in_memory().expect("valid demo");
        let view = app.current_player_view().expect("initial PlayerView");

        assert_eq!(view.character_name, "娜娜");
        assert_eq!(view.node_id, "node_001");
        assert_eq!(view.inventory.len(), 1);
        assert_eq!(view.relationship.trust, RelationshipBand::Guarded);
        assert!(!view.beats.is_empty());
        assert_eq!(app.pack_summary().plot_modules, 1);
    }

    #[test]
    fn promise_turn_reduces_persists_and_restores_before_projection() {
        let app = DemoAppState::open_in_memory().expect("valid demo");
        let result = app
            .submit_turn(&promise_request("node_001"))
            .expect("committed turn");

        assert_eq!(result.player_view.promises.len(), 1);
        assert_eq!(result.player_view.promises[0].content, "天亮前一定回来");
        assert_eq!(result.player_view.history.len(), 2);

        let persisted = app
            .store
            .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
            .expect("persisted state");
        assert_eq!(persisted.current_node, result.committed_node_id);
        assert_eq!(persisted.relationships["nana->player"].hope, 35);

        let restored = app.current_player_view().expect("restored PlayerView");
        assert_eq!(restored.node_id, result.committed_node_id);
        assert_eq!(restored.promises, result.player_view.promises);
        assert_eq!(restored.beats, result.player_view.beats);
        assert_eq!(restored.suggestions, result.player_view.suggestions);
        assert_eq!(
            restored.character_expression,
            result.player_view.character_expression
        );
        assert_eq!(restored.character_pose, result.player_view.character_pose);
    }

    #[test]
    fn file_database_reopens_at_the_committed_player_view() {
        let database = TemporaryDatabase::new();
        let committed = {
            let app = DemoAppState::open(database.path()).expect("new file demo");
            app.submit_turn(&promise_request("node_001"))
                .expect("committed file turn")
                .player_view
        };

        let reopened = DemoAppState::open(database.path()).expect("reopened file demo");
        let restored = reopened.current_player_view().expect("restored file view");

        assert_eq!(restored.node_id, committed.node_id);
        assert_eq!(restored.promises, committed.promises);
        assert_eq!(restored.relationship, committed.relationship);
        assert_eq!(restored.history, committed.history);
        assert_eq!(restored.beats, committed.beats);
        assert_eq!(restored.suggestions, committed.suggestions);
        assert_eq!(
            restored.character_expression,
            committed.character_expression
        );
        assert_eq!(restored.character_pose, committed.character_pose);
    }

    #[test]
    fn player_view_does_not_serialize_hidden_item_facts_or_checks() {
        let app = DemoAppState::open_in_memory().expect("valid demo");
        let rendered =
            serde_json::to_string(&app.current_player_view().expect("PlayerView")).expect("JSON");

        assert!(!rendered.contains("电池仓"));
        assert!(!rendered.contains("\"checks\""));
        assert!(!rendered.contains("\"roll\""));
        assert!(!rendered.contains("\"target\""));
    }

    #[test]
    fn history_does_not_reveal_npc_only_promises() {
        let private_promise = Promise {
            id: "private".to_owned(),
            promiser: "nana".to_owned(),
            promisee: "sister".to_owned(),
            content: "不能让玩家知道".to_owned(),
            status: PromiseStatus::Accepted,
            weight: PromiseWeight::Major,
            created_at: "node_private".to_owned(),
            accepted_at: Some("node_private".to_owned()),
            resolved_at: None,
        };
        let private_node = node(
            "node_private",
            Some("node_001"),
            vec![StateOp::CreatePromise {
                promise: private_promise,
            }],
        );

        assert_eq!(history_label(&private_node), "回应：继续前进");
    }

    #[test]
    fn relationship_metadata_tracks_latest_visible_edge_across_later_nodes() {
        let root = node("node_001", None, Vec::new());
        let changed = node(
            "node_002",
            Some("node_001"),
            vec![StateOp::AdjustRelationship {
                from: "nana".to_owned(),
                to: "player".to_owned(),
                adjustment: RelationshipAdjustment {
                    dimension: RelationshipDimension::Trust,
                    delta: 2,
                    cause: "test".to_owned(),
                    judgment_rule: None,
                },
            }],
        );
        let current = node("node_003", Some("node_002"), Vec::new());

        assert_eq!(
            last_player_relationship_update(&[root, changed, current]),
            Some("node_002")
        );
    }
}
