use core::ops::ControlFlow;
use std::io::Write;
use std::path::{Path, PathBuf};

use bone_app::{AppCore, FrameCount, FrameTarget, InputEvent, KeyDown, NavKey, WindowPoint};
use bone_render::{
    OffscreenContext, PickIdError, PickIndex, Picker, RenderError, ViewportExtent, encode_png,
};
use bone_ui::input::{KeyCode, ModifierMask, NamedKey};
use serde::Serialize;
use thiserror::Error;

use crate::scenario::{PointerTarget, Scenario, Step, modifier_mask};
use crate::target::resolve_target;
use crate::tree::TreeDump;
use crate::{DEFAULT_VIEWPORT, offscreen_context};

#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
pub enum RunStatus {
    Passed,
    Failed,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub enum StepOutcome {
    Ok,
    Failed(String),
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct StepRecord {
    pub step: Step,
    pub outcome: StepOutcome,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct RunResult {
    pub steps: Vec<StepRecord>,
    pub status: RunStatus,
}

#[derive(Debug, Error)]
pub enum JigError {
    #[error("render: {0}")]
    Render(#[from] RenderError),
    #[error("pick id space: {0}")]
    PickId(#[from] PickIdError),
    #[error("{path}: {error}")]
    Io {
        path: PathBuf,
        error: std::io::Error,
    },
    #[error("encode result: {0}")]
    ResultEncode(#[from] ron::Error),
}

struct OffscreenTarget(OffscreenContext);

impl FrameTarget for OffscreenTarget {
    fn picker(&self, index: PickIndex) -> Picker<'_> {
        self.0.picker(index)
    }

    fn render(
        &mut self,
        build_passes: impl FnOnce(
            &mut wgpu::CommandEncoder,
            &wgpu::TextureView,
            &wgpu::TextureView,
            &wgpu::TextureView,
        ),
    ) {
        self.0.render_passes(build_passes);
    }
}

pub fn run_scenario(
    scenario: &Scenario,
    out_dir: &Path,
    sink: &mut impl Write,
) -> Result<RunResult, JigError> {
    std::fs::create_dir_all(out_dir).map_err(|error| JigError::Io {
        path: out_dir.to_path_buf(),
        error,
    })?;
    let context = offscreen_context(DEFAULT_VIEWPORT)?;
    let core = AppCore::new(context.gpu(), context.color_format(), DEFAULT_VIEWPORT)?;
    let mut session = Session {
        core,
        target: OffscreenTarget(context),
        out_dir,
        sink,
    };
    session.frame();
    let outcome =
        scenario
            .steps
            .iter()
            .enumerate()
            .try_fold(Vec::new(), |mut records, (index, step)| {
                let (outcome, flow) = match session.exec(index, step) {
                    Ok(()) => (StepOutcome::Ok, ControlFlow::Continue(())),
                    Err(message) => (StepOutcome::Failed(message), ControlFlow::Break(())),
                };
                records.push(StepRecord {
                    step: step.clone(),
                    outcome,
                });
                match flow {
                    ControlFlow::Continue(()) => ControlFlow::Continue(records),
                    ControlFlow::Break(()) => ControlFlow::Break(records),
                }
            });
    let (steps, status) = match outcome {
        ControlFlow::Continue(records) => (records, RunStatus::Passed),
        ControlFlow::Break(records) => (records, RunStatus::Failed),
    };
    let result = RunResult { steps, status };
    let text = ron::ser::to_string_pretty(&result, ron::ser::PrettyConfig::default())?;
    let path = out_dir.join("result.ron");
    std::fs::write(&path, text).map_err(|error| JigError::Io { path, error })?;
    Ok(result)
}

struct Session<'a, W: Write> {
    core: AppCore,
    target: OffscreenTarget,
    out_dir: &'a Path,
    sink: &'a mut W,
}

impl<W: Write> Session<'_, W> {
    fn exec(&mut self, index: usize, step: &Step) -> Result<(), String> {
        match step {
            Step::Resize { width, height } => {
                let extent = ViewportExtent::new(*width, *height);
                self.target.0.resize(extent).map_err(|e| e.to_string())?;
                self.input(InputEvent::Resize(extent));
                self.frame();
                Ok(())
            }
            Step::Theme(mode) => {
                self.core.set_theme(*mode);
                self.frame();
                Ok(())
            }
            Step::PointerMove(target) => {
                let point = self.resolve(index, target)?;
                self.input(InputEvent::CursorMove(point));
                self.frame();
                Ok(())
            }
            Step::Click { target, button } => {
                let point = self.resolve(index, target)?;
                self.input(InputEvent::CursorMove(point));
                self.frame();
                self.press_release(*button);
                Ok(())
            }
            Step::Drag { from, to, button } => {
                let start = self.resolve(index, from)?;
                self.input(InputEvent::CursorMove(start));
                self.frame();
                self.input(InputEvent::Pointer {
                    button: *button,
                    pressed: true,
                });
                self.frame();
                let end = self.resolve(index, to)?;
                self.input(InputEvent::CursorMove(end));
                self.frame();
                self.input(InputEvent::Pointer {
                    button: *button,
                    pressed: false,
                });
                self.frame();
                self.frame();
                self.frame();
                Ok(())
            }
            Step::Wheel(delta) => {
                self.input(InputEvent::Wheel(*delta));
                self.frame();
                Ok(())
            }
            Step::Key { code, modifiers } => {
                self.key_down(*code, modifiers);
                Ok(())
            }
            Step::Text(text) => {
                self.input(InputEvent::KeyDown(KeyDown {
                    code: None,
                    nav: None,
                    text: Some(text.clone()),
                    repeat: false,
                }));
                self.frame();
                Ok(())
            }
            Step::Advance(frames) => {
                (0..frames.get()).for_each(|_| self.frame());
                Ok(())
            }
            Step::OpenDocument(path) => {
                self.core
                    .open_document(path.clone())
                    .map_err(|e| e.to_string())?;
                self.frame();
                Ok(())
            }
            Step::Snapshot(name) => {
                let frame = self.target.0.capture().map_err(|e| e.to_string())?;
                let png = encode_png(&frame).map_err(|e| e.to_string())?;
                self.write_file(&artifact_file(index, &name.to_string(), "png"), &png)
            }
            Step::DumpTree(name) => {
                let dump = self.dump()?;
                let file = artifact_file(index, &name.to_string(), "tree.json");
                self.write_tree(&file, &dump)?;
                writeln!(self.sink, "# {file}").map_err(|e| e.to_string())?;
                self.sink
                    .write_all(dump.to_text().as_bytes())
                    .map_err(|e| e.to_string())
            }
        }
    }

    fn resolve(&mut self, index: usize, target: &PointerTarget) -> Result<WindowPoint, String> {
        let update = self.core.access_tree();
        resolve_target(target, &update).map_err(|resolve_error| {
            let file = artifact_file(index, "unresolved", "tree.json");
            let flushed = TreeDump::from_update(&update)
                .map_err(|e| e.to_string())
                .and_then(|dump| self.write_tree(&file, &dump));
            match flushed {
                Ok(()) => format!("{resolve_error}. searched tree written to {file}"),
                Err(write_error) => format!("{resolve_error}. searched tree lost: {write_error}"),
            }
        })
    }

    fn dump(&self) -> Result<TreeDump, String> {
        TreeDump::from_update(&self.core.access_tree()).map_err(|e| e.to_string())
    }

    fn write_tree(&self, file: &str, dump: &TreeDump) -> Result<(), String> {
        let json = serde_json::to_vec_pretty(dump).map_err(|e| e.to_string())?;
        self.write_file(file, &json)
    }

    fn write_file(&self, file: &str, bytes: &[u8]) -> Result<(), String> {
        let path = self.out_dir.join(file);
        std::fs::write(&path, bytes).map_err(|e| format!("write {}: {e}", path.display()))
    }

    fn key_down(&mut self, code: KeyCode, modifiers: &[crate::scenario::Modifier]) {
        let mask = modifier_mask(modifiers);
        if mask != ModifierMask::NONE {
            self.input(InputEvent::Modifiers(mask));
        }
        self.input(InputEvent::KeyDown(KeyDown {
            code: Some(code),
            nav: nav_key(code),
            text: None,
            repeat: false,
        }));
        self.frame();
        if mask != ModifierMask::NONE {
            self.input(InputEvent::Modifiers(ModifierMask::NONE));
            self.frame();
        }
    }

    fn press_release(&mut self, button: bone_ui::input::PointerButton) {
        self.input(InputEvent::Pointer {
            button,
            pressed: true,
        });
        self.frame();
        self.input(InputEvent::Pointer {
            button,
            pressed: false,
        });
        self.frame();
        self.frame();
        self.frame();
    }

    fn input(&mut self, event: InputEvent) {
        let _ack = self.core.handle_input(&self.target, event);
    }

    fn frame(&mut self) {
        self.core.clock_mut().advance(FrameCount::ONE);
        let _report = self.core.render_frame(&mut self.target);
    }
}

fn artifact_file(index: usize, name: &str, ext: &str) -> String {
    format!("{index:03}_{name}.{ext}")
}

fn nav_key(code: KeyCode) -> Option<NavKey> {
    match code {
        KeyCode::Named(NamedKey::ArrowLeft) => Some(NavKey::Left),
        KeyCode::Named(NamedKey::ArrowRight) => Some(NavKey::Right),
        KeyCode::Named(NamedKey::ArrowUp) => Some(NavKey::Up),
        KeyCode::Named(NamedKey::ArrowDown) => Some(NavKey::Down),
        KeyCode::Named(_) => None,
        KeyCode::Char(c) => match c.get().to_ascii_lowercase() {
            'z' => Some(NavKey::Zoom),
            '=' | '+' => Some(NavKey::ZoomIn),
            '-' | '_' => Some(NavKey::ZoomOut),
            _ => None,
        },
    }
}

#[cfg(test)]
mod tests {
    use crate::scenario::Scenario;

    use super::*;

    fn run_in_tempdir(scenario_text: &str) -> (tempfile::TempDir, RunResult, String) {
        let dir = match tempfile::tempdir() {
            Ok(d) => d,
            Err(e) => panic!("tempdir: {e}"),
        };
        let scenario = match Scenario::parse(scenario_text) {
            Ok(s) => s,
            Err(e) => panic!("parse: {e}"),
        };
        let mut sink = Vec::new();
        let result = match run_scenario(&scenario, dir.path(), &mut sink) {
            Ok(r) => r,
            Err(e) => panic!("run: {e}"),
        };
        let text = String::from_utf8_lossy(&sink).into_owned();
        (dir, result, text)
    }

    #[test]
    fn snapshot_and_dump_write_numbered_artifacts() {
        let (dir, result, text) = run_in_tempdir(
            r#"Scenario(
                steps: [
                    Advance(2),
                    Snapshot("shell"),
                    DumpTree("shell"),
                ],
            )"#,
        );
        assert_eq!(result.status, RunStatus::Passed);
        assert_eq!(result.steps.len(), 3);
        let png = match std::fs::read(dir.path().join("001_shell.png")) {
            Ok(bytes) => bytes,
            Err(e) => panic!("png: {e}"),
        };
        assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
        assert!(dir.path().join("002_shell.tree.json").is_file());
        assert!(dir.path().join("result.ron").is_file());
        assert!(text.starts_with("# 002_shell.tree.json\n"));
        assert!(text.contains("window"), "tree text was: {text}");
    }

    #[test]
    fn failed_resolution_flushes_the_searched_tree_and_halts() {
        let (dir, result, _text) = run_in_tempdir(
            r#"Scenario(
                steps: [
                    Snapshot("before"),
                    Click(target: Label("No Such Widget")),
                    Snapshot("after"),
                ],
            )"#,
        );
        assert_eq!(result.status, RunStatus::Failed);
        assert_eq!(
            result.steps.len(),
            2,
            "steps after the failure must not run"
        );
        assert!(matches!(result.steps[1].outcome, StepOutcome::Failed(_)));
        assert!(dir.path().join("000_before.png").is_file());
        assert!(dir.path().join("001_unresolved.tree.json").is_file());
        let result_text = match std::fs::read_to_string(dir.path().join("result.ron")) {
            Ok(t) => t,
            Err(e) => panic!("result.ron: {e}"),
        };
        assert!(result_text.contains("Failed"), "result was: {result_text}");
    }

    #[test]
    fn nav_key_zoom_is_case_insensitive() {
        use bone_ui::input::KeyChar;
        let zoom = |c| nav_key(KeyCode::Char(KeyChar::from_char(c)));
        assert_eq!(zoom('z'), Some(NavKey::Zoom));
        assert_eq!(zoom('Z'), Some(NavKey::Zoom));
        assert_eq!(zoom('='), Some(NavKey::ZoomIn));
        assert_eq!(zoom('+'), Some(NavKey::ZoomIn));
        assert_eq!(zoom('-'), Some(NavKey::ZoomOut));
        assert_eq!(zoom('_'), Some(NavKey::ZoomOut));
    }

    #[test]
    fn clicking_a_labeled_button_resolves_and_passes() {
        let (_dir, result, _text) = run_in_tempdir(
            r#"Scenario(
                steps: [
                    Advance(1),
                    Click(target: Label("File")),
                    DumpTree("after-click"),
                ],
            )"#,
        );
        assert_eq!(result.status, RunStatus::Passed);
    }
}
