use chrono::DateTime;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use futures::Future;
use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
use indexmap::IndexMap;
use itertools::Itertools;
use ratatui::{
  layout::{Constraint, Direction, Layout, Rect},
  style::{Color, Modifier, Style},
  text::{Line, Span},
  widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use tokio::{
  io::AsyncBufReadExt,
  sync::mpsc::{self, UnboundedSender},
  task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use tui_input::{backend::crossterm::EventHandler, Input};

use std::{
  process::{Command, Stdio},
  time::Duration,
};

use super::{logger::Logger, Component, Frame};
use crate::{
  action::Action,
  systemd::{self, diagnose_missing_logs, parse_journalctl_error, Scope, UnitFile, UnitId, UnitScope, UnitWithStatus},
};

#[derive(Debug, Default, Copy, Clone, PartialEq)]
pub enum Mode {
  #[default]
  Search,
  ServiceList,
  Help,
  ActionMenu,
  Processing,
  Error,
  SignalMenu,
}

#[derive(Clone, Copy)]
pub struct Theme {
  pub primary: Color,   // Cyan (dark) / Blue (light) - used in help popup
  pub accent: Color,    // LightGreen (dark) / Green (light) - borders
  pub kbd: Color,       // Gray (dark, appears white-ish) / Blue (light) - keyboard shortcuts
  pub muted: Color,     // Gray (dark) / DarkGray (light)
  pub muted_alt: Color, // DarkGray (dark) / Reset (light)
}

impl Default for Theme {
  fn default() -> Self {
    Self::detect()
  }
}

impl Theme {
  pub fn detect() -> Self {
    let is_light = terminal_light::luma().is_ok_and(|luma| luma > 0.5);

    if is_light {
      Self {
        primary: Color::Blue,
        accent: Color::Green,
        kbd: Color::Blue,
        muted: Color::DarkGray,
        muted_alt: Color::Reset,
      }
    } else {
      Self {
        primary: Color::Cyan,
        accent: Color::LightGreen,
        kbd: Color::Gray, // appears white-ish when bold on dark terminals
        muted: Color::Gray,
        muted_alt: Color::DarkGray,
      }
    }
  }
}

/// A unit with fuzzy match indices for highlighting
#[derive(Clone)]
pub struct MatchedUnit {
  pub unit: UnitWithStatus,
  pub match_indices: Vec<usize>,
}

#[derive(Default)]
pub struct Home {
  pub scope: Scope,
  pub limit_units: Vec<String>,
  pub theme: Theme,
  pub logger: Logger,
  pub show_logger: bool,
  pub all_units: IndexMap<UnitId, UnitWithStatus>,
  pub filtered_units: StatefulList<MatchedUnit>,
  pub logs: Vec<String>,
  pub logs_scroll_offset: u16,
  pub mode: Mode,
  pub previous_mode: Option<Mode>,
  pub input: Input,
  pub menu_items: StatefulList<MenuItem>,
  pub cancel_token: Option<CancellationToken>,
  pub spinner_tick: u8,
  pub error_message: String,
  pub action_tx: Option<mpsc::UnboundedSender<Action>>,
  pub journalctl_tx: Option<std::sync::mpsc::Sender<UnitId>>,
  pub fuzzy_matcher: SkimMatcherV2,
}

pub struct MenuItem {
  pub name: String,
  pub action: Action,
  pub key: Option<KeyCode>,
}

impl MenuItem {
  pub fn new(name: &str, action: Action, key: Option<KeyCode>) -> Self {
    Self { name: name.to_owned(), action, key }
  }

  pub fn key_string(&self) -> String {
    if let Some(key) = self.key {
      format!("{key}")
    } else {
      String::new()
    }
  }
}

pub struct StatefulList<T> {
  state: ListState,
  items: Vec<T>,
}

impl<T> Default for StatefulList<T> {
  fn default() -> Self {
    Self::with_items(vec![])
  }
}

impl<T> StatefulList<T> {
  pub fn with_items(items: Vec<T>) -> StatefulList<T> {
    StatefulList { state: ListState::default(), items }
  }

  #[allow(dead_code)]
  fn selected_mut(&mut self) -> Option<&mut T> {
    if self.items.is_empty() {
      return None;
    }
    match self.state.selected() {
      Some(i) => Some(&mut self.items[i]),
      None => None,
    }
  }

  fn selected(&self) -> Option<&T> {
    if self.items.is_empty() {
      return None;
    }
    match self.state.selected() {
      Some(i) => Some(&self.items[i]),
      None => None,
    }
  }

  fn next(&mut self) {
    let i = match self.state.selected() {
      Some(i) => {
        if i >= self.items.len().saturating_sub(1) {
          0
        } else {
          i + 1
        }
      },
      None => 0,
    };
    self.state.select(Some(i));
  }

  fn previous(&mut self) {
    let i = match self.state.selected() {
      Some(i) => {
        if i == 0 {
          self.items.len() - 1
        } else {
          i - 1
        }
      },
      None => 0,
    };
    self.state.select(Some(i));
  }

  fn select(&mut self, index: Option<usize>) {
    self.state.select(index);
  }

  fn unselect(&mut self) {
    self.state.select(None);
  }
}

impl Home {
  pub fn new(scope: Scope, limit_units: &[String]) -> Self {
    let limit_units = limit_units.to_vec();
    Self { scope, limit_units, ..Default::default() }
  }

  pub fn set_units(&mut self, units: Vec<UnitWithStatus>) {
    self.all_units.clear();
    for unit_status in units.into_iter() {
      self.all_units.insert(unit_status.id(), unit_status);
    }
    self.refresh_filtered_units();
  }

  pub fn sort_units(&mut self) {
    self.all_units.sort_by(|_, a, _, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
  }

  /// Merge unit file info (enablement state, file path) into existing units.
  /// Also adds units that aren't returned by ListUnits (e.g. disabled, static, masked).
  pub fn merge_unit_files(&mut self, unit_files: Vec<UnitFile>) {
    for unit_file in unit_files {
      let id = unit_file.id();
      if let Some(unit) = self.all_units.get_mut(&id) {
        // Update existing unit with enablement state and file path
        unit.enablement_state = Some(unit_file.enablement_state);
        unit.file_path = Some(Ok(unit_file.path));
      } else if unit_file.enablement_state != "generated" && unit_file.enablement_state != "alias" {
        // Add units not returned by ListUnits (disabled, static, masked, etc.)
        // Skip generated units - they're created dynamically by systemd generators and aren't user-manageable
        // Skip alias units - they're just symlinks to other units already in the list
        let new_unit = UnitWithStatus {
          name: unit_file.name,
          scope: unit_file.scope,
          description: String::new(),
          file_path: Some(Ok(unit_file.path)),
          load_state: "not-loaded".into(),
          activation_state: "inactive".into(),
          sub_state: "dead".into(),
          enablement_state: Some(unit_file.enablement_state),
        };
        self.all_units.insert(id, new_unit);
      }
    }
    self.sort_units();
    self.refresh_filtered_units();
  }

  // Update units in-place, then filter the list
  // This is inefficient but it's fast enough
  // (on gen 13 i7: ~100 microseconds to update, ~100 microseconds to filter)
  // revisit if needed
  pub fn update_units(&mut self, units: Vec<UnitWithStatus>) {
    let now = std::time::Instant::now();

    for unit in units {
      if let Some(existing) = self.all_units.get_mut(&unit.id()) {
        existing.update(unit);
      } else {
        self.all_units.insert(unit.id(), unit);
      }
    }
    info!("Updated units in {:?}", now.elapsed());

    let now = std::time::Instant::now();
    self.refresh_filtered_units();
    info!("Filtered units in {:?}", now.elapsed());
  }

  pub fn next(&mut self) {
    self.logs = vec![];
    self.filtered_units.next();
    self.get_logs();
    self.logs_scroll_offset = 0;
  }

  pub fn previous(&mut self) {
    self.logs = vec![];
    self.filtered_units.previous();
    self.get_logs();
    self.logs_scroll_offset = 0;
  }

  pub fn select(&mut self, index: Option<usize>, refresh_logs: bool) {
    if refresh_logs {
      self.logs = vec![];
    }
    self.filtered_units.select(index);
    if refresh_logs {
      self.get_logs();
      self.logs_scroll_offset = 0;
    }
  }

  pub fn unselect(&mut self) {
    self.logs = vec![];
    self.filtered_units.unselect();
  }

  pub fn selected_service(&self) -> Option<UnitId> {
    self.filtered_units.selected().map(|m| m.unit.id())
  }

  pub fn get_logs(&mut self) {
    if let Some(selected) = self.filtered_units.selected() {
      let unit_id = selected.unit.id();
      if let Err(e) = self.journalctl_tx.as_ref().unwrap().send(unit_id) {
        warn!("Error sending unit name to journalctl thread: {}", e);
      }
    } else {
      self.logs = vec![];
    }
  }

  pub fn refresh_filtered_units(&mut self) {
    let previously_selected = self.selected_service();
    let search_value = self.input.value();

    let matching: Vec<MatchedUnit> = if search_value.is_empty() {
      // No search - return all units without highlighting
      self.all_units.values().map(|u| MatchedUnit { unit: u.clone(), match_indices: vec![] }).collect()
    } else {
      // Fuzzy match with indices for highlighting
      let mut scored: Vec<(i64, MatchedUnit)> = self
        .all_units
        .values()
        .filter_map(|u| {
          self
            .fuzzy_matcher
            .fuzzy_indices(u.short_name(), search_value)
            .map(|(score, indices)| (score, MatchedUnit { unit: u.clone(), match_indices: indices }))
        })
        .collect();

      // Sort by score descending (best matches first)
      scored.sort_by(|a, b| b.0.cmp(&a.0));
      scored.into_iter().map(|(_, m)| m).collect()
    };

    self.filtered_units.items = matching;

    // try to select the same item we had selected before
    if let Some(previously_selected) = previously_selected {
      if let Some(index) = self
        .filtered_units
        .items
        .iter()
        .position(|m| m.unit.name == previously_selected.name && m.unit.scope == previously_selected.scope)
      {
        self.select(Some(index), false);
      } else {
        self.select(Some(0), true);
      }
    } else {
      // if we can't, select the first item in the list
      if !self.filtered_units.items.is_empty() {
        self.select(Some(0), true);
      } else {
        self.unselect();
      }
    }
  }

  fn start_service(&mut self, service: UnitId) {
    let cancel_token = CancellationToken::new();
    let future = systemd::start_service(service.clone(), cancel_token.clone());
    self.service_action(service, "Start".into(), cancel_token, future, false);
  }

  fn stop_service(&mut self, service: UnitId) {
    let cancel_token = CancellationToken::new();
    let future = systemd::stop_service(service.clone(), cancel_token.clone());
    self.service_action(service, "Stop".into(), cancel_token, future, false);
  }

  fn reload_service(&mut self, service: UnitId) {
    let cancel_token = CancellationToken::new();
    let future = systemd::reload(service.scope, cancel_token.clone());
    self.service_action(service, "Reload".into(), cancel_token, future, false);
  }

  fn restart_service(&mut self, service: UnitId) {
    let cancel_token = CancellationToken::new();
    let future = systemd::restart_service(service.clone(), cancel_token.clone());
    self.service_action(service, "Restart".into(), cancel_token, future, false);
  }

  fn enable_service(&mut self, service: UnitId) {
    let cancel_token = CancellationToken::new();
    let future = systemd::enable_service(service.clone(), cancel_token.clone());
    self.service_action(service, "Enable".into(), cancel_token, future, true);
  }

  fn disable_service(&mut self, service: UnitId) {
    let cancel_token = CancellationToken::new();
    let future = systemd::disable_service(service.clone(), cancel_token.clone());
    self.service_action(service, "Disable".into(), cancel_token, future, true);
  }

  fn service_action<Fut>(
    &mut self,
    service: UnitId,
    action_name: String,
    cancel_token: CancellationToken,
    action: Fut,
    refresh_unit_files: bool,
  ) where
    Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
  {
    let tx = self.action_tx.clone().unwrap();

    self.cancel_token = Some(cancel_token.clone());

    let tx_clone = tx.clone();
    let spinner_task = tokio::spawn(async move {
      let mut interval = tokio::time::interval(Duration::from_millis(200));
      loop {
        interval.tick().await;
        tx_clone.send(Action::SpinnerTick).unwrap();
      }
    });

    tokio::spawn(async move {
      tx.send(Action::EnterMode(Mode::Processing)).unwrap();
      match action.await {
        Ok(_) => {
          info!("{} of {:?} service {} succeeded", action_name, service.scope, service.name);
          tx.send(Action::EnterMode(Mode::ServiceList)).unwrap();
        },
        // would be nicer to check the error type here, but this is easier
        Err(_) if cancel_token.is_cancelled() => {
          warn!("{} of {:?} service {} was cancelled", action_name, service.scope, service.name)
        },
        Err(e) => {
          error!("{} of {:?} service {} failed: {}", action_name, service.scope, service.name, e);
          let mut error_string = e.to_string();

          if error_string.contains("AccessDenied") {
            error_string.push('\n');
            error_string.push('\n');
            error_string.push_str("Try running this tool with sudo.");
          }

          tx.send(Action::EnterError(error_string)).unwrap();
        },
      }
      spinner_task.abort();
      tx.send(Action::RefreshServices).unwrap();
      if refresh_unit_files {
        tx.send(Action::RefreshUnitFiles).unwrap();
      }

      // Refresh a bit more frequently after a service action
      for _ in 0..3 {
        tokio::time::sleep(Duration::from_secs(1)).await;
        tx.send(Action::RefreshServices).unwrap();
      }
    });
  }

  fn kill_service(&mut self, service: UnitId, signal: String) {
    let cancel_token = CancellationToken::new();
    let future = systemd::kill_service(service.clone(), signal.clone(), cancel_token.clone());
    self.service_action(service, format!("Kill with {}", signal), cancel_token, future, false);
  }
}

impl Component for Home {
  fn init(&mut self, tx: UnboundedSender<Action>) -> anyhow::Result<()> {
    self.action_tx = Some(tx.clone());
    // TODO find a better name for these. They're used to run any async data loading that needs to happen after the selection is changed,
    // not just journalctl stuff
    let (journalctl_tx, journalctl_rx) = std::sync::mpsc::channel::<UnitId>();
    self.journalctl_tx = Some(journalctl_tx);

    // TODO: move into function
    tokio::task::spawn_blocking(move || {
      let mut last_follow_handle: Option<JoinHandle<()>> = None;

      loop {
        let mut unit: UnitId = match journalctl_rx.recv() {
          Ok(unit) => unit,
          Err(_) => return,
        };

        // drain the channel, use the last value
        while let Ok(service) = journalctl_rx.try_recv() {
          info!("Skipping logs for {}...", unit.name);
          unit = service;
        }

        if let Some(handle) = last_follow_handle.take() {
          info!("Cancelling previous journalctl task");
          handle.abort();
        }

        // lazy debounce to avoid spamming journalctl on slow connections/systems
        std::thread::sleep(Duration::from_millis(100));

        // get the unit file path
        match systemd::get_unit_file_location(&unit) {
          Ok(path) => {
            let _ = tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Ok(path) });
            let _ = tx.send(Action::Render);
          },
          Err(e) => {
            // Fix this!!! Set the path to an error enum variant instead of a string
            let _ =
              tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Err("could not be determined".into()) });
            let _ = tx.send(Action::Render);
            error!("Error getting unit file path for {}: {}", unit.name, e);
          },
        }

        // First, get the N lines in a batch
        info!("Getting logs for {}", unit.name);
        let start = std::time::Instant::now();

        let mut args = vec!["--quiet", "--output=short-iso", "--lines=500", "-u"];

        args.push(&unit.name);

        if unit.scope == UnitScope::User {
          args.push("--user");
        }

        match Command::new("journalctl").args(&args).output() {
          Ok(output) => {
            if output.status.success() {
              info!("Got logs for {} in {:?}", unit.name, start.elapsed());
              if let Ok(stdout) = std::str::from_utf8(&output.stdout) {
                let mut logs = stdout.trim().split('\n').map(String::from).collect_vec();

                if logs.is_empty() || logs[0].is_empty() {
                  let diagnostic = diagnose_missing_logs(&unit);
                  logs = vec![diagnostic.message()];
                }
                let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs });
                let _ = tx.send(Action::Render);
              } else {
                warn!("Error parsing stdout for {}", unit.name);
              }
            } else {
              let stderr = String::from_utf8_lossy(&output.stderr);
              warn!("Error getting logs for {}: {}", unit.name, stderr);
              let diagnostic = parse_journalctl_error(&stderr);
              let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs: vec![diagnostic.message()] });
              let _ = tx.send(Action::Render);
            }
          },
          Err(e) => {
            warn!("Error getting logs for {}: {}", unit.name, e);
            let _ =
              tx.send(Action::SetLogs { unit: unit.clone(), logs: vec![format!("Failed to run journalctl: {}", e)] });
            let _ = tx.send(Action::Render);
          },
        }

        // Then follow the logs
        // Splitting this into two commands is a bit of a hack that makes it easier to get the initial batch of logs
        // This does mean that we'll miss any logs that are written between the two commands, low enough risk for now
        let tx = tx.clone();
        last_follow_handle = Some(tokio::spawn(async move {
          let mut command = tokio::process::Command::new("journalctl");
          command.arg("-u");
          command.arg(unit.name.clone());
          command.arg("--output=short-iso");
          command.arg("--follow");
          command.arg("--lines=0");
          command.arg("--quiet");
          command.stdout(Stdio::piped());
          command.stderr(Stdio::piped());

          if unit.scope == UnitScope::User {
            command.arg("--user");
          }

          let mut child = command.spawn().expect("failed to execute process");

          let stdout = child.stdout.take().unwrap();

          let reader = tokio::io::BufReader::new(stdout);
          let mut lines = reader.lines();
          while let Some(line) = lines.next_line().await.unwrap() {
            let _ = tx.send(Action::AppendLogLine { unit: unit.clone(), line });
            let _ = tx.send(Action::Render);
          }
        }));
      }
    });
    Ok(())
  }

  fn handle_key_events(&mut self, key: KeyEvent) -> Vec<Action> {
    if key.modifiers.contains(KeyModifiers::CONTROL) {
      match key.code {
        KeyCode::Char('c') => return vec![Action::Quit],
        KeyCode::Char('q') => return vec![Action::Quit],
        KeyCode::Char('z') => return vec![Action::Suspend],
        KeyCode::Char('f') => return vec![Action::EnterMode(Mode::Search)],
        KeyCode::Char('l') => return vec![Action::ToggleShowLogger],
        // vim keybindings, apparently
        KeyCode::Char('d') => return vec![Action::ScrollDown(1), Action::Render],
        KeyCode::Char('u') => return vec![Action::ScrollUp(1), Action::Render],
        _ => (),
      }
    }

    if matches!(key.code, KeyCode::Char('?')) || matches!(key.code, KeyCode::F(1)) {
      return vec![Action::ToggleHelp, Action::Render];
    }

    // TODO: seems like terminals can't recognize shift or ctrl at the same time as page up/down
    // Is there another way we could scroll in large increments?
    match key.code {
      KeyCode::PageDown => return vec![Action::ScrollDown(1), Action::Render],
      KeyCode::PageUp => return vec![Action::ScrollUp(1), Action::Render],
      KeyCode::Home => return vec![Action::ScrollToTop, Action::Render],
      KeyCode::End => return vec![Action::ScrollToBottom, Action::Render],
      _ => (),
    }

    match self.mode {
      Mode::ServiceList => {
        match key.code {
          KeyCode::Char('q') => vec![Action::Quit],
          KeyCode::Up | KeyCode::Char('k') => {
            // if we're filtering the list, and we're at the top, and there's text in the search box, go to search mode
            if self.filtered_units.state.selected() == Some(0) {
              return vec![Action::EnterMode(Mode::Search)];
            }

            self.previous();
            vec![Action::Render]
          },
          KeyCode::Down | KeyCode::Char('j') => {
            self.next();
            vec![Action::Render]
          },
          KeyCode::Char('/') => vec![Action::EnterMode(Mode::Search)],
          KeyCode::Char('e') => {
            if let Some(selected) = self.filtered_units.selected() {
              if let Some(Ok(file_path)) = &selected.unit.file_path {
                return vec![Action::EditUnitFile { unit: selected.unit.id(), path: file_path.clone() }];
              }
            }
            vec![]
          },
          KeyCode::Char('o') => {
            vec![Action::OpenLogsInPager { logs: self.logs.clone() }]
          },
          KeyCode::Enter | KeyCode::Char(' ') => vec![Action::EnterMode(Mode::ActionMenu)],
          _ => vec![],
        }
      },
      Mode::Help => match key.code {
        KeyCode::Esc | KeyCode::Enter => vec![Action::ToggleHelp],
        _ => vec![],
      },
      Mode::Error => match key.code {
        KeyCode::Esc | KeyCode::Enter => vec![Action::EnterMode(Mode::ServiceList)],
        _ => vec![],
      },
      Mode::Search => match key.code {
        KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
        KeyCode::Enter => vec![Action::EnterMode(Mode::ActionMenu)],
        KeyCode::Down | KeyCode::Tab => {
          self.next();
          vec![Action::EnterMode(Mode::ServiceList)]
        },
        KeyCode::Up => {
          self.previous();
          vec![Action::EnterMode(Mode::ServiceList)]
        },
        _ => {
          let prev_search_value = self.input.value().to_owned();
          self.input.handle_event(&crossterm::event::Event::Key(key));

          // if the search value changed, filter the list
          if prev_search_value != self.input.value() {
            self.refresh_filtered_units();
          }
          vec![Action::Render]
        },
      },
      Mode::ActionMenu => match key.code {
        KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
        KeyCode::Down | KeyCode::Char('j') => {
          self.menu_items.next();
          vec![Action::Render]
        },
        KeyCode::Up | KeyCode::Char('k') => {
          self.menu_items.previous();
          vec![Action::Render]
        },
        KeyCode::Enter | KeyCode::Char(' ') => match self.menu_items.selected() {
          Some(i) => vec![i.action.clone()],
          None => vec![Action::EnterMode(Mode::ServiceList)],
        },
        _ => {
          for item in self.menu_items.items.iter() {
            if let Some(key_code) = item.key {
              if key_code == key.code {
                return vec![item.action.clone()];
              }
            }
          }
          vec![]
        },
      },
      Mode::Processing => match key.code {
        KeyCode::Esc => vec![Action::CancelTask],
        _ => vec![],
      },
      Mode::SignalMenu => match key.code {
        KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
        KeyCode::Down | KeyCode::Char('j') => {
          self.menu_items.next();
          vec![Action::Render]
        },
        KeyCode::Up | KeyCode::Char('k') => {
          self.menu_items.previous();
          vec![Action::Render]
        },
        KeyCode::Enter | KeyCode::Char(' ') => match self.menu_items.selected() {
          Some(i) => vec![i.action.clone()],
          None => vec![Action::EnterMode(Mode::ServiceList)],
        },
        _ => {
          for item in self.menu_items.items.iter() {
            if let Some(key_code) = item.key {
              if key_code == key.code {
                return vec![item.action.clone()];
              }
            }
          }
          vec![]
        },
      },
    }
  }

  fn dispatch(&mut self, action: Action) -> Option<Action> {
    match action {
      Action::ToggleShowLogger => {
        self.show_logger = !self.show_logger;
        return Some(Action::Render);
      },
      Action::EnterMode(mode) => {
        if mode == Mode::ActionMenu {
          if let Some(selected) = self.filtered_units.selected() {
            let mut menu_items = vec![
              MenuItem::new("Start", Action::StartService(selected.unit.id()), Some(KeyCode::Char('s'))),
              MenuItem::new("Stop", Action::StopService(selected.unit.id()), Some(KeyCode::Char('t'))),
              MenuItem::new("Restart", Action::RestartService(selected.unit.id()), Some(KeyCode::Char('r'))),
              MenuItem::new("Reload", Action::ReloadService(selected.unit.id()), Some(KeyCode::Char('l'))),
              MenuItem::new("Enable", Action::EnableService(selected.unit.id()), Some(KeyCode::Char('n'))),
              MenuItem::new("Disable", Action::DisableService(selected.unit.id()), Some(KeyCode::Char('d'))),
              MenuItem::new("Kill", Action::EnterMode(Mode::SignalMenu), Some(KeyCode::Char('k'))),
              MenuItem::new(
                "Open logs in pager",
                Action::OpenLogsInPager { logs: self.logs.clone() },
                Some(KeyCode::Char('o')),
              ),
            ];

            if let Some(Ok(file_path)) = &selected.unit.file_path {
              menu_items.push(MenuItem::new("Copy unit file path", Action::CopyUnitFilePath, Some(KeyCode::Char('c'))));
              menu_items.push(MenuItem::new(
                "Edit unit file",
                Action::EditUnitFile { unit: selected.unit.id(), path: file_path.clone() },
                Some(KeyCode::Char('e')),
              ));
            }

            self.menu_items = StatefulList::with_items(menu_items);
            self.menu_items.state.select(Some(0));
          } else {
            return None;
          }
        } else if mode == Mode::SignalMenu {
          if let Some(selected) = self.filtered_units.selected() {
            let signals = vec![
              ("SIGTERM", KeyCode::Char('t')),
              ("SIGHUP", KeyCode::Char('h')),
              ("SIGINT", KeyCode::Char('i')),
              ("SIGQUIT", KeyCode::Char('q')),
              ("SIGKILL", KeyCode::Char('k')),
              ("SIGUSR1", KeyCode::Char('1')),
              ("SIGUSR2", KeyCode::Char('2')),
            ];

            let menu_items: Vec<MenuItem> = signals
              .into_iter()
              .map(|(name, key_code)| {
                MenuItem::new(name, Action::KillService(selected.unit.id(), name.to_string()), Some(key_code))
              })
              .collect();

            self.menu_items = StatefulList::with_items(menu_items);
            self.menu_items.state.select(Some(0));
          } else {
            return None;
          }
        }

        self.mode = mode;
        return Some(Action::Render);
      },
      Action::EnterError(err) => {
        tracing::error!(err);
        self.error_message = err;
        return Some(Action::EnterMode(Mode::Error));
      },
      Action::ToggleHelp => {
        if self.mode != Mode::Help {
          self.previous_mode = Some(self.mode);
          self.mode = Mode::Help;
        } else {
          self.mode = self.previous_mode.unwrap_or(Mode::Search);
        }
        return Some(Action::Render);
      },
      Action::CopyUnitFilePath => {
        if let Some(selected) = self.filtered_units.selected() {
          if let Some(Ok(file_path)) = &selected.unit.file_path {
            match clipboard_anywhere::set_clipboard(file_path) {
              Ok(_) => return Some(Action::EnterMode(Mode::ServiceList)),
              Err(e) => return Some(Action::EnterError(format!("Error copying to clipboard: {e}"))),
            }
          } else {
            return Some(Action::EnterError("No unit file path available".into()));
          }
        }
      },
      Action::SetUnitFilePath { unit, path } => {
        if let Some(unit) = self.all_units.get_mut(&unit) {
          unit.file_path = Some(path.clone());
        }
        self.refresh_filtered_units(); // copy the updated unit file path to the filtered list
      },
      Action::SetLogs { unit, logs } => {
        if let Some(selected) = self.filtered_units.selected() {
          if selected.unit.id() == unit {
            self.logs = logs;
          }
        }
      },
      Action::AppendLogLine { unit, line } => {
        if let Some(selected) = self.filtered_units.selected() {
          if selected.unit.id() == unit {
            self.logs.push(line);
          }
        }
      },
      Action::ScrollUp(offset) => {
        self.logs_scroll_offset = self.logs_scroll_offset.saturating_sub(offset);
        info!("scroll offset: {}", self.logs_scroll_offset);
      },
      Action::ScrollDown(offset) => {
        self.logs_scroll_offset = self.logs_scroll_offset.saturating_add(offset);
        info!("scroll offset: {}", self.logs_scroll_offset);
      },
      Action::ScrollToTop => {
        self.logs_scroll_offset = 0;
      },
      Action::ScrollToBottom => {
        // TODO: this is partially broken, figure out a better way to scroll to end
        // problem: we don't actually know the height of the paragraph before it's rendered
        // because it's wrapped based on the width of the widget
        // A proper fix might need to wait until ratatui improves scrolling: https://github.com/ratatui-org/ratatui/issues/174
        self.logs_scroll_offset = self.logs.len() as u16;
      },

      Action::StartService(service_name) => self.start_service(service_name),
      Action::StopService(service_name) => self.stop_service(service_name),
      Action::ReloadService(service_name) => self.reload_service(service_name),
      Action::RestartService(service_name) => self.restart_service(service_name),
      Action::EnableService(service_name) => self.enable_service(service_name),
      Action::DisableService(service_name) => self.disable_service(service_name),
      Action::RefreshServices => {
        let tx = self.action_tx.clone().unwrap();
        let scope = self.scope;
        let limit_units = self.limit_units.to_vec();
        tokio::spawn(async move {
          let units = systemd::get_all_services(scope, &limit_units)
            .await
            .expect("Failed to get services. Check that systemd is running and try running this tool with sudo.");
          tx.send(Action::SetServices(units)).unwrap();
        });
      },
      Action::SetServices(units) => {
        self.update_units(units);
        return Some(Action::Render);
      },
      Action::RefreshUnitFiles => {
        let tx = self.action_tx.clone().unwrap();
        let scope = self.scope;
        let limit_units = self.limit_units.clone();
        tokio::spawn(async move {
          match systemd::get_unit_files(scope, &limit_units).await {
            Ok(unit_files) => {
              let _ = tx.send(Action::SetUnitFiles(unit_files));
            },
            Err(e) => {
              error!("Failed to get unit files: {:?}", e);
            },
          }
        });
      },
      Action::SetUnitFiles(unit_files) => {
        self.merge_unit_files(unit_files);
        return Some(Action::Render);
      },
      Action::KillService(service_name, signal) => self.kill_service(service_name, signal),
      Action::SpinnerTick => {
        self.spinner_tick = self.spinner_tick.wrapping_add(1);
        return Some(Action::Render);
      },
      Action::CancelTask => {
        if let Some(cancel_token) = self.cancel_token.take() {
          cancel_token.cancel();
        }
        self.mode = Mode::ServiceList;
        return Some(Action::Render);
      },
      _ => (),
    }
    None
  }

  fn render(&mut self, f: &mut Frame<'_>, rect: Rect) {
    // Theme colors for adaptive light/dark support
    let theme = self.theme;

    fn span(s: &str, color: Color) -> Span<'_> {
      Span::styled(s, Style::default().fg(color))
    }

    fn colored_line(value: &str, color: Color) -> Line<'_> {
      Line::from(vec![Span::styled(value, Style::default().fg(color))])
    }

    let rect = if self.show_logger {
      let chunks = Layout::new(Direction::Vertical, Constraint::from_percentages([50, 50])).split(rect);

      self.logger.render(f, chunks[1]);
      chunks[0]
    } else {
      rect
    };

    let rects =
      Layout::new(Direction::Vertical, [Constraint::Min(3), Constraint::Percentage(100), Constraint::Length(1)])
        .split(rect);
    let search_panel = rects[0];
    let main_panel = rects[1];
    let help_line_rect = rects[2];

    // Helper for colouring based on the same logic as sysz
    // https://github.com/joehillen/sysz/blob/8da8e0dcbfde8d68fbdb22382671e395bd370d69/sysz#L69C1-L72C24
    //    Some units are colored based on state:
    //    green       active
    //    red         failed
    //    yellow      not-found
    fn unit_color(unit: &UnitWithStatus) -> Color {
      if unit.is_active() {
        Color::Green
      } else if unit.is_failed() {
        Color::Red
      } else if unit.is_not_found() {
        Color::Yellow
      } else {
        Color::Reset
      }
    }

    let items: Vec<ListItem> = self
      .filtered_units
      .items
      .iter()
      .map(|m| {
        let color = unit_color(&m.unit);
        let name = m.unit.short_name();

        if m.match_indices.is_empty() {
          ListItem::new(colored_line(name, color))
        } else {
          // Build spans with highlighted matched characters
          let mut spans = Vec::new();
          let mut last_end = 0;

          for &idx in &m.match_indices {
            if idx > last_end && idx <= name.len() {
              // Non-matched portion
              spans.push(Span::styled(&name[last_end..idx], Style::default().fg(color)));
            }
            // Matched character - bold + underlined
            if idx < name.len() {
              let char_end = name[idx..].chars().next().map(|c| idx + c.len_utf8()).unwrap_or(idx + 1);
              spans.push(Span::styled(
                &name[idx..char_end],
                Style::default().fg(color).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
              ));
              last_end = char_end;
            }
          }

          if last_end < name.len() {
            spans.push(Span::styled(&name[last_end..], Style::default().fg(color)));
          }

          ListItem::new(Line::from(spans))
        }
      })
      .collect();

    // Create a List from all list items and highlight the currently selected one
    let items = List::new(items)
      .block(
        Block::default()
          .borders(Borders::ALL)
          .border_type(BorderType::Rounded)
          .border_style(if self.mode == Mode::ServiceList {
            Style::default().fg(theme.accent)
          } else {
            Style::default()
          })
          .title("─Services"),
      )
      .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));

    let chunks =
      Layout::new(Direction::Horizontal, [Constraint::Min(30), Constraint::Percentage(100)]).split(main_panel);
    let right_panel = chunks[1];

    f.render_stateful_widget(items, chunks[0], &mut self.filtered_units.state);

    let selected_item = self.filtered_units.selected();

    let right_panel =
      Layout::new(Direction::Vertical, [Constraint::Min(8), Constraint::Percentage(100)]).split(right_panel);
    let details_panel = right_panel[0];
    let logs_panel = right_panel[1];

    let details_block = Block::default().title("─Details").borders(Borders::ALL).border_type(BorderType::Rounded);
    let details_panel_panes = Layout::new(Direction::Horizontal, [Constraint::Min(14), Constraint::Percentage(100)])
      .split(details_block.inner(details_panel));
    let props_pane = details_panel_panes[0];
    let values_pane = details_panel_panes[1];

    let props_lines = vec![
      Line::from("Description: "),
      Line::from("Enablement: "),
      Line::from("Scope: "),
      Line::from("Loaded: "),
      Line::from("Active: "),
      Line::from("Unit file: "),
    ];

    let details_text = if let Some(m) = selected_item {
      fn line_color_string<'a>(value: String, color: Color) -> Line<'a> {
        Line::from(vec![Span::styled(value, Style::default().fg(color))])
      }

      let load_color = match m.unit.load_state.as_str() {
        "loaded" => Color::Green,
        "not-found" => Color::Yellow,
        "error" => Color::Red,
        _ => Color::Reset,
      };

      let active_color = match m.unit.activation_state.as_str() {
        "active" => Color::Green,
        "inactive" => Color::Reset,
        "failed" => Color::Red,
        _ => Color::Reset,
      };

      let active_state_value = format!("{} ({})", m.unit.activation_state, m.unit.sub_state);

      let scope = match m.unit.scope {
        UnitScope::Global => "Global",
        UnitScope::User => "User",
      };

      let enablement_state = m.unit.enablement_state.as_deref().unwrap_or("");
      let enablement_color = match enablement_state {
        "enabled" => Color::Green,
        "disabled" => Color::Yellow,
        "masked" => Color::Red,
        _ => Color::Reset,
      };

      let lines = vec![
        colored_line(&m.unit.description, Color::Reset),
        colored_line(enablement_state, enablement_color),
        colored_line(scope, Color::Reset),
        colored_line(&m.unit.load_state, load_color),
        line_color_string(active_state_value, active_color),
        match &m.unit.file_path {
          Some(Ok(file_path)) => Line::from(file_path.as_str()),
          Some(Err(e)) => colored_line(e, Color::Red),
          None => Line::from(""),
        },
      ];

      lines
    } else {
      vec![]
    };

    let paragraph = Paragraph::new(details_text).style(Style::default());

    let props_widget = Paragraph::new(props_lines).alignment(ratatui::layout::Alignment::Right);
    f.render_widget(props_widget, props_pane);

    f.render_widget(paragraph, values_pane);
    f.render_widget(details_block, details_panel);

    let log_lines = self
      .logs
      .iter()
      .rev()
      .map(|l| {
        if let Some((timestamp, rest)) = l.split_once(' ') {
          if let Some(formatted_date) = parse_journalctl_timestamp(timestamp) {
            return Line::from(vec![
              Span::styled(formatted_date, Style::default().add_modifier(Modifier::DIM)),
              Span::raw(" "),
              Span::raw(rest),
            ]);
          }
        }

        Line::from(l.as_str())
      })
      .collect_vec();

    let paragraph = Paragraph::new(log_lines)
      .block(Block::default().title("─Service Logs").borders(Borders::ALL).border_type(BorderType::Rounded))
      .style(Style::default())
      .wrap(Wrap { trim: true })
      .scroll((self.logs_scroll_offset, 0));
    f.render_widget(paragraph, logs_panel);

    let width = search_panel.width.max(3) - 3; // keep 2 for borders and 1 for cursor
    let scroll = self.input.visual_scroll(width as usize);
    let input = Paragraph::new(self.input.value())
      .style(match self.mode {
        Mode::Search => Style::default().fg(theme.accent),
        _ => Style::default(),
      })
      .scroll((0, scroll as u16))
      .block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title(Line::from(vec![
        Span::raw("─Search "),
        Span::styled("(", Style::default().fg(theme.muted_alt)),
        Span::styled("ctrl+f", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
        Span::styled(" or ", Style::default().fg(theme.muted_alt)),
        Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
        Span::styled(")", Style::default().fg(theme.muted_alt)),
      ])));
    f.render_widget(input, search_panel);
    // clear top right of search panel so we can put help instructions there
    let help_width = 24;
    let help_area = Rect::new(search_panel.x + search_panel.width - help_width - 2, search_panel.y, help_width, 1);
    f.render_widget(Clear, help_area);
    let help_text = Paragraph::new(Line::from(vec![
      Span::raw(" Press "),
      Span::styled("?", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
      Span::raw(" or "),
      Span::styled("F1", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
      Span::raw(" for help "),
    ]))
    .style(Style::default().fg(theme.muted_alt));
    f.render_widget(help_text, help_area);

    if self.mode == Mode::Search {
      f.set_cursor_position((
        (search_panel.x + 1 + self.input.cursor() as u16).min(search_panel.x + search_panel.width - 2),
        search_panel.y + 1,
      ));
    }

    if self.mode == Mode::Help {
      let popup = centered_rect_abs(50, 18, f.area());

      let primary = |s| Span::styled(s, Style::default().fg(theme.primary));
      let help_lines = vec![
        Line::from(""),
        Line::from(Span::styled("Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
        Line::from(""),
        Line::from(vec![primary("ctrl+C"), Span::raw(" or "), primary("ctrl+Q"), Span::raw(" to quit")]),
        Line::from(vec![primary("ctrl+L"), Span::raw(" toggles the logger pane")]),
        Line::from(vec![primary("PageUp"), Span::raw(" / "), primary("PageDown"), Span::raw(" scroll the logs")]),
        Line::from(vec![primary("Home"), Span::raw(" / "), primary("End"), Span::raw(" scroll to top/bottom")]),
        Line::from(vec![primary("Enter"), Span::raw(" or "), primary("Space"), Span::raw(" open the action menu")]),
        Line::from(vec![primary("?"), Span::raw(" / "), primary("F1"), Span::raw(" open this help pane")]),
        Line::from(""),
        Line::from(Span::styled("Vim Style Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
        Line::from(""),
        Line::from(vec![primary("j"), Span::raw(" navigate down")]),
        Line::from(vec![primary("k"), Span::raw(" navigate up")]),
        Line::from(vec![primary("ctrl+U"), Span::raw(" / "), primary("ctrl+D"), Span::raw(" scroll the logs")]),
      ];

      let name = env!("CARGO_PKG_NAME");
      let version = env!("CARGO_PKG_VERSION");
      let title = format!("─Help for {name} v{version}");

      let paragraph = Paragraph::new(help_lines)
        .block(Block::default().title(title).borders(Borders::ALL).border_type(BorderType::Rounded))
        .style(Style::default())
        .wrap(Wrap { trim: true });

      f.render_widget(Clear, popup);
      f.render_widget(paragraph, popup);
    }

    if self.mode == Mode::Error {
      let popup = centered_rect_abs(50, 12, f.area());
      let error_lines = self.error_message.split('\n').map(Line::from).collect_vec();
      let paragraph = Paragraph::new(error_lines)
        .block(
          Block::default()
            .title("─Error")
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(Color::Red)),
        )
        .wrap(Wrap { trim: true });

      f.render_widget(Clear, popup);
      f.render_widget(paragraph, popup);
    }

    let selected_item = match self.filtered_units.selected() {
      Some(s) => s,
      None => return,
    };

    // Help line at the bottom

    let version = format!("v{}", env!("CARGO_PKG_VERSION"));

    let help_line_rects =
      Layout::new(Direction::Horizontal, [Constraint::Fill(1), Constraint::Length(version.len() as u16)])
        .split(help_line_rect);
    let help_rect = help_line_rects[0];
    let version_rect = help_line_rects[1];

    let help_line = match self.mode {
      Mode::Search => Line::from(span("Show actions: <enter>", theme.primary)),
      Mode::ServiceList => {
        Line::from(span("Show actions: <enter> | Open logs in pager: o | Edit unit file: e | Quit: q", theme.primary))
      },
      Mode::Help => Line::from(span("Close menu: <esc>", theme.primary)),
      Mode::ActionMenu => Line::from(span("Execute action: <enter> | Close menu: <esc>", theme.primary)),
      Mode::Processing => Line::from(span("Cancel task: <esc>", theme.primary)),
      Mode::Error => Line::from(span("Close menu: <esc>", theme.primary)),
      Mode::SignalMenu => Line::from(span("Send signal: <enter> | Close menu: <esc>", theme.primary)),
    };

    f.render_widget(help_line, help_rect);
    f.render_widget(Line::from(version), version_rect);

    let title = format!("Actions for {}", selected_item.unit.name);
    let mut min_width = title.len() as u16 + 2; // title plus corners
    min_width = min_width.max(24); // hack: the width of the longest action name + 2

    let popup_width = min_width.min(f.area().width);

    if self.mode == Mode::ActionMenu || self.mode == Mode::SignalMenu {
      let title_prefix = if self.mode == Mode::ActionMenu { "Actions" } else { "Signals" };
      let title = format!("{} for {}", title_prefix, selected_item.unit.name);
      let height = self.menu_items.items.len() as u16 + 2;
      let popup = centered_rect_abs(popup_width, height, f.area());

      let items: Vec<ListItem> = self
        .menu_items
        .items
        .iter()
        .map(|i| {
          let key_string = Span::styled(format!(" {:1} ", i.key_string()), Style::default().fg(theme.primary));
          let line = Line::from(vec![key_string, Span::raw(&i.name)]);
          ListItem::new(line)
        })
        .collect();
      let items = List::new(items)
        .block(
          Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(theme.accent))
            .title(title),
        )
        .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));

      f.render_widget(Clear, popup);
      f.render_stateful_widget(items, popup, &mut self.menu_items.state);
    }

    if self.mode == Mode::Processing {
      let height = self.menu_items.items.len() as u16 + 2;
      let popup = centered_rect_abs(popup_width, height, f.area());

      static SPINNER_CHARS: &[char] = &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];

      let spinner_char = SPINNER_CHARS[self.spinner_tick as usize % SPINNER_CHARS.len()];
      // TODO: make this a spinner
      let paragraph = Paragraph::new(vec![Line::from(format!("{spinner_char}"))])
        .block(
          Block::default()
            .title("Processing")
            .border_type(BorderType::Rounded)
            .borders(Borders::ALL)
            .border_style(Style::default().fg(theme.accent)),
        )
        .style(Style::default())
        .wrap(Wrap { trim: true });

      f.render_widget(Clear, popup);
      f.render_widget(paragraph, popup);
    }
  }
}

/// helper function to create a centered rect using up certain percentage of the available rect `r`
fn _centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
  let popup_layout = Layout::new(
    Direction::Vertical,
    [
      Constraint::Percentage((100 - percent_y) / 2),
      Constraint::Percentage(percent_y),
      Constraint::Percentage((100 - percent_y) / 2),
    ],
  )
  .split(r);

  Layout::new(
    Direction::Horizontal,
    [
      Constraint::Percentage((100 - percent_x) / 2),
      Constraint::Percentage(percent_x),
      Constraint::Percentage((100 - percent_x) / 2),
    ],
  )
  .split(popup_layout[1])[1]
}

fn centered_rect_abs(width: u16, height: u16, r: Rect) -> Rect {
  let offset_x = (r.width.saturating_sub(width)) / 2;
  let offset_y = (r.height.saturating_sub(height)) / 2;
  let width = width.min(r.width);
  let height = height.min(r.height);

  Rect::new(offset_x, offset_y, width, height)
}

/// Parse a journalctl timestamp and return a formatted date string.
///
/// systemd v255 changed the timestamp format from `-0700` to `-07:00` (RFC 3339).
/// See: https://github.com/systemd/systemd/pull/29134
fn parse_journalctl_timestamp(timestamp: &str) -> Option<String> {
  // %z accepts both "-0700" (systemd <v255) and "-07:00" (systemd >=v255)
  DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%z").ok().map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
}

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

  #[test]
  fn test_parse_timestamp_systemd_v255_and_later() {
    // systemd >=v255 uses RFC 3339 format with colon in timezone offset
    // https://github.com/systemd/systemd/pull/29134
    let timestamp = "2025-04-26T06:04:45-07:00";
    let result = parse_journalctl_timestamp(timestamp);
    assert_eq!(result, Some("2025-04-26 06:04".to_string()));
  }

  #[test]
  fn test_parse_timestamp_systemd_before_v255() {
    // systemd <v255 uses ISO 8601 format without colon in timezone offset
    let timestamp = "2025-10-06T11:07:44-0700";
    let result = parse_journalctl_timestamp(timestamp);
    assert_eq!(result, Some("2025-10-06 11:07".to_string()));
  }
}
