Skip to content
//! Application state and the egui frame loop.

use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};

use twice_core::{AppSummary, Host, JobOutcome};

use crate::backend::{Backend, BackendEvent, UiCommand};
use crate::client::Lifecycle;
use crate::profiles::{self, ProfileStore, ServerProfile, default_profile_path};
use crate::views::{self, RowAction};

/// How often the application list refreshes on its own.
const AUTO_REFRESH: Duration = Duration::from_secs(5);

/// Tri-state control for the `--auto-update` flag.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum AutoUpdateChoice {
    /// Do not send the flag at all.
    #[default]
    Inherit,
    /// Send `--auto-update=true`.
    Enable,
    /// Send `--auto-update=false`.
    Disable,
}

/// State of the image update modal.
#[derive(Clone, Debug)]
pub(crate) struct UpdateDialog {
    /// Application being updated.
    pub(crate) host: Host,
    /// Image reference being typed.
    pub(crate) image: String,
    /// Whether to pin `--auto-update`.
    pub(crate) auto_update: AutoUpdateChoice,
    /// Validation failure to show under the field.
    pub(crate) error: Option<String>,
    /// Set when the operator closed the window.
    pub(crate) closed: bool,
}

/// Editable infrastructure-as-code document and its validation state.
#[derive(Clone, Debug)]
pub(crate) struct InfrastructureEditor {
    /// TOML sent to the selected server after local validation.
    pub(crate) document: String,
    /// Parse or validation failure shown next to the document.
    pub(crate) error: Option<String>,
}

impl Default for InfrastructureEditor {
    fn default() -> Self {
        Self {
            document: concat!(
                "host = \"app.example.com\"\n",
                "source = \"ghcr.io/owner/app\"\n",
                "version = \"1.0.0\"\n",
            )
            .to_owned(),
            error: None,
        }
    }
}

/// Live view of the most recent job.
#[derive(Clone, Debug)]
pub(crate) struct JobPane {
    /// Heading, e.g. `update image · app.example.com`.
    pub(crate) title: String,
    /// Output lines received so far.
    pub(crate) lines: Vec<String>,
    /// Terminal outcome once known.
    pub(crate) outcome: Option<JobOutcome>,
}

#[derive(Clone, Debug, Default)]
enum Status {
    #[default]
    Idle,
    Info(String),
    Error(String),
}

/// The whole client.
pub(crate) struct TwiceApp {
    pub(crate) backend: Backend,
    pub(crate) store: ProfileStore,
    pub(crate) draft: ServerProfile,
    pub(crate) editing_servers: bool,
    pub(crate) editing_infrastructure: bool,
    pub(crate) pending_selection: Option<usize>,
    events: Receiver<BackendEvent>,
    profile_path: PathBuf,
    apps: Vec<AppSummary>,
    status: Status,
    job: Option<JobPane>,
    dialog: Option<UpdateDialog>,
    infrastructure: InfrastructureEditor,
    last_refresh: Instant,
}

impl TwiceApp {
    /// Builds the client, loading saved profiles and connecting to the last one.
    pub(crate) fn new(ctx: &egui::Context) -> Self {
        let (backend, events) = Backend::spawn(ctx.clone());
        let profile_path =
            default_profile_path().unwrap_or_else(|_ignored| PathBuf::from("twice-ui.toml"));
        let store = ProfileStore::load(&profile_path).unwrap_or_default();
        let draft = store.current().cloned().unwrap_or_default();
        let status = store.current().map_or_else(
            || Status::Info("add a server profile to begin".to_owned()),
            |profile| {
                backend.send(UiCommand::Connect(Box::new(profile.clone())));
                Status::Info(format!("connecting to {}", profile.base_url))
            },
        );

        Self {
            backend,
            store,
            draft,
            editing_servers: false,
            editing_infrastructure: false,
            pending_selection: None,
            events,
            profile_path,
            apps: Vec::new(),
            status,
            job: None,
            dialog: None,
            infrastructure: InfrastructureEditor::default(),
            last_refresh: Instant::now(),
        }
    }

    /// Colour and text for the status area of the toolbar.
    pub(crate) fn status_line(&self) -> (egui::Color32, String) {
        match &self.status {
            Status::Idle => (egui::Color32::GRAY, String::new()),
            Status::Info(text) => (egui::Color32::GRAY, text.clone()),
            Status::Error(text) => (egui::Color32::from_rgb(220, 90, 90), text.clone()),
        }
    }

    /// Saves the draft as a new profile, or updates the selected one in place.
    pub(crate) fn save_draft(&mut self) {
        let cleaned = match profiles::normalize(&self.draft) {
            Ok(cleaned) => cleaned,
            Err(rejection) => {
                self.status = Status::Error(rejection.0);
                return;
            }
        };
        self.draft = cleaned.clone();

        if let Some(existing) = self
            .store
            .profiles
            .iter_mut()
            .find(|p| p.name == cleaned.name)
        {
            *existing = cleaned;
        } else {
            self.store.profiles.push(cleaned);
            self.store.selected = self.store.profiles.len().saturating_sub(1);
        }
        self.persist();
        self.connect_selected();
    }

    /// Removes the selected profile from local storage. Servers are untouched.
    pub(crate) fn forget_selected(&mut self) {
        if self.store.selected >= self.store.profiles.len() {
            return;
        }
        let removed = self.store.profiles.remove(self.store.selected);
        self.store.selected = 0;
        self.apps.clear();
        self.persist();
        self.status = Status::Info(format!("forgot profile {}", removed.name));
        self.connect_selected();
    }

    fn persist(&mut self) {
        if let Err(err) = self.store.save(&self.profile_path) {
            self.status = Status::Error(format!("{err:#}"));
        }
    }

    fn connect_selected(&mut self) {
        if let Some(profile) = self.store.current() {
            self.draft = profile.clone();
            self.backend
                .send(UiCommand::Connect(Box::new(profile.clone())));
        }
    }

    fn drain_events(&mut self) {
        while let Ok(event) = self.events.try_recv() {
            match event {
                BackendEvent::Apps(apps) => {
                    self.status = Status::Info(format!("{} application(s)", apps.len()));
                    self.apps = apps;
                }
                BackendEvent::JobStarted(view) => {
                    self.job = Some(JobPane {
                        title: format!("{} · {}", view.kind.label(), view.kind.host()),
                        lines: view.log.iter().map(|line| line.text.clone()).collect(),
                        outcome: None,
                    });
                }
                BackendEvent::JobLine(line) => {
                    if let Some(job) = self.job.as_mut() {
                        job.lines.push(line.text);
                    }
                }
                BackendEvent::JobFinished(outcome) => {
                    if let Some(job) = self.job.as_mut() {
                        job.outcome = Some(outcome);
                    }
                }
                BackendEvent::Failed(message) => self.status = Status::Error(message),
            }
        }
    }

    fn apply(&mut self, action: RowAction) {
        match action {
            RowAction::Start(host) => {
                self.backend.send(UiCommand::Lifecycle {
                    host,
                    op: Lifecycle::Start,
                });
            }
            RowAction::Stop(host) => {
                self.backend.send(UiCommand::Lifecycle {
                    host,
                    op: Lifecycle::Stop,
                });
            }
            RowAction::OpenUpdate(host, image) => {
                self.dialog = Some(UpdateDialog {
                    host,
                    image,
                    auto_update: AutoUpdateChoice::default(),
                    error: None,
                    closed: false,
                });
            }
        }
    }
}

impl eframe::App for TwiceApp {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        let ctx = ui.ctx().clone();
        self.drain_events();

        if let Some(index) = self.pending_selection.take() {
            self.store.selected = index;
            self.persist();
            self.connect_selected();
        }

        if self.store.current().is_some() && self.last_refresh.elapsed() >= AUTO_REFRESH {
            self.last_refresh = Instant::now();
            self.backend.send(UiCommand::RefreshApps);
        }

        egui::Panel::top("toolbar").show(ui, |ui| views::toolbar(self, ui));

        if self.editing_servers {
            egui::Panel::top("servers").show(ui, |ui| views::server_editor(self, ui));
        }

        if self.editing_infrastructure {
            let command = egui::Panel::top("infrastructure")
                .show(ui, |ui| {
                    views::infrastructure_editor(&mut self.infrastructure, ui)
                })
                .inner;
            if let Some(command) = command {
                self.backend.send(command);
            }
        }

        if let Some(job) = self.job.clone() {
            egui::Panel::bottom("job")
                .resizable(true)
                .show(ui, |ui| views::job_pane(&job, ui));
        }

        let action = egui::CentralPanel::default()
            .show(ui, |ui| {
                egui::ScrollArea::both()
                    .show(ui, |ui| views::apps_table(&self.apps, ui))
                    .inner
            })
            .inner;
        if let Some(action) = action {
            self.apply(action);
        }

        if let Some(dialog) = self.dialog.as_mut() {
            let command = views::update_dialog(dialog, &ctx);
            let closed = dialog.closed;
            if let Some(command) = command {
                self.backend.send(command);
                self.dialog = None;
            } else if closed {
                self.dialog = None;
            }
        }

        ctx.request_repaint_after(Duration::from_millis(500));
    }
}