use nana_domain::{
    ActionSuggestion, HistoryNodeView, ItemSpec, PlayerItemView, PlayerKnowledgeView,
    PlayerPromiseView, PlayerView, PresentationBeat, PromiseStatus, RelationshipAxes,
    RelationshipBand, RelationshipView, RuntimeState,
};

/// Public scene data supplied by the presentation layer for the current node.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SceneMetadata<'a> {
    pub scene_id: &'a str,
    pub scene_title: &'a str,
    pub character_name: &'a str,
}

/// The non-persistent, already-authorized inputs needed to build a [`PlayerView`].
///
/// Runtime facts always come from `RuntimeState`. Presentation data and public
/// resource definitions are passed separately so this projection remains pure and
/// callers do not need to expose the complete runtime state to the frontend.
#[derive(Debug, Clone, Copy)]
pub struct PlayerViewProjectionContext<'a> {
    pub player_id: &'a str,
    pub relationship_character_id: &'a str,
    pub relationship_updated_at_node: Option<&'a str>,
    pub item_specs: &'a [ItemSpec],
    pub scene: SceneMetadata<'a>,
    pub beats: &'a [PresentationBeat],
    pub suggestions: &'a [ActionSuggestion],
    pub history: &'a [HistoryNodeView],
    pub can_continue: bool,
}

/// Projects private runtime state into the only state shape safe for player UI.
#[must_use]
pub fn project_player_view(
    state: &RuntimeState,
    context: &PlayerViewProjectionContext<'_>,
) -> PlayerView {
    let inventory = state
        .items
        .iter()
        .filter(|item| item.holder == context.player_id)
        .filter_map(|item| {
            let spec = context
                .item_specs
                .iter()
                .find(|spec| spec.header.id == item.spec_ref)?;
            Some(PlayerItemView {
                instance_id: item.id.clone(),
                name: spec.name.clone(),
                description: spec.description.clone(),
                quantity: item.quantity,
                placement: item.placement,
                condition: item.condition.clone(),
            })
        })
        .collect();

    let knowledge = state
        .knowledge
        .iter()
        .filter(|record| record.observer == context.player_id)
        .map(|record| PlayerKnowledgeView {
            id: record.id.clone(),
            title: record
                .subject
                .clone()
                .unwrap_or_else(|| "线索".to_owned()),
            summary: record.fact.clone(),
            certainty: record.certainty,
        })
        .collect();

    let promises = state
        .promises
        .iter()
        .filter(|promise| {
            is_visible_promise_status(promise.status)
                && (promise.promiser == context.player_id
                    || promise.promisee == context.player_id)
        })
        .map(|promise| PlayerPromiseView {
            id: promise.id.clone(),
            content: promise.content.clone(),
            status: promise.status,
            weight: promise.weight,
        })
        .collect();

    let relationship_key = format!(
        "{}->{}",
        context.relationship_character_id, context.player_id
    );
    let relationship_axes = state
        .relationships
        .get(&relationship_key)
        .copied()
        .unwrap_or_else(RelationshipAxes::neutral);

    PlayerView {
        story_id: state.story_id.clone(),
        node_id: state.current_node.clone(),
        branch_id: state.current_branch.clone(),
        scene_id: context.scene.scene_id.to_owned(),
        scene_title: context.scene.scene_title.to_owned(),
        character_name: context.scene.character_name.to_owned(),
        beats: context.beats.to_vec(),
        suggestions: context.suggestions.to_vec(),
        inventory,
        knowledge,
        promises,
        relationship: relationship_view(
            relationship_axes,
            context.relationship_updated_at_node,
        ),
        history: context.history.to_vec(),
        can_continue: context.can_continue,
    }
}

/// Converts an exact relationship value into its intentionally coarse player band.
///
/// Runtime validation constrains values to 0..=100. Values outside that range are
/// still mapped to the nearest outer band so this boundary never exposes an exact
/// value merely because it received malformed state.
#[must_use]
pub const fn relationship_band(value: i16) -> RelationshipBand {
    match value {
        ..=19 => RelationshipBand::Distant,
        20..=39 => RelationshipBand::Guarded,
        40..=59 => RelationshipBand::Warming,
        60..=79 => RelationshipBand::Close,
        80.. => RelationshipBand::Bonded,
    }
}

fn relationship_view(
    axes: RelationshipAxes,
    updated_at_node: Option<&str>,
) -> RelationshipView {
    RelationshipView {
        affinity: relationship_band(axes.affinity),
        trust: relationship_band(axes.trust),
        hope: relationship_band(axes.hope),
        respect: relationship_band(axes.respect),
        intimacy: relationship_band(axes.intimacy),
        attachment: relationship_band(axes.attachment),
        updated_at_node: updated_at_node.map(str::to_owned),
    }
}

const fn is_visible_promise_status(status: PromiseStatus) -> bool {
    matches!(
        status,
        PromiseStatus::Accepted
            | PromiseStatus::Fulfilled
            | PromiseStatus::Broken
            | PromiseStatus::Released
            | PromiseStatus::Impossible
    )
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use nana_domain::{
        AcquisitionMode, ActionSuggestion, BeatKind, CheckDifficulty, CheckRecord, CheckResult,
        HistoryNodeView, ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec,
        KnowledgeCertainty, KnowledgeRecord, PresentationBeat, Promise, PromiseStatus,
        PromiseWeight, RelationshipAxes, RelationshipBand, ResourceHeader, ResourceId,
        ResourceKind, RuntimeState, VisualDirective,
    };

    use super::{
        project_player_view, relationship_band, PlayerViewProjectionContext, SceneMetadata,
    };

    fn state() -> RuntimeState {
        RuntimeState {
            story_id: "story_demo".to_owned(),
            current_node: "node_008".to_owned(),
            current_branch: "branch_main".to_owned(),
            world_flags: BTreeMap::new(),
            relationships: BTreeMap::new(),
            relationship_states: Vec::new(),
            promises: Vec::new(),
            knowledge: Vec::new(),
            items: Vec::new(),
            clocks: Vec::new(),
            checks: Vec::new(),
        }
    }

    fn context<'a>(
        item_specs: &'a [ItemSpec],
        beats: &'a [PresentationBeat],
        suggestions: &'a [ActionSuggestion],
        history: &'a [HistoryNodeView],
    ) -> PlayerViewProjectionContext<'a> {
        PlayerViewProjectionContext {
            player_id: "player",
            relationship_character_id: "nana",
            relationship_updated_at_node: Some("node_007"),
            item_specs,
            scene: SceneMetadata {
                scene_id: "old_station",
                scene_title: "旧青川站",
                character_name: "娜娜",
            },
            beats,
            suggestions,
            history,
            can_continue: true,
        }
    }

    fn item_spec(id: &str, name: &str, description: &str, hidden_fact: &str) -> ItemSpec {
        ItemSpec {
            header: ResourceHeader {
                id: ResourceId(id.to_owned()),
                kind: ResourceKind::ItemSpec,
                schema_version: 1,
                revision: "1".to_owned(),
                content_hash: format!("sha256:{}", "0".repeat(64)),
                dependencies: Vec::new(),
            },
            name: name.to_owned(),
            description: description.to_owned(),
            tags: vec!["public_tag".to_owned()],
            lore_refs: vec!["private_lore_ref".to_owned()],
            mechanics: ItemMechanics {
                usable: true,
                grants_tags: vec!["private_mechanic".to_owned()],
                check_modifier: Some(2),
            },
            hidden_facts: vec![hidden_fact.to_owned()],
        }
    }

    fn item(id: &str, spec_ref: &str, owner: &str, holder: &str) -> ItemInstance {
        ItemInstance {
            id: id.to_owned(),
            spec_ref: ResourceId(spec_ref.to_owned()),
            owner: owner.to_owned(),
            holder: holder.to_owned(),
            placement: ItemPlacement::Bag,
            quantity: 1,
            condition: "intact".to_owned(),
            state_tags: vec!["private_instance_tag".to_owned()],
            acquisition: ItemAcquisition {
                mode: AcquisitionMode::Stolen,
                from: Some("nana".to_owned()),
                at_node: "node_006".to_owned(),
            },
        }
    }

    fn knowledge(id: &str, observer: &str, subject: Option<&str>, fact: &str) -> KnowledgeRecord {
        KnowledgeRecord {
            id: id.to_owned(),
            observer: observer.to_owned(),
            subject: subject.map(str::to_owned),
            fact: fact.to_owned(),
            certainty: KnowledgeCertainty::Confirmed,
            source: "source detail".to_owned(),
            learned_at: "node_004".to_owned(),
            last_verified_at: None,
        }
    }

    fn promise(id: &str, status: PromiseStatus) -> Promise {
        Promise {
            id: id.to_owned(),
            promiser: "player".to_owned(),
            promisee: "nana".to_owned(),
            content: format!("promise content {id}"),
            status,
            weight: PromiseWeight::Major,
            created_at: "node_001".to_owned(),
            accepted_at: (status != PromiseStatus::Proposed).then(|| "node_002".to_owned()),
            resolved_at: (!matches!(status, PromiseStatus::Proposed | PromiseStatus::Accepted))
                .then(|| "node_005".to_owned()),
        }
    }

    #[test]
    fn preserves_supplied_presentation_and_scene_metadata() {
        let original = state();
        let beats = vec![PresentationBeat {
            id: "beat_1".to_owned(),
            kind: BeatKind::Dialogue,
            speaker: Some("娜娜".to_owned()),
            text: "你回来了。".to_owned(),
            visual: Some(VisualDirective {
                character: Some("nana".to_owned()),
                expression: Some("relieved".to_owned()),
                pose: None,
                scene: Some("old_station_rain".to_owned()),
            }),
        }];
        let suggestions = vec![ActionSuggestion {
            id: "answer".to_owned(),
            label: "回答".to_owned(),
            draft: "我回来了。".to_owned(),
        }];
        let history = vec![HistoryNodeView {
            id: "node_008".to_owned(),
            parent_id: Some("node_007".to_owned()),
            branch_id: "branch_main".to_owned(),
            label: "重逢".to_owned(),
            is_current: true,
        }];

        let view = project_player_view(
            &original,
            &context(&[], &beats, &suggestions, &history),
        );

        assert_eq!(view.story_id, original.story_id);
        assert_eq!(view.node_id, original.current_node);
        assert_eq!(view.branch_id, original.current_branch);
        assert_eq!(view.scene_id, "old_station");
        assert_eq!(view.scene_title, "旧青川站");
        assert_eq!(view.character_name, "娜娜");
        assert_eq!(view.beats, beats);
        assert_eq!(view.suggestions, suggestions);
        assert_eq!(view.history, history);
        assert!(view.can_continue);
        assert_eq!(
            view.relationship.updated_at_node.as_deref(),
            Some("node_007")
        );
    }

    #[test]
    fn inventory_only_contains_player_held_items_with_public_spec_fields() {
        const HIDDEN_FACT: &str = "HIDDEN_ITEM_FACT_CANARY";
        let specs = vec![
            item_spec(
                "item.flashlight",
                "旧手电筒",
                "足以照清站台下方。",
                HIDDEN_FACT,
            ),
            item_spec(
                "item.hairpin",
                "红色发卡",
                "已经有些褪色。",
                "NPC_HIDDEN_ITEM_FACT_CANARY",
            ),
        ];
        let mut original = state();
        original.items = vec![
            item(
                "flashlight_1",
                "item.flashlight",
                "nana",
                "player",
            ),
            item("hairpin_1", "item.hairpin", "nana", "nana"),
            item("missing_spec_1", "item.missing", "player", "player"),
        ];

        let view =
            project_player_view(&original, &context(&specs, &[], &[], &[]));

        assert_eq!(view.inventory.len(), 1);
        assert_eq!(view.inventory[0].instance_id, "flashlight_1");
        assert_eq!(view.inventory[0].name, "旧手电筒");
        assert_eq!(view.inventory[0].description, "足以照清站台下方。");

        let rendered = format!("{view:?}");
        assert!(!rendered.contains(HIDDEN_FACT));
        assert!(!rendered.contains("NPC_HIDDEN_ITEM_FACT_CANARY"));
        assert!(!rendered.contains("hairpin_1"));
        assert!(!rendered.contains("private_lore_ref"));
        assert!(!rendered.contains("private_mechanic"));
        assert!(!rendered.contains("private_instance_tag"));
    }

    #[test]
    fn knowledge_only_contains_records_observed_by_player() {
        let mut original = state();
        original.knowledge = vec![
            knowledge(
                "known",
                "player",
                Some("停摆的站钟"),
                "站钟并非自然损坏。",
            ),
            knowledge(
                "npc_secret",
                "nana",
                Some("妹妹的去向"),
                "NPC_KNOWLEDGE_CANARY",
            ),
            knowledge("untitled", "player", None, "雨刚刚停了。"),
        ];

        let view =
            project_player_view(&original, &context(&[], &[], &[], &[]));

        assert_eq!(view.knowledge.len(), 2);
        assert_eq!(view.knowledge[0].id, "known");
        assert_eq!(view.knowledge[0].title, "停摆的站钟");
        assert_eq!(view.knowledge[0].summary, "站钟并非自然损坏。");
        assert_eq!(view.knowledge[1].title, "线索");
        assert!(!format!("{view:?}").contains("NPC_KNOWLEDGE_CANARY"));
    }

    #[test]
    fn promises_only_show_player_involved_accepted_and_terminal_states() {
        const NPC_PROMISE_CANARY: &str = "NPC_ONLY_ACCEPTED_PROMISE_CANARY";
        let statuses = [
            PromiseStatus::Proposed,
            PromiseStatus::Accepted,
            PromiseStatus::Fulfilled,
            PromiseStatus::Broken,
            PromiseStatus::Released,
            PromiseStatus::Impossible,
        ];
        let mut original = state();
        original.promises = statuses
            .into_iter()
            .enumerate()
            .map(|(index, status)| promise(&format!("promise_{index}"), status))
            .collect();
        original.promises.push(Promise {
            id: "npc_only".to_owned(),
            promiser: "nana".to_owned(),
            promisee: "sister".to_owned(),
            content: NPC_PROMISE_CANARY.to_owned(),
            status: PromiseStatus::Accepted,
            weight: PromiseWeight::Major,
            created_at: "node_001".to_owned(),
            accepted_at: Some("node_002".to_owned()),
            resolved_at: None,
        });

        let view =
            project_player_view(&original, &context(&[], &[], &[], &[]));

        assert_eq!(view.promises.len(), 5);
        assert!(!format!("{view:?}").contains(NPC_PROMISE_CANARY));
        assert!(
            view.promises
                .iter()
                .all(|promise| promise.status != PromiseStatus::Proposed)
        );
        for status in [
            PromiseStatus::Accepted,
            PromiseStatus::Fulfilled,
            PromiseStatus::Broken,
            PromiseStatus::Released,
            PromiseStatus::Impossible,
        ] {
            assert!(
                view.promises
                    .iter()
                    .any(|promise| promise.status == status)
            );
        }
    }

    #[test]
    fn relationship_band_thresholds_are_inclusive_and_defensive() {
        for (value, expected) in [
            (i16::MIN, RelationshipBand::Distant),
            (0, RelationshipBand::Distant),
            (19, RelationshipBand::Distant),
            (20, RelationshipBand::Guarded),
            (39, RelationshipBand::Guarded),
            (40, RelationshipBand::Warming),
            (59, RelationshipBand::Warming),
            (60, RelationshipBand::Close),
            (79, RelationshipBand::Close),
            (80, RelationshipBand::Bonded),
            (100, RelationshipBand::Bonded),
            (i16::MAX, RelationshipBand::Bonded),
        ] {
            assert_eq!(relationship_band(value), expected, "value {value}");
        }
    }

    #[test]
    fn relationship_uses_character_to_player_edge_and_hides_exact_values_and_checks() {
        let mut original = state();
        original.relationships.insert(
            "nana->player".to_owned(),
            RelationshipAxes {
                affinity: 19,
                trust: 20,
                hope: 40,
                respect: 60,
                intimacy: 80,
                attachment: 73,
            },
        );
        original
            .relationships
            .insert("player->nana".to_owned(), RelationshipAxes::neutral());
        original.checks.push(CheckRecord {
            id: "HIDDEN_CHECK_ID_CANARY".to_owned(),
            action_id: "hidden_action".to_owned(),
            actor: "player".to_owned(),
            skill: "HIDDEN_CHECK_SKILL_CANARY".to_owned(),
            target: 73,
            difficulty: CheckDifficulty::Hard,
            bonus_dice: 1,
            roll: 24,
            result: CheckResult::Success,
            pushed_from: None,
            node_id: "node_008".to_owned(),
        });

        let view =
            project_player_view(&original, &context(&[], &[], &[], &[]));

        assert_eq!(view.relationship.affinity, RelationshipBand::Distant);
        assert_eq!(view.relationship.trust, RelationshipBand::Guarded);
        assert_eq!(view.relationship.hope, RelationshipBand::Warming);
        assert_eq!(view.relationship.respect, RelationshipBand::Close);
        assert_eq!(view.relationship.intimacy, RelationshipBand::Bonded);
        assert_eq!(view.relationship.attachment, RelationshipBand::Close);

        let rendered = format!("{view:?}");
        assert!(!rendered.contains("HIDDEN_CHECK_ID_CANARY"));
        assert!(!rendered.contains("HIDDEN_CHECK_SKILL_CANARY"));
    }

    #[test]
    fn missing_relationship_projects_to_neutral_bands() {
        let view = project_player_view(
            &state(),
            &context(&[], &[], &[], &[]),
        );

        assert_eq!(view.relationship.affinity, RelationshipBand::Warming);
        assert_eq!(view.relationship.trust, RelationshipBand::Warming);
        assert_eq!(view.relationship.hope, RelationshipBand::Warming);
        assert_eq!(view.relationship.respect, RelationshipBand::Warming);
        assert_eq!(view.relationship.intimacy, RelationshipBand::Warming);
        assert_eq!(view.relationship.attachment, RelationshipBand::Warming);
    }
}
