use core::fmt;
use core::num::NonZeroU64;
use core::str::FromStr;
use std::path::PathBuf;

use accesskit::NodeId;
use bone_app::{FrameCount, ScrollDelta};
use bone_render::ViewportPx;
use bone_ui::WidgetId;
use bone_ui::input::{KeyCode, ModifierMask, PointerButton};
use bone_ui::theme::ThemeMode;
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct NodeRef(NonZeroU64);

impl NodeRef {
    #[must_use]
    pub const fn from_widget(id: WidgetId) -> Self {
        Self(id.raw())
    }

    #[must_use]
    pub const fn node_id(self) -> NodeId {
        NodeId(self.0.get())
    }

    #[must_use]
    pub const fn from_node_id(id: NodeId) -> Option<Self> {
        match NonZeroU64::new(id.0) {
            Some(raw) => Some(Self(raw)),
            None => None,
        }
    }
}

impl fmt::Display for NodeRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:016x}", self.0)
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Error)]
#[error("node ref {text:?} is not a non-zero 64-bit hex value")]
pub struct NodeRefParseError {
    text: String,
}

impl FromStr for NodeRef {
    type Err = NodeRefParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        u64::from_str_radix(s, 16)
            .ok()
            .and_then(NonZeroU64::new)
            .map(Self)
            .ok_or_else(|| NodeRefParseError { text: s.to_owned() })
    }
}

impl TryFrom<String> for NodeRef {
    type Error = NodeRefParseError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        value.parse()
    }
}

impl From<NodeRef> for String {
    fn from(value: NodeRef) -> Self {
        value.to_string()
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ArtifactName(String);

impl fmt::Display for ArtifactName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Error)]
#[error("artifact name {text:?} must be non-empty ascii alphanumeric with '-' or '_'")]
pub struct ArtifactNameError {
    text: String,
}

impl TryFrom<String> for ArtifactName {
    type Error = ArtifactNameError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        let valid = !value.is_empty()
            && value
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
        if valid {
            Ok(Self(value))
        } else {
            Err(ArtifactNameError { text: value })
        }
    }
}

impl From<ArtifactName> for String {
    fn from(value: ArtifactName) -> Self {
        value.0
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Modifier {
    Ctrl,
    Shift,
    Alt,
    Meta,
}

#[must_use]
pub fn modifier_mask(modifiers: &[Modifier]) -> ModifierMask {
    modifiers.iter().fold(ModifierMask::NONE, |acc, m| {
        acc.union(match m {
            Modifier::Ctrl => ModifierMask::CTRL,
            Modifier::Shift => ModifierMask::SHIFT,
            Modifier::Alt => ModifierMask::ALT,
            Modifier::Meta => ModifierMask::META,
        })
    })
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum PointerTarget {
    At { x: f64, y: f64 },
    Node(NodeRef),
    Label(String),
}

fn primary_button() -> PointerButton {
    PointerButton::Primary
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Step {
    Resize {
        width: ViewportPx,
        height: ViewportPx,
    },
    Theme(ThemeMode),
    PointerMove(PointerTarget),
    Click {
        target: PointerTarget,
        #[serde(default = "primary_button")]
        button: PointerButton,
    },
    Drag {
        from: PointerTarget,
        to: PointerTarget,
        #[serde(default = "primary_button")]
        button: PointerButton,
    },
    Wheel(ScrollDelta),
    Key {
        code: KeyCode,
        #[serde(default)]
        modifiers: Vec<Modifier>,
    },
    Text(String),
    Advance(FrameCount),
    OpenDocument(PathBuf),
    Snapshot(ArtifactName),
    DumpTree(ArtifactName),
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Scenario {
    pub steps: Vec<Step>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScenarioSource {
    Path(PathBuf),
    Stdin,
}

impl fmt::Display for ScenarioSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Path(path) => write!(f, "{}", path.display()),
            Self::Stdin => f.write_str("stdin"),
        }
    }
}

#[derive(Debug, Error)]
pub enum ScenarioError {
    #[error("read scenario from {from}: {error}")]
    Read {
        from: ScenarioSource,
        error: std::io::Error,
    },
    #[error("parse scenario: {0}")]
    Parse(#[from] ron::error::SpannedError),
}

impl Scenario {
    pub fn parse(text: &str) -> Result<Self, ron::error::SpannedError> {
        ron::de::from_str(text)
    }

    pub fn load(source: &ScenarioSource) -> Result<Self, ScenarioError> {
        let text = match source {
            ScenarioSource::Path(path) => std::fs::read_to_string(path),
            ScenarioSource::Stdin => std::io::read_to_string(std::io::stdin()),
        }
        .map_err(|error| ScenarioError::Read {
            from: source.clone(),
            error,
        })?;
        Ok(Self::parse(&text)?)
    }
}

#[cfg(test)]
mod tests {
    use bone_ui::input::NamedKey;

    use super::*;

    fn parsed(text: &str) -> Scenario {
        match Scenario::parse(text) {
            Ok(scenario) => scenario,
            Err(e) => panic!("parse failed: {e}"),
        }
    }

    fn frames(n: u32) -> FrameCount {
        match FrameCount::try_from(n) {
            Ok(count) => count,
            Err(e) => panic!("frame count: {e}"),
        }
    }

    #[test]
    fn full_step_vocabulary_parses() {
        let scenario = parsed(
            r#"Scenario(
                steps: [
                    Resize(width: 1280, height: 800),
                    OpenDocument("parts/bracket.step"),
                    Advance(3),
                    PointerMove(Label("Extrude")),
                    Click(target: At(x: 640.0, y: 400.0)),
                    Click(target: Node("00000000000000ff"), button: Secondary),
                    Drag(from: At(x: 100.0, y: 100.0), to: At(x: 220.0, y: 160.0)),
                    Wheel(Lines(x: 0.0, y: 1.0)),
                    Key(code: Named(Escape)),
                    Key(code: Char('z'), modifiers: [Ctrl, Shift]),
                    Text("12.5"),
                    Snapshot("after-extrude"),
                    DumpTree("final"),
                ],
            )"#,
        );
        assert_eq!(scenario.steps.len(), 13);
        assert_eq!(
            scenario.steps[4],
            Step::Click {
                target: PointerTarget::At { x: 640.0, y: 400.0 },
                button: PointerButton::Primary,
            },
            "click button defaults to primary",
        );
        assert_eq!(
            scenario.steps[8],
            Step::Key {
                code: KeyCode::Named(NamedKey::Escape),
                modifiers: vec![],
            },
            "key modifiers default to empty",
        );
        assert_eq!(scenario.steps[2], Step::Advance(frames(3)));
    }

    #[test]
    fn advance_rejects_zero_frames() {
        assert!(
            Scenario::parse("Scenario(steps: [Advance(0)])").is_err(),
            "a zero-frame advance is not representable",
        );
        assert!(Scenario::parse("Scenario(steps: [Advance(1)])").is_ok());
    }

    #[test]
    fn scenario_round_trips_through_ron() {
        let scenario = parsed(
            r#"Scenario(
                steps: [
                    Click(target: Label("OK")),
                    Wheel(Pixels(x: 0.0, y: -40.0)),
                    Snapshot("dialog"),
                ],
            )"#,
        );
        let text = match ron::ser::to_string(&scenario) {
            Ok(t) => t,
            Err(e) => panic!("serialize failed: {e}"),
        };
        assert_eq!(parsed(&text), scenario);
    }

    #[test]
    fn node_ref_round_trips_as_hex() {
        let node = NodeRef::from_widget(WidgetId::ROOT);
        assert_eq!(node.to_string(), "b0feb0feb0feb0fe");
        assert_eq!("b0feb0feb0feb0fe".parse(), Ok(node));
    }

    #[test]
    fn node_ref_rejects_zero_and_garbage() {
        assert!(NodeRef::from_str("0").is_err());
        assert!(NodeRef::from_str("not-hex").is_err());
        assert!(NodeRef::from_str("").is_err());
        assert!(NodeRef::from_node_id(NodeId(0)).is_none());
    }

    #[test]
    fn artifact_name_rejects_path_traversal() {
        assert!(ArtifactName::try_from("../escape".to_owned()).is_err());
        assert!(ArtifactName::try_from(String::new()).is_err());
        assert!(ArtifactName::try_from("a/b".to_owned()).is_err());
        assert!(ArtifactName::try_from("cold_shell-01".to_owned()).is_ok());
    }

    #[test]
    fn modifier_mask_folds_all_flags() {
        let mask = modifier_mask(&[Modifier::Ctrl, Modifier::Shift]);
        assert!(mask.contains(ModifierMask::CTRL));
        assert!(mask.contains(ModifierMask::SHIFT));
        assert!(!mask.contains(ModifierMask::ALT));
        assert_eq!(modifier_mask(&[]), ModifierMask::NONE);
    }

    #[test]
    fn load_reads_a_scenario_file() {
        let dir = match tempfile::tempdir() {
            Ok(d) => d,
            Err(e) => panic!("tempdir: {e}"),
        };
        let path = dir.path().join("smoke.ron");
        if let Err(e) = std::fs::write(&path, "Scenario(steps: [Advance(1)])") {
            panic!("write: {e}");
        }
        let scenario = match Scenario::load(&ScenarioSource::Path(path)) {
            Ok(s) => s,
            Err(e) => panic!("load: {e}"),
        };
        assert_eq!(scenario.steps, vec![Step::Advance(FrameCount::ONE)]);
    }

    #[test]
    fn load_reports_the_missing_path() {
        let source = ScenarioSource::Path(PathBuf::from("/nonexistent/limpet.ron"));
        match Scenario::load(&source) {
            Err(ScenarioError::Read { from, .. }) => assert_eq!(from, source),
            other => panic!("expected read error, got {other:?}"),
        }
    }
}
