//! smix-migrate — static maestro→smix YAML codemod.
//!
//! # Why this crate exists
//!
//! `smix run` already accepts maestro-flavored yaml directly (smix is a
//! superset of maestro). Migration is not required for correctness. But
//! when insight (or any external consumer) commits their yaml flows for
//! long-term maintenance, they want:
//!
//! 1. **Canonical verb names** — grep for `- tap:` finds every tap, not
//!    a mix of `- tapOn:` (maestro) / `- tap:` (smix). Better code
//!    review, better tooling.
//! 2. **smix-native argument shapes** — e.g. `extendedWaitUntil.timeout`
//!    becomes `expect.timeoutMs`, aligning with smix's `expect` verb
//!    family.
//! 3. **Deprecated-verb removal** — maestro-only forms flagged
//!    (`WARN:` to stderr) so consumers can decide whether to hand-edit.
//!
//! # v0.2.5 §Phase B — landing target
//!
//! - **Top-20 verb transforms** (list below in [`Migrator::default`])
//! - **Comment-preservation NOT guaranteed** for this cycle — `serde_norway`
//!   loses `#` comments during round-trip. Documented in `smix migrate --help`
//!   and warned at CLI. v0.3.0 can revisit with a comment-preserving parser
//!   (`saphyr` or `yaml-rust2`) if consumer feedback prioritizes it.
//! - **Unrecognized verbs preserved verbatim** — e.g. `runScript`,
//!   `evalScript` — a `WARN:` per unknown verb, one line to stderr.
//! - **Library + CLI** — this crate exposes `Migrator` + `MigrateReport`;
//!   `smix migrate` in the CLI is a thin wrapper.
//!
//! # Design decisions
//!
//! The codemod operates on `serde_norway::Value` (loose YAML AST). We
//! do NOT deserialize into a strongly-typed maestro schema because:
//! - Preserving unknown / smix-native verbs verbatim is required
//! - We only touch the specific keys we care about (whitelist approach)
//! - Round-tripping through a typed schema would drop any field we
//!   don't know about, breaking already-smix-native yamls
//!
//! # Anti-goals
//!
//! - Not a linter (no correctness checks; malformed yaml passes through
//!   with a parse error)
//! - Not a formatter (indentation / trailing spaces at serde_norway's
//!   discretion)
//! - Not a comment preserver (see above)

use std::fmt;

use serde_norway::Value;
use thiserror::Error;

/// Result of migrating one flow file.
#[derive(Clone, Debug, Default)]
pub struct MigrateReport {
    /// Verbs that were successfully renamed / restructured. Keys are
    /// original verb name; values are `smix-native` verb name.
    pub renamed: Vec<Rename>,
    /// Verbs the migrator does not recognize. They were left verbatim
    /// in the output. Consumers should see the `WARN:` line at the CLI
    /// layer.
    pub unknown_verbs: Vec<String>,
    /// Number of top-level steps in the flow.
    pub step_count: usize,
    /// Number of `runFlow` invocations discovered in the flow. Not
    /// migrated recursively (nested files are user's responsibility).
    pub subflow_refs: usize,
}

/// One verb rename recorded during a migration pass.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rename {
    pub from: &'static str,
    pub to: &'static str,
    /// 1-indexed step position where the rename was applied.
    pub step_index: usize,
}

impl fmt::Display for Rename {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "step #{}: {} → {}", self.step_index, self.from, self.to)
    }
}

#[derive(Debug, Error)]
pub enum MigrateError {
    #[error("failed to parse maestro yaml: {0}")]
    Parse(#[from] serde_norway::Error),
    #[error("failed to serialize smix yaml: {source}")]
    Serialize {
        #[source]
        source: serde_norway::Error,
    },
    #[error("input yaml has no top-level list (need `[header, steps...]` or `[steps...]`)")]
    Shape,
}

/// The migrator. Stateless by design — `run` is a pure fn of input.
///
/// Use [`Migrator::default`] for the standard transform table. Custom
/// consumers can construct with `Migrator::new(&[...])` to add / omit
/// rules for their yaml corpus (currently unused; opened for future
/// extension without wire-format changes).
#[derive(Clone, Debug)]
pub struct Migrator {
    rules: &'static [Rule],
}

impl Default for Migrator {
    fn default() -> Self {
        Self { rules: DEFAULT_RULES }
    }
}

impl Migrator {
    /// Migrate a maestro yaml string to smix canonical form. Returns the
    /// migrated yaml + a report. Comments in the input are lost per §
    /// design decisions above.
    pub fn migrate(&self, yaml: &str) -> Result<(String, MigrateReport), MigrateError> {
        let mut docs = serde_norway::Deserializer::from_str(yaml);
        // Maestro yamls are multi-document (`---` splits header from
        // steps in the top-level array form). serde_norway sees them as
        // one Value each. Collect all, migrate each, re-serialize joined.
        let mut out_parts: Vec<String> = Vec::new();
        let mut report = MigrateReport::default();
        // v0.2.5 §Phase B — 1-indexed step counter increments across
        // all docs (typically header doc has 0 steps; second doc has N).
        let mut step_counter = 0usize;
        while let Some(de) = docs.next() {
            let mut val: Value =
                serde_norway::with::singleton_map_recursive::deserialize(de)?;
            self.transform_value(&mut val, &mut report, &mut step_counter);
            let serialized = serde_norway::to_string(&val).map_err(|source| {
                MigrateError::Serialize { source }
            })?;
            out_parts.push(serialized);
        }
        // Re-join with `---` doc separator. serde_norway serializes each
        // doc without a leading `---` (that's a multi-doc marker inserted
        // by the stream, not per-doc). We insert one between docs.
        let out = out_parts
            .into_iter()
            .map(|s| s.trim_end().to_string())
            .collect::<Vec<_>>()
            .join("\n---\n")
            + "\n";
        Ok((out, report))
    }

    fn transform_value(
        &self,
        value: &mut Value,
        report: &mut MigrateReport,
        step_counter: &mut usize,
    ) {
        // The interesting shapes:
        //
        // 1. Top-level Value is a Sequence of steps (each step is a
        //    Mapping with one verb-key).
        // 2. Top-level Value is a Mapping (probably the flow header).
        //    Header has no verbs; walk children in case someone put
        //    steps in a nested `commands:` list (runFlow inline).
        //
        // Since we do a recursive walk regardless of shape, both are
        // covered by one code path.
        walk(value, |step| {
            *step_counter += 1;
            report.step_count += 1;
            let idx = *step_counter;
            if let Value::Mapping(map) = step {
                self.apply_step_transform(map, report, idx);
            }
        });
    }

    fn apply_step_transform(
        &self,
        map: &mut serde_norway::Mapping,
        report: &mut MigrateReport,
        idx: usize,
    ) {
        // A step is a single-key mapping in the canonical form. We look
        // at each rule's `from` verb; when present, apply.
        let mut applicable: Vec<&Rule> = Vec::new();
        for rule in self.rules {
            let key = Value::String(rule.from.to_string());
            if map.contains_key(&key) {
                applicable.push(rule);
            }
        }
        for rule in applicable {
            let from_key = Value::String(rule.from.to_string());
            let to_key = Value::String(rule.to.to_string());
            // Remove-then-insert preserves original value; apply
            // rule-specific argument transform if any.
            if let Some(mut arg_val) = map.remove(&from_key) {
                (rule.transform)(&mut arg_val);
                map.insert(to_key, arg_val);
                report.renamed.push(Rename {
                    from: rule.from,
                    to: rule.to,
                    step_index: idx,
                });
                if rule.from == "runFlow" || rule.to == "runFlow" {
                    report.subflow_refs += 1;
                }
            }
        }
        // Unknown-verb tracking: any single-key top-level map whose key
        // is NOT in a rules `from` set + NOT in the smix-native known
        // list is reported.
        if map.len() == 1 {
            if let Some((Value::String(key), _)) = map.iter().next() {
                if !is_known_verb(key) && !is_ignored_key(key) {
                    if !report.unknown_verbs.iter().any(|v| v == key) {
                        report.unknown_verbs.push(key.clone());
                    }
                }
            }
        }
    }
}

/// Walk a Value tree, invoking `visit` on every Mapping node that
/// looks like a "step" (single-key mapping with a string key).
fn walk<F: FnMut(&mut Value)>(value: &mut Value, mut visit: F) {
    walk_inner(value, &mut visit);
}

fn walk_inner<F: FnMut(&mut Value)>(value: &mut Value, visit: &mut F) {
    match value {
        Value::Sequence(seq) => {
            for item in seq {
                // v0.2.5 §Phase B — a step can be:
                //   (a) `Value::Mapping` with a single verb-key (canonical)
                //   (b) `Value::String("verb")` bare-string form (maestro
                //       accepts `- back`, `- scroll`, `- hideKeyboard`,
                //       `- waitForAnimationToEnd` etc.)
                //
                // Both need canonicalization. For (b), we visit a
                // temporary single-key mapping shim so all rules apply
                // uniformly. If the visitor renames the verb, we keep
                // bare-string shape (bare-in, bare-out — more idiomatic).
                if let Value::String(s) = item {
                    let name = s.clone();
                    // Temporarily promote to `{name: null}` so
                    // `apply_step_transform` matches rules by key.
                    let mut shim = Value::Mapping(
                        [(Value::String(name.clone()), Value::Null)].into_iter().collect(),
                    );
                    visit(&mut shim);
                    // Detect whether the visit renamed the key. If so,
                    // reproject back to bare-string.
                    if let Value::Mapping(m) = &shim {
                        if m.len() == 1 {
                            if let Some((Value::String(new_key), Value::Null)) = m.iter().next() {
                                if new_key != &name {
                                    *item = Value::String(new_key.clone());
                                }
                            } else if let Some((Value::String(new_key), _)) = m.iter().next() {
                                // Value became non-null (unlikely for
                                // string-shape input); reproject as
                                // mapping to preserve.
                                *item = Value::Mapping(m.clone());
                                let _ = new_key;
                            }
                        }
                    }
                    continue;
                }
                if let Value::Mapping(m) = item {
                    if m.len() == 1 {
                        visit(item);
                        // After the step visitor ran, walk the inner
                        // value in case it's a `runFlow` etc. that
                        // contains `commands: [...]`.
                        if let Value::Mapping(m) = item {
                            for (_, v) in m.iter_mut() {
                                walk_inner(v, visit);
                            }
                        }
                        continue;
                    }
                }
                walk_inner(item, visit);
            }
        }
        Value::Mapping(m) => {
            for (_, v) in m.iter_mut() {
                walk_inner(v, visit);
            }
        }
        _ => {}
    }
}

/// v0.2.5 §Phase B — top-20 verb rename + argument transform rules.
///
/// Each rule = (maestro verb name, smix verb name, arg transform fn).
/// Argument transform runs on the value BEFORE re-insertion under the
/// new key. Identity transform = arg carried verbatim.
#[derive(Debug)]
struct Rule {
    from: &'static str,
    to: &'static str,
    transform: fn(&mut Value),
}

fn id_transform(_: &mut Value) {}

/// `extendedWaitUntil: { visible: X, timeout: 3000 }`
/// → `expect: { visible: X, timeoutMs: 3000 }`
fn transform_extended_wait_until(arg: &mut Value) {
    rename_key(arg, "timeout", "timeoutMs");
}

/// `retry: { max: 3, ... }` → `retry: { maxRetries: 3, ... }`
fn transform_retry(arg: &mut Value) {
    rename_key(arg, "max", "maxRetries");
}

/// `launchApp: { clearState: true, ... }` — smix-native accepts the same
/// shape but strips deprecated `clearState: false` (no-op maestro form).
fn transform_launch_app(arg: &mut Value) {
    if let Value::Mapping(m) = arg {
        // Drop `clearState: false` — it's the maestro default and adds noise
        let cs_key = Value::String("clearState".to_string());
        if let Some(Value::Bool(false)) = m.get(&cs_key) {
            m.remove(&cs_key);
        }
    }
}

/// Rename an inner mapping key when it exists.
fn rename_key(val: &mut Value, from: &str, to: &str) {
    if let Value::Mapping(m) = val {
        let from_key = Value::String(from.to_string());
        let to_key = Value::String(to.to_string());
        if let Some(v) = m.remove(&from_key) {
            m.insert(to_key, v);
        }
    }
}

/// v0.2.5 §Phase B — the canonical rename table. Ordering: most common
/// first (parser walks each step against the whole table; not perf-
/// sensitive but keeps diffs clean).
static DEFAULT_RULES: &[Rule] = &[
    Rule { from: "tapOn", to: "tap", transform: id_transform },
    Rule { from: "assertVisible", to: "expect", transform: id_transform },
    Rule { from: "assertNotVisible", to: "expectNotVisible", transform: id_transform },
    Rule { from: "extendedWaitUntil", to: "expect", transform: transform_extended_wait_until },
    Rule { from: "inputText", to: "fill", transform: id_transform },
    Rule { from: "eraseText", to: "clear", transform: id_transform },
    Rule { from: "clearState", to: "reset", transform: id_transform },
    Rule { from: "clearKeychain", to: "resetKeychain", transform: id_transform },
    Rule { from: "runFlow", to: "runFlow", transform: id_transform },
    Rule { from: "launchApp", to: "launchApp", transform: transform_launch_app },
    Rule { from: "stopApp", to: "terminate", transform: id_transform },
    Rule { from: "killApp", to: "terminate", transform: id_transform },
    Rule { from: "openLink", to: "openUrl", transform: id_transform },
    Rule { from: "back", to: "pressKey", transform: id_transform },
    Rule { from: "hideKeyboard", to: "hideKeyboard", transform: id_transform },
    Rule { from: "pressKey", to: "pressKey", transform: id_transform },
    Rule { from: "scroll", to: "scroll", transform: id_transform },
    Rule { from: "scrollUntilVisible", to: "scroll", transform: id_transform },
    Rule { from: "swipe", to: "swipe", transform: id_transform },
    Rule { from: "retry", to: "retry", transform: transform_retry },
];

/// Verbs smix natively recognizes — used by the unknown-verb detector.
/// Any single-key top-level map whose key is NOT here + NOT in the
/// rename table → `WARN` line.
fn is_known_verb(v: &str) -> bool {
    matches!(
        v,
        // smix canonical + maestro-canonical that stays verbatim
        "tap" | "tapOn"
        | "expect" | "expectNotVisible" | "assertVisible" | "assertNotVisible"
        | "extendedWaitUntil"
        | "fill" | "inputText" | "clear" | "eraseText"
        | "reset" | "clearState"
        | "resetKeychain" | "clearKeychain"
        | "runFlow"
        | "launchApp" | "terminate" | "stopApp" | "killApp"
        | "openUrl" | "openLink"
        | "pressKey" | "back"
        | "hideKeyboard"
        | "scroll" | "scrollUntilVisible"
        | "swipe" | "swipeOnce"
        | "retry"
        | "takeScreenshot" | "screenshot"
        | "setClipboard" | "readClipboard"
        | "waitForAnimationToEnd"
        | "assertTrue" | "assertFalse" | "assertNotEqual" | "assertEqual"
        | "toggleAirplaneMode" | "travel"
        | "setLocation" | "startRecording" | "stopRecording"
        | "addMedia" | "assertWithAI"
        | "when"
        // smix-native
        | "ocrText" | "anchorRelative" | "tapById" | "tapAtCoord"
        | "swipeAtCoord" | "doubleTap" | "longPress"
        | "setOrientation" | "findTextByOcr"
    )
}

/// Non-verb keys we ignore for unknown-verb reporting (e.g. `when`
/// nested inside a runFlow inline).
fn is_ignored_key(v: &str) -> bool {
    matches!(v, "when" | "file" | "commands" | "label" | "config")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rename_tap_on_to_tap() {
        let yaml = "appId: com.example\n---\n- tapOn: Login\n- tapOn:\n    text: Submit\n";
        let (out, report) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("tap: Login"));
        assert!(out.contains("tap:"));
        assert!(!out.contains("tapOn:"));
        assert_eq!(report.step_count, 2);
        assert_eq!(report.renamed.len(), 2);
    }

    #[test]
    fn rename_extended_wait_until() {
        let yaml = "appId: com.example\n---\n- extendedWaitUntil:\n    visible: Ready\n    timeout: 3000\n";
        let (out, _) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("expect:"));
        assert!(out.contains("timeoutMs: 3000"));
        assert!(!out.contains("extendedWaitUntil"));
        assert!(!out.contains("timeout: 3000"));
    }

    #[test]
    fn rename_retry_max() {
        let yaml = "appId: com.example\n---\n- retry:\n    max: 3\n    commands:\n    - tapOn: X\n";
        let (out, report) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("maxRetries: 3"));
        assert!(!out.contains("max: 3"));
        assert!(out.contains("tap: X"));
        assert_eq!(report.step_count, 2);
    }

    #[test]
    fn strip_launchapp_clearstate_false() {
        let yaml = "appId: com.example\n---\n- launchApp:\n    clearState: false\n    permissions:\n      camera: allow\n";
        let (out, _) = Migrator::default().migrate(yaml).unwrap();
        assert!(!out.contains("clearState: false"));
        assert!(out.contains("permissions:"));
    }

    #[test]
    fn unknown_verb_reported_and_preserved() {
        let yaml = "appId: com.example\n---\n- runScript:\n    script: foo.js\n- tapOn: X\n";
        let (out, report) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("runScript:"));
        assert!(out.contains("tap: X"));
        assert_eq!(report.unknown_verbs, vec!["runScript"]);
    }

    #[test]
    fn native_smix_verbs_untouched() {
        let yaml = "appId: com.example\n---\n- ocrText: Sign In\n- tapById: submit\n";
        let (out, report) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("ocrText: Sign In"));
        assert!(out.contains("tapById: submit"));
        assert!(report.unknown_verbs.is_empty());
        assert!(report.renamed.is_empty());
    }

    #[test]
    fn nested_runflow_inline_walked() {
        let yaml = "appId: com.example\n---\n- runFlow:\n    when:\n      visible: Splash\n    commands:\n    - tapOn: Skip\n    - inputText: hello\n";
        let (out, report) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("tap: Skip"));
        assert!(out.contains("fill: hello"));
        // runFlow stays runFlow — only inner steps rename.
        assert!(out.contains("runFlow:"));
        assert!(report.renamed.iter().any(|r| r.to == "tap"));
        assert!(report.renamed.iter().any(|r| r.to == "fill"));
    }

    #[test]
    fn stop_app_becomes_terminate() {
        let yaml = "appId: com.example\n---\n- stopApp: com.other\n";
        let (out, _) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("terminate: com.other"));
        assert!(!out.contains("stopApp"));
    }

    #[test]
    fn multi_doc_yaml_preserves_split() {
        let yaml = "appId: com.example\n---\n- tapOn: A\n";
        let (out, _) = Migrator::default().migrate(yaml).unwrap();
        // Two docs → separator preserved
        assert!(out.contains("---"));
        assert!(out.contains("tap: A"));
    }

    #[test]
    fn parse_error_surfaces() {
        let yaml = "appId: com.example\n---\n- [unclosed\n";
        let err = Migrator::default().migrate(yaml).unwrap_err();
        matches!(err, MigrateError::Parse(_));
    }

    #[test]
    fn empty_flow_no_ops() {
        let yaml = "appId: com.example\n---\n";
        let (out, report) = Migrator::default().migrate(yaml).unwrap();
        assert!(out.contains("appId: com.example"));
        assert_eq!(report.step_count, 0);
    }
}
