use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use editor_ui::theme::{EditorVisualPalette, BLACKSITE_PALETTE};
use serde::{Deserialize, Serialize};

use crate::fixtures::FixtureOverrides;

const POLL_INTERVAL: Duration = Duration::from_millis(300);

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeOverrides {
    pub canvas: Option<String>,
    pub panel: Option<String>,
    pub panel_dark: Option<String>,
    pub widget: Option<String>,
    pub elevated: Option<String>,
    pub accent: Option<String>,
    pub selection: Option<String>,
    pub text: Option<String>,
    pub text_dim: Option<String>,
    pub text_muted: Option<String>,
    pub border: Option<String>,
    pub border_strong: Option<String>,
    pub success: Option<String>,
    pub warning: Option<String>,
    pub error: Option<String>,
}

impl ThemeOverrides {
    pub fn palette(&self) -> Result<EditorVisualPalette, String> {
        let mut palette = BLACKSITE_PALETTE;
        macro_rules! apply {
            ($field:ident) => {
                if let Some(value) = self.$field.as_deref() {
                    palette.$field = parse_color(value)?;
                }
            };
        }
        apply!(canvas);
        apply!(panel);
        apply!(panel_dark);
        apply!(widget);
        apply!(elevated);
        apply!(accent);
        apply!(selection);
        apply!(text);
        apply!(text_dim);
        apply!(text_muted);
        apply!(border);
        apply!(border_strong);
        apply!(success);
        apply!(warning);
        apply!(error);
        Ok(palette)
    }
}

pub struct RuntimeDocuments {
    theme: ReloadableDocument<ThemeOverrides>,
    fixture: ReloadableDocument<FixtureOverrides>,
    pub theme_value: ThemeOverrides,
    pub fixture_value: FixtureOverrides,
    pub error: Option<String>,
}

impl RuntimeDocuments {
    pub fn new(root: &Path) -> Self {
        Self::from_directory(root.join("crates/ui_gallery/fixtures"))
    }

    fn from_directory(directory: PathBuf) -> Self {
        Self {
            theme: ReloadableDocument::new(directory.join("theme.json")),
            fixture: ReloadableDocument::new(directory.join("material.json")),
            theme_value: ThemeOverrides::default(),
            fixture_value: FixtureOverrides::default(),
            error: None,
        }
    }

    pub fn poll(&mut self, now: f64) -> bool {
        let mut changed = false;
        match self.theme.poll(now) {
            Ok(Some(value)) => {
                self.theme_value = value;
                self.error = None;
                changed = true;
            }
            Err(error) => self.error = Some(error),
            Ok(None) => {}
        }
        match self.fixture.poll(now) {
            Ok(Some(value)) => {
                self.fixture_value = value;
                self.error = None;
                changed = true;
            }
            Err(error) => self.error = Some(error),
            Ok(None) => {}
        }
        changed
    }
}

struct ReloadableDocument<T> {
    path: PathBuf,
    modified: Option<SystemTime>,
    next_poll: f64,
    marker: std::marker::PhantomData<T>,
}

impl<T> ReloadableDocument<T>
where
    T: for<'de> Deserialize<'de>,
{
    fn new(path: PathBuf) -> Self {
        Self {
            path,
            modified: None,
            next_poll: 0.0,
            marker: std::marker::PhantomData,
        }
    }

    fn poll(&mut self, now: f64) -> Result<Option<T>, String> {
        if now < self.next_poll {
            return Ok(None);
        }
        self.next_poll = now + POLL_INTERVAL.as_secs_f64();
        let metadata = fs::metadata(&self.path)
            .map_err(|error| format!("{}: {error}", self.path.display()))?;
        let modified = metadata.modified().ok();
        if self.modified == modified {
            return Ok(None);
        }
        let bytes =
            fs::read(&self.path).map_err(|error| format!("{}: {error}", self.path.display()))?;
        let value = serde_json::from_slice(&bytes)
            .map_err(|error| format!("{}: {error}", self.path.display()))?;
        self.modified = modified;
        Ok(Some(value))
    }
}

fn parse_color(value: &str) -> Result<egui::Color32, String> {
    let value = value.trim().trim_start_matches('#');
    if value.len() != 6 && value.len() != 8 {
        return Err(format!("expected #RRGGBB or #RRGGBBAA, got {value}"));
    }
    let byte = |offset| {
        u8::from_str_radix(&value[offset..offset + 2], 16)
            .map_err(|error| format!("invalid color {value}: {error}"))
    };
    Ok(egui::Color32::from_rgba_unmultiplied(
        byte(0)?,
        byte(2)?,
        byte(4)?,
        if value.len() == 8 { byte(6)? } else { 255 },
    ))
}

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

    fn temporary_directory() -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .expect("system clock after epoch")
            .as_nanos();
        let directory = std::env::temp_dir().join(format!(
            "blacksite-ui-gallery-reload-{}-{nonce}",
            std::process::id()
        ));
        fs::create_dir_all(&directory).expect("create temporary fixture directory");
        directory
    }

    #[test]
    fn runtime_documents_reload_fixture_without_reconstruction() {
        let directory = temporary_directory();
        fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture");
        fs::write(directory.join("material.json"), "{}\n").expect("write material fixture");
        let mut documents = RuntimeDocuments::from_directory(directory.clone());
        assert!(documents.poll(0.0));
        assert_eq!(documents.fixture_value.material_label, None);

        fs::write(
            directory.join("material.json"),
            r#"{"material_label":"LIVE_RELOAD_PROOF"}"#,
        )
        .expect("update material fixture");
        assert!(documents.poll(1.0));
        assert_eq!(
            documents.fixture_value.material_label.as_deref(),
            Some("LIVE_RELOAD_PROOF")
        );
        assert_eq!(documents.error, None);
        fs::remove_dir_all(directory).expect("remove temporary fixture directory");
    }

    #[test]
    fn invalid_reload_keeps_the_last_valid_document_and_reports_the_error() {
        let directory = temporary_directory();
        fs::write(directory.join("theme.json"), "{}\n").expect("write theme fixture");
        fs::write(
            directory.join("material.json"),
            r#"{"material_label":"baseline"}"#,
        )
        .expect("write material fixture");
        let mut documents = RuntimeDocuments::from_directory(directory.clone());
        assert!(documents.poll(0.0));

        fs::write(directory.join("material.json"), "{ invalid json\n")
            .expect("write invalid material fixture");
        assert!(!documents.poll(1.0));
        assert_eq!(
            documents.fixture_value.material_label.as_deref(),
            Some("baseline")
        );
        assert!(documents
            .error
            .as_deref()
            .is_some_and(|error| error.contains("material.json")));
        fs::remove_dir_all(directory).expect("remove temporary fixture directory");
    }

    #[test]
    fn theme_colors_accept_rgb_and_rgba_hex() {
        assert_eq!(
            parse_color("#41C6CF").expect("valid RGB"),
            egui::Color32::from_rgb(65, 198, 207)
        );
        assert_eq!(
            parse_color("E25E5E80").expect("valid RGBA"),
            egui::Color32::from_rgba_unmultiplied(226, 94, 94, 128)
        );
        assert!(parse_color("#not-a-color").is_err());
    }
}
