//! TUI state: which pipeline tab is open, which job is selected, scroll
//! positions, detail panel width, and reload flag.
//!
//! The event loop in `main.rs` feeds key/mouse events in, the [`ui`](crate::ui)
//! module reads `App` to render each frame.

use std::collections::HashMap;
use std::path::PathBuf;

use unicode_width::UnicodeWidthStr;

use crate::container::ContainerConfig;
use crate::graph::{GraphLayout, compute_layout};
use crate::model::{CiSystem, Pipeline};
use crate::parser::gitlab_fetch::GitlabConfig;
use crate::resolve::GitContext;
use crate::ui::job_detail::DetailTab;
use crate::ui::{COL_WIDTH, JOB_BOX_HEIGHT, JOB_BOX_WIDTH, JOB_GAP};

/// Tab bar layout: each title padded with a space on each side, separated by "│".
/// Ratatui's Tabs widget renders as: " Title1 │ Title2 │ Title3 "
const TAB_TITLES: &[&str] = &["Config", "Script", "Variables", "Debug"];

pub enum InputResult {
    Continue,
    Quit,
    Reload,
    LaunchContainer(Box<ContainerConfig>),
    RunJob(Box<ContainerConfig>),
    OpenChildPipeline(PathBuf),
    /// Open a step-pipeline tab for the selected job's steps.
    OpenStepPipeline,
    /// Open $EDITOR on job variables; edited values are stored per-job.
    EditVariables(String),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
    Dag,
    Detail,
}

/// Per-pipeline state, one per tab.
pub struct PipelineTab {
    pub file_path: PathBuf,
    pub pipeline: Pipeline,
    pub layout: GraphLayout,
    pub selected_job: Option<String>,
    pub selected_col: usize,
    pub selected_row: usize,
    /// Remembered row for horizontal navigation (like cursor column memory in editors).
    /// Reset by vertical navigation (up/down).
    pub desired_row: usize,
    pub detail_tab: DetailTab,
    pub detail_scroll: u16,
    pub dag_scroll_x: u16,
    pub dag_scroll_y: u16,
    pub label: String,
    /// If this is a child pipeline: (parent trigger job name, parent tab index)
    pub parent_info: Option<(String, usize)>,
}

impl PipelineTab {
    fn new(file_path: PathBuf, pipeline: Pipeline, label: String) -> Self {
        let layout = compute_layout(&pipeline);
        let selected_job = layout.nodes.first().map(|n| n.name.clone());
        PipelineTab {
            file_path,
            pipeline,
            layout,
            selected_job,
            selected_col: 0,
            selected_row: 0,
            desired_row: 0,
            detail_tab: DetailTab::Config,
            detail_scroll: 0,
            dag_scroll_x: 0,
            dag_scroll_y: 0,
            label,
            parent_info: None,
        }
    }
}

/// Container launch options, set once at startup.
pub struct ContainerOptions {
    pub no_overlay: bool,
    /// Host environment variable names to forward into the container.
    pub pass_env: Vec<String>,
    /// Explicit project directory for container mounts (when CI config is separate).
    pub project_dir: Option<PathBuf>,
    /// Image used for git operations (clone, apply) in dispatch mode.
    pub git_image: String,
}

pub struct App {
    pub tabs: Vec<PipelineTab>,
    pub active_tab: usize,
    pub ci_system: CiSystem,
    pub gitlab_config: Option<GitlabConfig>,
    pub git_context: GitContext,
    /// Size of the DAG inner area (updated each frame for auto-scrolling)
    pub dag_area_width: u16,
    pub dag_area_height: u16,
    /// Screen rects for mouse hit-testing (updated each frame)
    pub dag_area: ratatui::layout::Rect,
    pub detail_area: ratatui::layout::Rect,
    /// Height of the detail panel (user-resizable via drag)
    pub detail_height: u16,
    pub focus: Focus,
    pub error: Option<String>,
    /// Transient status message (non-error) shown in the status bar.
    pub status_message: Option<String>,
    pub container_opts: ContainerOptions,
    /// Exit codes from non-interactive job runs (job name -> exit code).
    pub run_results: HashMap<String, i32>,
    pub allow_unresolved: bool,
    /// Per-job variable overrides from `v` key editing (job name -> variables).
    pub edited_vars: HashMap<String, indexmap::IndexMap<String, String>>,
    /// Variable overrides from config files and `-e` CLI flags, injected into
    /// child pipelines when opening them via trigger jobs.
    pub var_overrides: indexmap::IndexMap<String, String>,
}

impl App {
    pub fn new(
        file_path: PathBuf,
        ci_system: CiSystem,
        gitlab_config: Option<GitlabConfig>,
        git_context: GitContext,
        pipeline: Pipeline,
    ) -> Self {
        let label = file_path
            .file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| "pipeline".to_string());
        let tab = PipelineTab::new(file_path, pipeline, label);

        App {
            tabs: vec![tab],
            active_tab: 0,
            ci_system,
            gitlab_config,
            git_context,
            dag_area_width: 0,
            dag_area_height: 0,
            dag_area: ratatui::layout::Rect::default(),
            detail_area: ratatui::layout::Rect::default(),
            detail_height: 16,
            focus: Focus::Dag,
            error: None,
            status_message: None,
            container_opts: ContainerOptions {
                no_overlay: false,
                pass_env: vec![],
                project_dir: None,
                git_image: crate::container::DEFAULT_GIT_IMAGE.to_string(),
            },
            run_results: HashMap::new(),
            allow_unresolved: false,
            edited_vars: HashMap::new(),
            var_overrides: indexmap::IndexMap::new(),
        }
    }

    /// Access the currently active tab.
    pub fn current_tab(&self) -> &PipelineTab {
        &self.tabs[self.active_tab]
    }

    /// Access the currently active tab mutably.
    pub fn current_tab_mut(&mut self) -> &mut PipelineTab {
        &mut self.tabs[self.active_tab]
    }

    /// The root file path (first tab's file, used as project root reference).
    pub fn root_file_path(&self) -> &PathBuf {
        &self.tabs[0].file_path
    }

    pub fn reload(&mut self) -> anyhow::Result<()> {
        let file_path = self.tabs[self.active_tab].file_path.clone();
        let pipeline = match self.ci_system {
            CiSystem::GitHub => {
                let raw = crate::parser::github::parse_workflow_file(&file_path)?;
                crate::resolve::github::resolve_pipeline(raw, &self.var_overrides)?
            }
            CiSystem::GitLab => {
                let project_root = if self.active_tab > 0 {
                    self.tabs[0].file_path.parent().map(|p| p.to_path_buf())
                } else {
                    None
                };
                let raw = crate::parser::parse_pipeline_file_with_root(
                    &file_path,
                    self.gitlab_config.as_ref(),
                    project_root.as_deref(),
                    self.allow_unresolved,
                )?;
                if self.active_tab == 0 {
                    self.git_context = GitContext::detect(&file_path);
                }
                if self.allow_unresolved {
                    crate::resolve::resolve_pipeline_tolerant(
                        raw,
                        &self.git_context,
                        &self.var_overrides,
                    )?
                } else {
                    crate::resolve::resolve_pipeline(raw, &self.git_context, &self.var_overrides)?
                }
            }
        };
        let layout = compute_layout(&pipeline);

        let tab = &mut self.tabs[self.active_tab];
        let selected_job = if let Some(ref name) = tab.selected_job {
            if pipeline.jobs.contains_key(name) {
                Some(name.clone())
            } else {
                layout.nodes.first().map(|n| n.name.clone())
            }
        } else {
            layout.nodes.first().map(|n| n.name.clone())
        };

        tab.pipeline = pipeline;
        tab.layout = layout;
        tab.selected_job = selected_job.clone();
        // Sync col/row/desired_row to the selected job's position in the new layout
        if let Some(ref name) = selected_job
            && let Some(node) = tab.layout.nodes.iter().find(|n| n.name == *name)
        {
            tab.selected_col = node.col;
            tab.selected_row = node.row;
            tab.desired_row = node.row;
        }
        self.error = None;
        Ok(())
    }

    pub fn build_container_config(&self, privileged: bool) -> Option<ContainerConfig> {
        let job = self.selected_job_data()?;
        let tab = self.current_tab();
        let mut config = crate::container::build_config_for_job(
            job,
            self.ci_system,
            &tab.file_path,
            self.container_opts.project_dir.as_deref(),
            &self.container_opts.pass_env,
            self.container_opts.no_overlay,
            privileged,
            &self.git_context,
            &self.container_opts.git_image,
        )?;
        if let Some(overrides) = self.edited_vars.get(&job.name) {
            for (k, v) in overrides {
                config.variables.insert(k.clone(), v.clone());
            }
        }
        Some(config)
    }

    /// Check if the selected job has "kvm" in any of its tags.
    pub fn selected_job_has_kvm_tag(&self) -> bool {
        self.selected_job_data()
            .map(|job| job.tags.iter().any(|t| t.to_lowercase().contains("kvm")))
            .unwrap_or(false)
    }

    pub fn selected_job_data(&self) -> Option<&crate::model::Job> {
        let tab = self.current_tab();
        tab.selected_job
            .as_ref()
            .and_then(|name| tab.pipeline.jobs.get(name))
    }

    /// Get the text content of the currently active detail tab for the selected job.
    pub fn detail_tab_content(&self) -> Option<String> {
        let job = self.selected_job_data()?;
        let tab = self.current_tab();
        Some(match tab.detail_tab {
            DetailTab::Config => job.resolved_yaml.clone(),
            DetailTab::Script => {
                let empty = indexmap::IndexMap::new();
                let extra = self.edited_vars.get(&job.name).unwrap_or(&empty);
                job.build_local_script_with_extra_vars(self.ci_system, extra)
            }
            DetailTab::Variables => {
                let mut merged = job.variables.clone();
                if let Some(overrides) = self.edited_vars.get(&job.name) {
                    for (k, v) in overrides {
                        merged.insert(k.clone(), v.clone());
                    }
                }
                let mut sorted: Vec<_> = merged.iter().collect();
                sorted.sort_by_key(|(k, _)| k.as_str());
                sorted
                    .into_iter()
                    .map(|(k, v)| format!("{} = {}", k, v))
                    .collect::<Vec<_>>()
                    .join("\n")
            }
            DetailTab::Debug => return None,
        })
    }

    /// Get the resolved trigger file path for the currently selected job, if any.
    pub fn selected_trigger_path(&self) -> Option<PathBuf> {
        let job = self.selected_job_data()?;
        let trigger = job.trigger_file.as_ref()?;
        let root_dir = self.root_file_path().parent()?;
        let relative = trigger.trim_start_matches('/');
        let full_path = root_dir.join(relative);
        if full_path.exists() {
            Some(full_path)
        } else {
            None
        }
    }

    /// Check if the selected job has steps that can be shown as a child pipeline.
    pub fn selected_has_steps(&self) -> bool {
        self.selected_job_data()
            .is_some_and(|j| !j.steps.is_empty())
    }

    /// Open a step-pipeline tab for the selected job's steps.
    pub fn open_step_pipeline(&mut self) -> anyhow::Result<()> {
        let job = self
            .selected_job_data()
            .ok_or_else(|| anyhow::anyhow!("No job selected"))?;
        let mut pipeline = crate::resolve::github::build_step_pipeline(job)
            .ok_or_else(|| anyhow::anyhow!("Job has no steps"))?;

        let parent_job_name = self.current_tab().selected_job.clone().unwrap_or_default();
        let parent_tab = self.active_tab;

        let fake_job_name = format!("« {}", parent_job_name);
        Self::inject_parent_job(&mut pipeline, &fake_job_name);

        let label = format!("{} steps", parent_job_name);
        let file_path = self.current_tab().file_path.clone();
        let mut tab = PipelineTab::new(file_path, pipeline, label);
        tab.parent_info = Some((fake_job_name, parent_tab));

        if let Some(node) = tab.layout.nodes.iter().find(|n| n.col > 0) {
            tab.selected_job = Some(node.name.clone());
            tab.selected_col = node.col;
            tab.selected_row = node.row;
        }

        self.tabs.push(tab);
        self.active_tab = self.tabs.len() - 1;
        self.error = None;
        Ok(())
    }

    /// Open a child pipeline tab from a trigger file path.
    pub fn open_child_tab(&mut self, child_path: PathBuf) -> anyhow::Result<()> {
        // Check if tab already exists for this path
        for (i, tab) in self.tabs.iter().enumerate() {
            if tab.file_path == child_path {
                self.active_tab = i;
                return Ok(());
            }
        }

        let parent_job_name = self.current_tab().selected_job.clone().unwrap_or_default();
        let parent_tab = self.active_tab;

        let mut pipeline = match self.ci_system {
            CiSystem::GitHub => {
                let raw = crate::parser::github::parse_workflow_file(&child_path)?;
                crate::resolve::github::resolve_pipeline(raw, &self.var_overrides)?
            }
            CiSystem::GitLab => {
                let project_root = self.tabs[0].file_path.parent().map(|p| p.to_path_buf());
                let ctx = crate::resolve::ResolveContext {
                    gitlab_config: self.gitlab_config.as_ref(),
                    project_root: project_root.as_deref(),
                    allow_unresolved: self.allow_unresolved,
                    git_context: &self.git_context,
                    var_overrides: &self.var_overrides,
                };
                crate::resolve::resolve_child_pipeline(&child_path, &ctx)?
            }
        };

        // Inject a fake parent job as the first stage
        let fake_job_name = format!("« {}", parent_job_name);
        Self::inject_parent_job(&mut pipeline, &fake_job_name);

        let label = child_path
            .file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| "child".to_string());
        let mut tab = PipelineTab::new(child_path, pipeline, label);
        tab.parent_info = Some((fake_job_name, parent_tab));

        // Select the second job (skip the fake parent) if possible
        if let Some(node) = tab.layout.nodes.iter().find(|n| n.col > 0) {
            tab.selected_job = Some(node.name.clone());
            tab.selected_col = node.col;
            tab.selected_row = node.row;
        }

        self.tabs.push(tab);
        self.active_tab = self.tabs.len() - 1;
        self.error = None;
        Ok(())
    }

    /// Inject a fake parent job as the first stage of a child pipeline.
    fn inject_parent_job(pipeline: &mut Pipeline, fake_job_name: &str) {
        use crate::model::{Job, Stage};

        let parent_stage = Stage {
            name: ".parent".to_string(),
            job_names: vec![fake_job_name.to_string()],
        };
        pipeline.stages.insert(0, parent_stage);

        let fake_job = Job {
            name: fake_job_name.to_string(),
            stage: ".parent".to_string(),
            image: None,
            services: vec![],
            variables: indexmap::IndexMap::new(),
            before_script: vec![],
            script: vec![],
            after_script: vec![],
            needs: vec![],
            artifact_sources: Some(vec![]),
            tags: vec![],
            when_val: "on_success".to_string(),
            allow_failure: false,
            rules_never: false,
            unresolved_extends: vec![],
            rules_summary: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_untracked: false,
            artifacts_when: "on_success".to_string(),
            dotenv_report: None,
            timeout: None,
            resource_group: None,
            coverage: None,
            resolved_yaml: String::new(),
            trigger_file: None,
            build_context: None,
            docker_entrypoint: None,
            step_id: None,
            steps: vec![],
            debug_info: crate::model::JobDebugInfo::default(),
        };
        pipeline.jobs.insert(fake_job_name.to_string(), fake_job);
    }

    /// Check if the selected job is the fake parent link in a child pipeline.
    /// Returns the parent tab index if so.
    pub fn selected_is_parent_link(&self) -> Option<usize> {
        let tab = self.current_tab();
        let (parent_job_name, parent_tab) = tab.parent_info.as_ref()?;
        if tab.selected_job.as_ref() == Some(parent_job_name) {
            Some(*parent_tab)
        } else {
            None
        }
    }

    /// Close the current tab (only if it's not the root tab).
    pub fn close_current_tab(&mut self) {
        if self.active_tab == 0 {
            return;
        }
        self.tabs.remove(self.active_tab);
        if self.active_tab >= self.tabs.len() {
            self.active_tab = self.tabs.len() - 1;
        }
    }

    /// Switch to the next pipeline tab.
    pub fn next_pipeline_tab(&mut self) {
        if self.tabs.len() > 1 {
            self.active_tab = (self.active_tab + 1) % self.tabs.len();
        }
    }

    /// Select a pipeline tab by screen x coordinate (from mouse click on title bar row).
    /// Returns true if a tab was clicked.
    pub fn select_pipeline_tab_at(&mut self, screen_x: u16) -> bool {
        if self.tabs.len() <= 1 {
            return false;
        }

        let x = screen_x as usize;
        // Layout: " cui-cui " (9) + " " (1) = 10 chars prefix
        let prefix_len = 10;
        if x < prefix_len {
            return false;
        }
        let rel_x = x - prefix_len;

        let mut pos = 0;
        for (i, tab) in self.tabs.iter().enumerate() {
            if i > 0 {
                pos += 3; // " │ " separator
            }
            let tab_width = tab.label.width();
            if rel_x >= pos && rel_x < pos + tab_width {
                self.active_tab = i;
                return true;
            }
            pos += tab_width;
        }
        false
    }

    /// Switch to the previous pipeline tab.
    pub fn prev_pipeline_tab(&mut self) {
        if self.tabs.len() > 1 {
            self.active_tab = (self.active_tab + self.tabs.len() - 1) % self.tabs.len();
        }
    }

    pub fn select_next_stage(&mut self) {
        let tab = self.current_tab();
        if tab.selected_col + 1 < tab.layout.num_cols {
            let tab = self.current_tab_mut();
            tab.selected_col += 1;
            let jobs = self.jobs_in_current_col();
            let tab = self.current_tab_mut();
            tab.selected_row = tab.desired_row.min(jobs.saturating_sub(1));
            self.update_selection();
        }
    }

    pub fn select_prev_stage(&mut self) {
        if self.current_tab().selected_col > 0 {
            let tab = self.current_tab_mut();
            tab.selected_col -= 1;
            let jobs = self.jobs_in_current_col();
            let tab = self.current_tab_mut();
            tab.selected_row = tab.desired_row.min(jobs.saturating_sub(1));
            self.update_selection();
        }
    }

    pub fn select_next_job(&mut self) {
        let jobs_in_col = self.jobs_in_current_col();
        if self.current_tab().selected_row + 1 < jobs_in_col {
            self.current_tab_mut().selected_row += 1;
            self.current_tab_mut().desired_row = self.current_tab().selected_row;
            self.update_selection();
        }
    }

    pub fn select_prev_job(&mut self) {
        if self.current_tab().selected_row > 0 {
            self.current_tab_mut().selected_row -= 1;
            self.current_tab_mut().desired_row = self.current_tab().selected_row;
            self.update_selection();
        }
    }

    fn jobs_in_current_col(&self) -> usize {
        let tab = self.current_tab();
        tab.layout
            .nodes
            .iter()
            .filter(|n| n.col == tab.selected_col)
            .count()
    }

    fn update_selection(&mut self) {
        let tab = self.current_tab();
        let selected_col = tab.selected_col;
        let selected_row = tab.selected_row;
        let node = tab
            .layout
            .nodes
            .iter()
            .find(|n| n.col == selected_col && n.row == selected_row);

        if let Some(node) = node {
            let name = node.name.clone();
            let col = node.col;
            let row = node.row;
            self.current_tab_mut().selected_job = Some(name);
            self.ensure_selected_visible(col, row);
        } else {
            self.current_tab_mut().selected_job = None;
        }
        self.current_tab_mut().detail_scroll = 0;
    }

    /// Auto-scroll the DAG view so the selected job box is visible.
    fn ensure_selected_visible(&mut self, col: usize, row: usize) {
        let box_x = (col as u16) * COL_WIDTH;
        let box_y = 2 + (row as u16) * (JOB_BOX_HEIGHT + JOB_GAP);

        let area_w = self.dag_area_width;
        let area_h = self.dag_area_height;
        let tab = &mut self.tabs[self.active_tab];

        // Scroll horizontally
        if box_x < tab.dag_scroll_x {
            tab.dag_scroll_x = box_x;
        } else if box_x + JOB_BOX_WIDTH > tab.dag_scroll_x + area_w {
            tab.dag_scroll_x = (box_x + JOB_BOX_WIDTH).saturating_sub(area_w);
        }

        // Scroll vertically
        if box_y < tab.dag_scroll_y {
            tab.dag_scroll_y = box_y;
        } else if box_y + JOB_BOX_HEIGHT > tab.dag_scroll_y + area_h {
            tab.dag_scroll_y = (box_y + JOB_BOX_HEIGHT).saturating_sub(area_h);
        }
    }

    pub fn next_tab(&mut self) {
        let tab = self.current_tab_mut();
        tab.detail_tab = tab.detail_tab.next();
        tab.detail_scroll = 0;
    }

    pub fn prev_tab(&mut self) {
        let tab = self.current_tab_mut();
        tab.detail_tab = tab.detail_tab.prev();
        tab.detail_scroll = 0;
    }

    pub fn scroll_detail(&mut self, delta: i16) {
        let tab = self.current_tab_mut();
        let new_scroll = tab.detail_scroll as i32 + delta as i32;
        tab.detail_scroll = new_scroll.clamp(0, u16::MAX as i32) as u16;
    }

    pub fn scroll_dag(&mut self, dx: i16, dy: i16) {
        let tab = self.current_tab_mut();
        let new_x = tab.dag_scroll_x as i32 + dx as i32;
        let new_y = tab.dag_scroll_y as i32 + dy as i32;
        tab.dag_scroll_x = new_x.clamp(0, u16::MAX as i32) as u16;
        tab.dag_scroll_y = new_y.clamp(0, u16::MAX as i32) as u16;
    }

    pub fn toggle_focus(&mut self) {
        self.focus = match self.focus {
            Focus::Dag => Focus::Detail,
            Focus::Detail => Focus::Dag,
        };
    }

    /// Select a detail tab by screen coordinates (from mouse click).
    /// The tab bar is rendered at detail_area.y + 1 (second row of detail inner area).
    pub fn select_tab_at(&mut self, screen_x: u16, screen_y: u16) -> bool {
        let tab_y = self.detail_area.y + 1;
        if screen_y != tab_y && screen_y != tab_y + 1 {
            return false;
        }

        let rel_x = screen_x.saturating_sub(self.detail_area.x) as usize;

        // Tabs render as: " Title │ Title │ Title "
        // Each tab occupies: 1 space + title.len() + 1 space + 1 divider
        let mut pos = 0;
        for (i, title) in TAB_TITLES.iter().enumerate() {
            let tab_width = title.len() + 2; // space + title + space
            if rel_x >= pos && rel_x < pos + tab_width {
                let tab = self.current_tab_mut();
                tab.detail_tab = match i {
                    0 => DetailTab::Config,
                    1 => DetailTab::Script,
                    2 => DetailTab::Variables,
                    _ => DetailTab::Debug,
                };
                tab.detail_scroll = 0;
                return true;
            }
            pos += tab_width + 1; // + divider
        }
        false
    }

    /// Select a job by screen coordinates (from mouse click).
    /// Returns true if a job was found and selected.
    #[cfg(test)]
    fn job_count_in_col(&self, col: usize) -> usize {
        self.current_tab()
            .layout
            .nodes
            .iter()
            .filter(|n| n.col == col)
            .count()
    }

    pub fn select_job_at(&mut self, screen_x: u16, screen_y: u16) -> bool {
        // Convert screen coords to DAG coords
        let dag_x = screen_x.saturating_sub(self.dag_area.x) + self.current_tab().dag_scroll_x;
        let dag_y = screen_y.saturating_sub(self.dag_area.y) + self.current_tab().dag_scroll_y;

        for node in &self.current_tab().layout.nodes {
            let box_x = (node.col as u16) * COL_WIDTH;
            let box_y = 2 + (node.row as u16) * (JOB_BOX_HEIGHT + JOB_GAP);

            if dag_x >= box_x
                && dag_x < box_x + JOB_BOX_WIDTH
                && dag_y >= box_y
                && dag_y < box_y + JOB_BOX_HEIGHT
            {
                let name = node.name.clone();
                let col = node.col;
                let row = node.row;
                let tab = self.current_tab_mut();
                tab.selected_job = Some(name);
                tab.selected_col = col;
                tab.selected_row = row;
                tab.detail_scroll = 0;
                return true;
            }
        }
        false
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::container;
    use crate::model::{Job, Pipeline, Stage};
    use indexmap::IndexMap;
    use std::path::PathBuf;

    fn make_job(name: &str, stage: &str, image: Option<&str>, tags: Vec<&str>) -> Job {
        Job {
            name: name.to_string(),
            stage: stage.to_string(),
            image: image.map(|s| s.to_string()),
            services: vec![],
            variables: IndexMap::new(),
            before_script: vec![],
            script: vec!["echo hi".to_string()],
            after_script: vec![],
            needs: vec![],
            artifact_sources: Some(vec![]),
            tags: tags.into_iter().map(|s| s.to_string()).collect(),
            when_val: "on_success".to_string(),
            allow_failure: false,
            rules_never: false,
            unresolved_extends: vec![],
            rules_summary: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_untracked: false,
            artifacts_when: "on_success".to_string(),
            dotenv_report: None,
            timeout: None,
            resource_group: None,
            coverage: None,
            resolved_yaml: String::new(),
            trigger_file: None,
            build_context: None,
            docker_entrypoint: None,
            step_id: None,
            steps: vec![],
            debug_info: crate::model::JobDebugInfo::default(),
        }
    }

    fn make_pipeline() -> Pipeline {
        let mut jobs = IndexMap::new();
        jobs.insert(
            "build".to_string(),
            make_job("build", "build", Some("rust:latest"), vec![]),
        );
        jobs.insert("test".to_string(), make_job("test", "test", None, vec![]));
        jobs.insert(
            "lint".to_string(),
            make_job("lint", "test", None, vec!["kvm"]),
        );
        Pipeline {
            stages: vec![
                Stage {
                    name: "build".to_string(),
                    job_names: vec!["build".to_string()],
                },
                Stage {
                    name: "test".to_string(),
                    job_names: vec!["test".to_string(), "lint".to_string()],
                },
            ],
            jobs,
            execution: Default::default(),
            checkout_path: None,
        }
    }

    fn make_app() -> App {
        App::new(
            PathBuf::from("test.yml"),
            CiSystem::GitLab,
            None,
            GitContext::default(),
            make_pipeline(),
        )
    }

    #[test]
    fn test_new_selects_first_job() {
        let app = make_app();
        let tab = app.current_tab();
        assert!(tab.selected_job.is_some());
        assert_eq!(tab.selected_col, 0);
        assert_eq!(tab.selected_row, 0);
    }

    #[test]
    fn test_navigate_stages() {
        let mut app = make_app();
        assert_eq!(app.current_tab().selected_col, 0);

        app.select_next_stage();
        assert_eq!(app.current_tab().selected_col, 1);

        // Can't go past last stage
        app.select_next_stage();
        app.select_next_stage();
        assert!(app.current_tab().selected_col <= app.current_tab().layout.num_cols);

        app.select_prev_stage();
        assert_eq!(app.current_tab().selected_col, 0);

        // Can't go below 0
        app.select_prev_stage();
        assert_eq!(app.current_tab().selected_col, 0);
    }

    #[test]
    fn test_navigate_jobs() {
        let mut app = make_app();
        // Go to the test stage which has 2 jobs
        app.select_next_stage();
        let jobs_in_col = app.job_count_in_col(app.current_tab().selected_col);
        assert!(jobs_in_col >= 2);

        app.select_next_job();
        assert_eq!(app.current_tab().selected_row, 1);

        app.select_prev_job();
        assert_eq!(app.current_tab().selected_row, 0);

        // Can't go below 0
        app.select_prev_job();
        assert_eq!(app.current_tab().selected_row, 0);
    }

    #[test]
    fn test_tab_cycling() {
        let mut app = make_app();
        assert_eq!(app.current_tab().detail_tab, DetailTab::Config);

        app.next_tab();
        assert_eq!(app.current_tab().detail_tab, DetailTab::Script);

        app.next_tab();
        assert_eq!(app.current_tab().detail_tab, DetailTab::Variables);

        app.next_tab();
        assert_eq!(app.current_tab().detail_tab, DetailTab::Debug);

        app.next_tab();
        assert_eq!(app.current_tab().detail_tab, DetailTab::Config);

        app.prev_tab();
        assert_eq!(app.current_tab().detail_tab, DetailTab::Debug);
    }

    #[test]
    fn test_scroll_detail() {
        let mut app = make_app();
        assert_eq!(app.current_tab().detail_scroll, 0);

        app.scroll_detail(5);
        assert_eq!(app.current_tab().detail_scroll, 5);

        app.scroll_detail(-3);
        assert_eq!(app.current_tab().detail_scroll, 2);

        // Can't go below 0
        app.scroll_detail(-10);
        assert_eq!(app.current_tab().detail_scroll, 0);
    }

    #[test]
    fn test_scroll_detail_no_overflow_at_high_values() {
        let mut app = make_app();
        // Set scroll to a value > i16::MAX to trigger the old u16→i16 overflow
        app.current_tab_mut().detail_scroll = 40000;
        // Scrolling up by 1 should give 39999, not wrap to negative
        app.scroll_detail(-1);
        assert_eq!(app.current_tab().detail_scroll, 39999);
        // Scrolling down by 1 should give 40000
        app.scroll_detail(1);
        assert_eq!(app.current_tab().detail_scroll, 40000);
    }

    #[test]
    fn test_scroll_dag() {
        let mut app = make_app();
        app.scroll_dag(5, 3);
        assert_eq!(app.current_tab().dag_scroll_x, 5);
        assert_eq!(app.current_tab().dag_scroll_y, 3);

        app.scroll_dag(-10, -10);
        assert_eq!(app.current_tab().dag_scroll_x, 0);
        assert_eq!(app.current_tab().dag_scroll_y, 0);
    }

    #[test]
    fn test_scroll_dag_no_overflow_at_high_values() {
        let mut app = make_app();
        app.current_tab_mut().dag_scroll_x = 40000;
        app.current_tab_mut().dag_scroll_y = 40000;
        // Should not wrap to negative / zero
        app.scroll_dag(-1, -1);
        assert_eq!(app.current_tab().dag_scroll_x, 39999);
        assert_eq!(app.current_tab().dag_scroll_y, 39999);
    }

    #[test]
    fn test_toggle_focus() {
        let mut app = make_app();
        assert_eq!(app.focus, Focus::Dag);

        app.toggle_focus();
        assert_eq!(app.focus, Focus::Detail);

        app.toggle_focus();
        assert_eq!(app.focus, Focus::Dag);
    }

    #[test]
    fn test_build_container_config_with_image() {
        let app = make_app();
        // "build" job has an image
        assert!(app.current_tab().selected_job.is_some());
        let config = app.build_container_config(false);
        assert!(config.is_some());
        let config = config.unwrap();
        assert_eq!(config.image, "rust:latest");
        assert!(!config.privileged);
    }

    #[test]
    fn test_build_container_config_privileged() {
        let app = make_app();
        let config = app.build_container_config(true).unwrap();
        assert!(config.privileged);
    }

    #[test]
    fn test_build_container_config_no_image_uses_default() {
        let mut app = make_app();
        // Select "test" job which has no image — should get default
        app.select_next_stage();
        assert!(app.selected_job_data().is_some());
        assert!(app.selected_job_data().unwrap().image.is_none());
        let config = app.build_container_config(false).unwrap();
        assert_eq!(config.image, container::DEFAULT_IMAGE);
    }

    #[test]
    fn test_container_opts_no_overlay_flows_to_config() {
        let mut app = make_app();
        app.container_opts.no_overlay = true;
        let config = app.build_container_config(false).unwrap();
        assert!(config.no_overlay);
    }

    #[test]
    fn test_container_opts_pass_env_flows_to_config() {
        // SAFETY: test is single-threaded, no concurrent env access
        unsafe { std::env::set_var("CUI_CUI_TEST_TOKEN", "secret123") };
        let mut app = make_app();
        app.container_opts.pass_env = vec!["CUI_CUI_TEST_TOKEN".to_string()];
        let config = app.build_container_config(false).unwrap();
        assert_eq!(
            config.variables.get("CUI_CUI_TEST_TOKEN"),
            Some(&"secret123".to_string())
        );
        unsafe { std::env::remove_var("CUI_CUI_TEST_TOKEN") };
    }

    #[test]
    fn test_selected_job_has_kvm_tag() {
        let mut app = make_app();
        // "build" job has no kvm tag
        assert!(!app.selected_job_has_kvm_tag());

        // Navigate to "lint" job which has kvm tag
        app.select_next_stage();
        app.select_next_job();
        assert_eq!(app.current_tab().selected_job.as_deref(), Some("lint"));
        assert!(app.selected_job_has_kvm_tag());
    }

    #[test]
    fn test_navigate_stages_preserves_row() {
        let mut app = make_app();
        // Pipeline: col 0 has 1 job ("build"), col 1 has 2 jobs ("test", "lint")

        // Go to col 1 (test stage), select second job
        app.select_next_stage();
        app.select_next_job();
        assert_eq!(app.current_tab().selected_row, 1);
        assert_eq!(app.current_tab().selected_job.as_deref(), Some("lint"));

        // Go back to col 0 (build stage, only 1 job) — row clamped to 0
        app.select_prev_stage();
        assert_eq!(app.current_tab().selected_row, 0);
        assert_eq!(app.current_tab().selected_job.as_deref(), Some("build"));

        // Go forward to col 1 again — desired_row (1) is restored
        app.select_next_stage();
        assert_eq!(app.current_tab().selected_row, 1);
        assert_eq!(app.current_tab().selected_job.as_deref(), Some("lint"));
    }

    #[test]
    fn test_desired_row_resets_on_vertical_nav() {
        let mut app = make_app();
        // Go to col 1, select second job (row 1)
        app.select_next_stage();
        app.select_next_job();
        assert_eq!(app.current_tab().desired_row, 1);

        // Go to col 0 (clamped to row 0), then navigate up (no-op but resets desired_row)
        app.select_prev_stage();
        // desired_row is still 1 (horizontal nav doesn't reset it)
        assert_eq!(app.current_tab().desired_row, 1);

        // Vertical nav resets desired_row to actual row
        app.select_prev_job(); // no-op (already at 0), but doesn't fire (row > 0 check)
        // Use select_next_job on col 0 (only 1 job) — also no-op
        // Go back to col 1 to verify desired_row is still 1
        app.select_next_stage();
        assert_eq!(app.current_tab().selected_row, 1);

        // Now press up to go to row 0, which should update desired_row
        app.select_prev_job();
        assert_eq!(app.current_tab().selected_row, 0);
        assert_eq!(app.current_tab().desired_row, 0);

        // Go to col 0 and back — should now stay at row 0
        app.select_prev_stage();
        app.select_next_stage();
        assert_eq!(app.current_tab().selected_row, 0);
    }

    #[test]
    fn test_detail_scroll_resets_on_navigation() {
        let mut app = make_app();
        app.scroll_detail(10);
        assert_eq!(app.current_tab().detail_scroll, 10);

        app.select_next_stage();
        assert_eq!(app.current_tab().detail_scroll, 0);
    }

    #[test]
    fn test_detail_scroll_resets_on_tab_change() {
        let mut app = make_app();
        app.scroll_detail(10);
        app.next_tab();
        assert_eq!(app.current_tab().detail_scroll, 0);
    }

    #[test]
    fn test_pipeline_tab_management() {
        let mut app = make_app();
        assert_eq!(app.tabs.len(), 1);
        assert_eq!(app.active_tab, 0);

        // Can't close root tab (single tab)
        app.close_current_tab();
        assert_eq!(app.tabs.len(), 1);

        // Switch tabs with only one tab does nothing
        app.next_pipeline_tab();
        assert_eq!(app.active_tab, 0);
        app.prev_pipeline_tab();
        assert_eq!(app.active_tab, 0);
    }

    #[test]
    fn test_pipeline_tab_switching_multiple() {
        let mut app = make_app();
        // Manually add a second tab
        let tab2 = PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child.yml".to_string(),
        );
        app.tabs.push(tab2);
        assert_eq!(app.tabs.len(), 2);

        // Next wraps around
        app.next_pipeline_tab();
        assert_eq!(app.active_tab, 1);
        app.next_pipeline_tab();
        assert_eq!(app.active_tab, 0);

        // Prev wraps around
        app.prev_pipeline_tab();
        assert_eq!(app.active_tab, 1);
        app.prev_pipeline_tab();
        assert_eq!(app.active_tab, 0);
    }

    #[test]
    fn test_close_non_root_tab() {
        let mut app = make_app();
        let tab2 = PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child.yml".to_string(),
        );
        app.tabs.push(tab2);
        app.active_tab = 1;

        app.close_current_tab();
        assert_eq!(app.tabs.len(), 1);
        assert_eq!(app.active_tab, 0);
    }

    #[test]
    fn test_cannot_close_root_tab_with_children() {
        let mut app = make_app();
        app.tabs.push(PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child".to_string(),
        ));
        assert_eq!(app.tabs.len(), 2);

        // Root tab cannot be closed even when other tabs exist
        app.active_tab = 0;
        app.close_current_tab();
        assert_eq!(app.tabs.len(), 2);
        assert_eq!(app.active_tab, 0);
    }

    #[test]
    fn test_close_middle_tab_adjusts_index() {
        let mut app = make_app();
        app.tabs.push(PipelineTab::new(
            PathBuf::from("a.yml"),
            make_pipeline(),
            "a".to_string(),
        ));
        app.tabs.push(PipelineTab::new(
            PathBuf::from("b.yml"),
            make_pipeline(),
            "b".to_string(),
        ));
        assert_eq!(app.tabs.len(), 3);

        // Close middle tab
        app.active_tab = 1;
        app.close_current_tab();
        assert_eq!(app.tabs.len(), 2);
        assert_eq!(app.active_tab, 1); // stays at 1, now pointing to "b"
        assert_eq!(app.current_tab().label, "b");
    }

    #[test]
    fn test_close_last_tab_adjusts_index() {
        let mut app = make_app();
        app.tabs.push(PipelineTab::new(
            PathBuf::from("a.yml"),
            make_pipeline(),
            "a".to_string(),
        ));
        assert_eq!(app.tabs.len(), 2);

        // Close last tab
        app.active_tab = 1;
        app.close_current_tab();
        assert_eq!(app.tabs.len(), 1);
        assert_eq!(app.active_tab, 0);
    }

    #[test]
    fn test_select_pipeline_tab_at_single_tab() {
        let mut app = make_app();
        // With a single tab, clicking does nothing
        assert!(!app.select_pipeline_tab_at(15));
    }

    #[test]
    fn test_select_pipeline_tab_at_multiple_tabs() {
        let mut app = make_app();
        // Root tab label is "test.yml" (from make_app)
        app.tabs.push(PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child.yml".to_string(),
        ));

        // Layout: " cui-cui " (9) + " " (1) + "test.yml" (8) + " │ " (3) + "child.yml" (9)
        // Prefix = 10, first tab at 10..18, separator 18..21, second tab at 21..30

        // Click on first tab
        assert!(app.select_pipeline_tab_at(10));
        assert_eq!(app.active_tab, 0);

        // Click on second tab
        assert!(app.select_pipeline_tab_at(21));
        assert_eq!(app.active_tab, 1);

        // Click before tabs (on " cui-cui " prefix)
        assert!(!app.select_pipeline_tab_at(5));

        // Click on separator
        assert!(!app.select_pipeline_tab_at(19));
    }

    #[test]
    fn test_select_pipeline_tab_at_unicode_labels() {
        let mut app = make_app();
        // Replace the first tab label with a multi-byte unicode string.
        // "café" is 4 display columns but 5 bytes (é = 2 bytes in UTF-8).
        app.tabs[0].label = "café".to_string();
        app.tabs.push(PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child.yml".to_string(),
        ));

        // Layout: prefix=10, "café" = 4 display cols, separator=3, "child.yml" = 9
        // First tab: 10..14, separator: 14..17, second tab: 17..26

        // Click on first tab (within "café")
        assert!(app.select_pipeline_tab_at(10));
        assert_eq!(app.active_tab, 0);
        assert!(app.select_pipeline_tab_at(13));
        assert_eq!(app.active_tab, 0);

        // Click on second tab (starts at 17, not 18 as byte-len would give)
        assert!(app.select_pipeline_tab_at(17));
        assert_eq!(app.active_tab, 1);

        // Click on separator between tabs (14..17)
        assert!(!app.select_pipeline_tab_at(14));
        assert!(!app.select_pipeline_tab_at(16));
    }

    #[test]
    fn test_select_pipeline_tab_at_cjk_labels() {
        let mut app = make_app();
        // CJK characters are 2 display columns each but 3 bytes in UTF-8.
        // "日本" = 2 chars, 4 display columns, 6 bytes.
        app.tabs[0].label = "日本".to_string();
        app.tabs.push(PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child.yml".to_string(),
        ));

        // Layout: prefix=10, "日本" = 4 display cols, separator=3, "child.yml" = 9
        // First tab: 10..14, separator: 14..17, second tab: 17..26

        assert!(app.select_pipeline_tab_at(10));
        assert_eq!(app.active_tab, 0);
        assert!(app.select_pipeline_tab_at(13));
        assert_eq!(app.active_tab, 0);

        // With byte length (6), second tab would start at 19. With display width (4),
        // it correctly starts at 17.
        assert!(app.select_pipeline_tab_at(17));
        assert_eq!(app.active_tab, 1);
    }

    #[test]
    fn test_selected_trigger_path_no_trigger() {
        let app = make_app();
        // "build" job has no trigger_file
        assert!(app.selected_trigger_path().is_none());
    }

    #[test]
    fn test_tabs_have_independent_state() {
        let mut app = make_app();
        app.tabs.push(PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child.yml".to_string(),
        ));

        // Modify state in tab 0
        app.select_next_stage();
        app.scroll_detail(5);
        let tab0_col = app.current_tab().selected_col;
        let tab0_scroll = app.current_tab().detail_scroll;

        // Switch to tab 1 — should have fresh state
        app.active_tab = 1;
        assert_eq!(app.current_tab().selected_col, 0);
        assert_eq!(app.current_tab().detail_scroll, 0);

        // Switch back — state preserved
        app.active_tab = 0;
        assert_eq!(app.current_tab().selected_col, tab0_col);
        assert_eq!(app.current_tab().detail_scroll, tab0_scroll);
    }

    #[test]
    fn test_root_file_path() {
        let mut app = make_app();
        assert_eq!(app.root_file_path(), &PathBuf::from("test.yml"));

        // Adding a tab doesn't change root
        app.tabs.push(PipelineTab::new(
            PathBuf::from("child.yml"),
            make_pipeline(),
            "child".to_string(),
        ));
        assert_eq!(app.root_file_path(), &PathBuf::from("test.yml"));
    }

    #[test]
    fn test_reload_syncs_col_row_desired_row() {
        let mut app = make_app();
        // Navigate to col 1, row 1 (lint job)
        app.select_next_stage();
        app.select_next_job();
        assert_eq!(app.current_tab().selected_job.as_deref(), Some("lint"));
        assert_eq!(app.current_tab().selected_col, 1);
        assert_eq!(app.current_tab().selected_row, 1);
        assert_eq!(app.current_tab().desired_row, 1);

        // Simulate a reload with a different layout: "lint" moves to row 0
        // by removing the "test" job
        let mut new_jobs = IndexMap::new();
        new_jobs.insert(
            "build".to_string(),
            make_job("build", "build", Some("rust:latest"), vec![]),
        );
        new_jobs.insert(
            "lint".to_string(),
            make_job("lint", "test", None, vec!["kvm"]),
        );
        let new_pipeline = Pipeline {
            stages: vec![
                Stage {
                    name: "build".to_string(),
                    job_names: vec!["build".to_string()],
                },
                Stage {
                    name: "test".to_string(),
                    job_names: vec!["lint".to_string()],
                },
            ],
            jobs: new_jobs,
            execution: Default::default(),
            checkout_path: None,
        };
        let new_layout = compute_layout(&new_pipeline);

        // Apply reload logic: preserve selected_job, sync position
        let tab = &mut app.tabs[app.active_tab];
        let selected_job = if let Some(ref name) = tab.selected_job {
            if new_pipeline.jobs.contains_key(name) {
                Some(name.clone())
            } else {
                new_layout.nodes.first().map(|n| n.name.clone())
            }
        } else {
            new_layout.nodes.first().map(|n| n.name.clone())
        };
        tab.pipeline = new_pipeline;
        tab.layout = new_layout;
        tab.selected_job = selected_job.clone();
        if let Some(ref name) = selected_job
            && let Some(node) = tab.layout.nodes.iter().find(|n| n.name == *name)
        {
            tab.selected_col = node.col;
            tab.selected_row = node.row;
            tab.desired_row = node.row;
        }

        // "lint" is still selected but now at row 0
        assert_eq!(app.current_tab().selected_job.as_deref(), Some("lint"));
        assert_eq!(app.current_tab().selected_row, 0);
        assert_eq!(app.current_tab().desired_row, 0);
    }
}
