use std::{
    collections::{BTreeMap, BTreeSet},
    time::{Duration, Instant},
};

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use gotcha_gitea::{
    ActivityFilter, Client, Config, HistoryCommit, IssueDraft, IssueQuery, MilestoneDraft,
    Provider, RepositoryId, Selection, models,
};

use crate::editor::{Editor, EditorAction, EditorEvent};

const ALL_BRANCHES: &str = "All branches";
const UPDATE_STATUS_DURATION: Duration = Duration::from_secs(2);

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Tab {
    Home,
    Issues,
    Repositories,
    Pulls,
    Milestones,
}

impl Tab {
    pub const ALL: [Self; 5] = [
        Self::Home,
        Self::Issues,
        Self::Repositories,
        Self::Pulls,
        Self::Milestones,
    ];

    pub const fn title(self) -> &'static str {
        match self {
            Self::Home => "1 Home",
            Self::Issues => "2 Issues",
            Self::Repositories => "3 Repos",
            Self::Pulls => "4 PRs",
            Self::Milestones => "5 Milestones",
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RepositoryDestination {
    Issues,
    Commits,
    Milestones,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ScreenKind {
    Servers,
    Home,
    Repositories(RepositoryDestination),
    Issues(RepositoryId),
    Issue(RepositoryId, i64),
    Commits(RepositoryId),
    Commit(RepositoryId, String),
    Files(RepositoryId, String),
    File(RepositoryId, String),
    Text(String, String),
    Pulls,
    Pull(RepositoryId, i64),
    Milestones(RepositoryId),
    Milestone(RepositoryId, i64),
}

impl ScreenKind {
    pub fn is_overview(&self) -> bool {
        matches!(
            self,
            Self::Servers
                | Self::Home
                | Self::Repositories(_)
                | Self::Issues(_)
                | Self::Commits(_)
                | Self::Files(_, _)
                | Self::Pulls
                | Self::Milestones(_)
        )
    }
}

#[derive(Clone, Debug)]
pub enum Target {
    None,
    Server(String),
    Repository(RepositoryId, RepositoryDestination),
    Issue(RepositoryId, i64, String),
    Pull(RepositoryId, i64),
    Milestone(RepositoryId, i64, String),
    Commit(RepositoryId, String),
    Content(RepositoryId, String, bool),
    Comment(RepositoryId, i64, i64, String, bool),
    Text(String, String),
}

#[derive(Clone, Debug)]
pub struct Item {
    pub key: String,
    pub title: String,
    pub graph_lane: Option<usize>,
    pub decoration: ItemDecoration,
    pub meta: String,
    pub detail: String,
    pub target: Target,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkItemState {
    Open,
    Closed,
    Unknown,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ItemDecoration {
    None,
    Activity(ActivityKind),
    State(WorkItemState),
    Milestone {
        state: WorkItemState,
        closed: i64,
        total: i64,
    },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ActivityKind {
    Repository,
    Issue,
    PullRequest,
    Branch,
    Tag,
    Push,
    Release,
}

impl ActivityKind {
    const fn label(self) -> &'static str {
        match self {
            Self::Repository => "Repository activity",
            Self::Issue => "Issue activity",
            Self::PullRequest => "Pull request activity",
            Self::Branch => "Branch activity",
            Self::Tag => "Tag activity",
            Self::Push => "Commit activity",
            Self::Release => "Release activity",
        }
    }
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum ActivityTitleKey {
    Issue(String, String, String, i64),
    Pull(String, String, String, i64),
}

#[derive(Clone, Debug)]
pub struct Screen {
    pub kind: ScreenKind,
    pub title: String,
    pub detail: String,
    pub items: Vec<Item>,
    pub selected: usize,
    pub detail_scroll: u16,
    pub page: u32,
    pub has_more: bool,
}

impl Screen {
    fn new(kind: ScreenKind, title: impl Into<String>) -> Self {
        Self {
            kind,
            title: title.into(),
            detail: String::new(),
            items: Vec::new(),
            selected: 0,
            detail_scroll: 0,
            page: 1,
            has_more: false,
        }
    }

    pub fn selected_item(&self) -> Option<&Item> {
        self.items.get(self.selected)
    }

    fn select(&mut self, index: usize) {
        if !self.items.is_empty() {
            self.selected = index.min(self.items.len() - 1);
            self.detail_scroll = 0;
        }
    }
}

#[derive(Clone, Debug)]
pub enum ConfirmAction {
    Issue(RepositoryId, i64),
    Milestone(RepositoryId, i64),
    Server(String),
}

pub struct App {
    pub config: Config,
    pub client: Client,
    pub server_name: String,
    pub tab: Tab,
    pub screen: Screen,
    pub history: Vec<Screen>,
    pub editor: Option<Editor>,
    pub confirm: Option<(String, ConfirmAction)>,
    pub status: String,
    status_deadline: Option<Instant>,
    pub should_quit: bool,
    pub last_input: Instant,
    pub last_reload: Instant,
    pub list_area: ratatui::layout::Rect,
    pub list_offset: usize,
    pub detail_area: ratatui::layout::Rect,
    pub tab_areas: Vec<ratatui::layout::Rect>,
    activity_filter: ActivityFilter,
    issue_state: String,
    issue_labels: String,
    issue_milestone: String,
    issue_search: String,
    pull_state: String,
    pull_milestone: String,
    pull_search: String,
    branches: BTreeMap<String, String>,
    activity_titles: BTreeMap<ActivityTitleKey, Option<String>>,
    last_click: Option<(usize, Instant)>,
}

impl App {
    pub async fn new(config: Config, requested_server: Option<&str>) -> Result<Self, String> {
        let selection = if config.servers.is_empty() && requested_server.is_none() {
            None
        } else {
            Some(initial_selection(&config, requested_server)?)
        };
        let client = match &selection {
            Some(selection) => client(selection)?,
            None => {
                Client::new("https://no-server.invalid", None).map_err(|error| error.to_string())?
            }
        };
        let server_name = selection
            .as_ref()
            .map(|selection| {
                selection
                    .name
                    .clone()
                    .unwrap_or_else(|| selection.url.clone())
            })
            .unwrap_or_else(|| "No server".into());
        let initial_kind = if selection.is_some() {
            ScreenKind::Home
        } else {
            ScreenKind::Servers
        };
        let mut app = Self {
            config,
            client,
            server_name,
            tab: Tab::Home,
            screen: Screen::new(initial_kind, "Home"),
            history: Vec::new(),
            editor: None,
            confirm: None,
            status: "Loading…".into(),
            status_deadline: None,
            should_quit: false,
            last_input: Instant::now(),
            last_reload: Instant::now(),
            list_area: ratatui::layout::Rect::default(),
            list_offset: 0,
            detail_area: ratatui::layout::Rect::default(),
            tab_areas: Vec::new(),
            activity_filter: ActivityFilter::All,
            issue_state: "open".into(),
            issue_labels: String::new(),
            issue_milestone: String::new(),
            issue_search: String::new(),
            pull_state: "open".into(),
            pull_milestone: String::new(),
            pull_search: String::new(),
            branches: BTreeMap::new(),
            activity_titles: BTreeMap::new(),
            last_click: None,
        };
        app.reload().await;
        Ok(app)
    }

    pub fn refresh_interval(&self) -> Option<Duration> {
        (self.config.tui.refresh_seconds > 0)
            .then(|| Duration::from_secs(self.config.tui.refresh_seconds))
    }

    pub fn should_auto_reload(&self) -> bool {
        self.editor.is_none()
            && self.confirm.is_none()
            && self.screen.kind.is_overview()
            && self.refresh_interval().is_some_and(|interval| {
                self.last_reload.elapsed() >= interval && self.last_input.elapsed() >= interval
            })
    }

    pub fn persist_selected_server(&mut self) -> Result<(), String> {
        let selected = self
            .config
            .servers
            .contains_key(&self.server_name)
            .then_some(self.server_name.as_str());
        self.config.set_tui_last_server(selected)
    }

    pub async fn handle_key(&mut self, key: KeyEvent) {
        self.last_input = Instant::now();
        self.status_deadline = None;
        if let Some(editor) = &mut self.editor {
            match editor.handle_key(key) {
                EditorEvent::Submit => self.submit_editor().await,
                EditorEvent::Cancel => {
                    self.editor = None;
                    self.status = "Edit cancelled".into();
                }
                EditorEvent::Changed | EditorEvent::Ignored => {}
            }
            return;
        }
        if key.code == KeyCode::Char('q') {
            self.should_quit = true;
            return;
        }
        if let Some((_, action)) = self.confirm.clone() {
            match key.code {
                KeyCode::Char('y') | KeyCode::Enter => self.perform_confirmed(action).await,
                KeyCode::Char('n') | KeyCode::Esc | KeyCode::Backspace => {
                    self.confirm = None;
                    self.status = "Action cancelled".into();
                }
                _ => {}
            }
            return;
        }
        match key.code {
            KeyCode::Char('1') => self.switch_tab(Tab::Home).await,
            KeyCode::Char('2') => self.switch_tab(Tab::Issues).await,
            KeyCode::Char('3') => self.switch_tab(Tab::Repositories).await,
            KeyCode::Char('4') => self.switch_tab(Tab::Pulls).await,
            KeyCode::Char('5') => self.switch_tab(Tab::Milestones).await,
            KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
            KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
            KeyCode::Char('g') => self.screen.select(0),
            KeyCode::Char('G') => self
                .screen
                .select(self.screen.items.len().saturating_sub(1)),
            KeyCode::PageDown => self.move_selection(10),
            KeyCode::PageUp => self.move_selection(-10),
            KeyCode::Char(']') | KeyCode::Char('n') => self.change_page(1).await,
            KeyCode::Char('[') | KeyCode::Char('p') => self.change_page(-1).await,
            KeyCode::Char('J') => {
                self.screen.detail_scroll = self.screen.detail_scroll.saturating_add(1)
            }
            KeyCode::Char('K') => {
                self.screen.detail_scroll = self.screen.detail_scroll.saturating_sub(1)
            }
            KeyCode::Enter => self.open_selected().await,
            KeyCode::Backspace | KeyCode::Left => self.back(),
            KeyCode::Char('r') => self.reload().await,
            KeyCode::Char('s') => self.open(ScreenKind::Servers).await,
            KeyCode::Char('/') => self.filter(),
            KeyCode::Char('v') => self.cycle_activity_filter().await,
            KeyCode::Char('b') => self.cycle_branch().await,
            KeyCode::Char('*') => self.toggle_favorite().await,
            KeyCode::Char(',') => {
                self.editor = Some(Editor::settings(self.config.tui.refresh_seconds));
            }
            KeyCode::Char('a') => self.add().await,
            KeyCode::Char('e') => self.edit().await,
            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.should_quit = true;
            }
            KeyCode::Char('c') => self.comment(),
            KeyCode::Char('x') => self.toggle_state().await,
            KeyCode::Char('d') => self.request_delete(),
            KeyCode::Char('f') => self.open_files().await,
            KeyCode::Esc => self.back(),
            _ => {}
        }
    }

    pub async fn handle_mouse(&mut self, mouse: MouseEvent) {
        self.last_input = Instant::now();
        self.status_deadline = None;
        if self.editor.is_some() || self.confirm.is_some() {
            return;
        }
        if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
            if let Some(index) = self
                .tab_areas
                .iter()
                .position(|area| area.contains((mouse.column, mouse.row).into()))
            {
                self.switch_tab(Tab::ALL[index]).await;
                return;
            }
            if let Some(index) = list_item_at(
                self.list_area,
                self.list_offset,
                mouse.column,
                mouse.row,
                self.screen.items.len(),
            ) {
                let now = Instant::now();
                let opens = self.last_click.is_some_and(|(last, time)| {
                    last == index && time.elapsed() < Duration::from_millis(500)
                });
                self.screen.select(index);
                self.last_click = Some((index, now));
                if opens {
                    self.open_selected().await;
                }
            }
        }
        match mouse.kind {
            MouseEventKind::ScrollDown
                if self.list_area.contains((mouse.column, mouse.row).into()) =>
            {
                let was_last = self.screen.selected + 1 == self.screen.items.len();
                self.move_selection(1);
                if was_last && self.screen.has_more {
                    self.change_page(1).await;
                }
            }
            MouseEventKind::ScrollUp
                if self.list_area.contains((mouse.column, mouse.row).into()) =>
            {
                let was_first = self.screen.selected == 0;
                self.move_selection(-1);
                if was_first && self.screen.page > 1 {
                    self.change_page(-1).await;
                }
            }
            MouseEventKind::ScrollDown
                if self.detail_area.contains((mouse.column, mouse.row).into()) =>
            {
                self.screen.detail_scroll = self.screen.detail_scroll.saturating_add(1);
            }
            MouseEventKind::ScrollUp
                if self.detail_area.contains((mouse.column, mouse.row).into()) =>
            {
                self.screen.detail_scroll = self.screen.detail_scroll.saturating_sub(1);
            }
            _ => {}
        }
    }

    pub async fn reload(&mut self) {
        let kind = self.screen.kind.clone();
        let page = self.screen.page;
        let selected_key = self.screen.selected_item().map(|item| item.key.clone());
        self.status = "Loading…".into();
        self.status_deadline = None;
        match self.load_screen(kind, page).await {
            Ok(mut screen) => {
                if let Some(key) = selected_key
                    && let Some(index) = screen.items.iter().position(|item| item.key == key)
                {
                    screen.select(index);
                }
                self.screen = screen;
                self.status = format!("Updated {}", clock());
                self.status_deadline = Some(Instant::now() + UPDATE_STATUS_DURATION);
            }
            Err(error) => self.status = error,
        }
        self.last_reload = Instant::now();
    }

    pub fn visible_status(&self) -> Option<&str> {
        (!self.status.is_empty()
            && self
                .status_deadline
                .is_none_or(|deadline| Instant::now() < deadline))
        .then_some(self.status.as_str())
    }

    async fn switch_tab(&mut self, tab: Tab) {
        self.tab = tab;
        self.history.clear();
        self.screen = Screen::new(root_kind(tab), tab.title());
        self.reload().await;
    }

    async fn open(&mut self, kind: ScreenKind) {
        match self.load_screen(kind, 1).await {
            Ok(screen) => {
                self.history.push(self.screen.clone());
                self.screen = screen;
                self.status.clear();
                self.last_reload = Instant::now();
            }
            Err(error) => self.status = error,
        }
    }

    fn back(&mut self) {
        if let Some(screen) = self.history.pop() {
            self.screen = screen;
            self.status.clear();
        }
    }

    fn move_selection(&mut self, amount: isize) {
        if self.screen.items.is_empty() {
            return;
        }
        let next = self.screen.selected.saturating_add_signed(amount);
        self.screen.select(next);
    }

    async fn change_page(&mut self, amount: i32) {
        if amount > 0 && !self.screen.has_more {
            self.status = "No next page".into();
            return;
        }
        let next = self.screen.page.saturating_add_signed(amount).max(1);
        if next == self.screen.page {
            self.status = "Already on the first page".into();
            return;
        }
        self.screen.page = next;
        self.screen.selected = 0;
        self.reload().await;
    }

    async fn open_selected(&mut self) {
        let Some(target) = self.screen.selected_item().map(|item| item.target.clone()) else {
            return;
        };
        match target {
            Target::None => {}
            Target::Server(name) => self.select_server(&name).await,
            Target::Repository(repository, destination) => {
                let kind = match destination {
                    RepositoryDestination::Issues => ScreenKind::Issues(repository),
                    RepositoryDestination::Commits => ScreenKind::Commits(repository),
                    RepositoryDestination::Milestones => ScreenKind::Milestones(repository),
                };
                self.open(kind).await;
            }
            Target::Issue(repository, number, _) => {
                self.open(ScreenKind::Issue(repository, number)).await;
            }
            Target::Pull(repository, number) => {
                self.open(ScreenKind::Pull(repository, number)).await;
            }
            Target::Milestone(repository, id, _) => {
                self.open(ScreenKind::Milestone(repository, id)).await;
            }
            Target::Commit(repository, sha) => {
                self.open(ScreenKind::Commit(repository, sha)).await;
            }
            Target::Content(repository, path, directory) => {
                self.open(if directory {
                    ScreenKind::Files(repository, path)
                } else {
                    ScreenKind::File(repository, path)
                })
                .await;
            }
            Target::Comment(_, _, _, _, _) => {}
            Target::Text(title, text) => {
                self.open(ScreenKind::Text(title, text)).await;
            }
        }
    }

    async fn select_server(&mut self, name: &str) {
        let Ok(selection) = self.config.select(Some(name), None) else {
            self.status = format!("Server {name:?} no longer exists");
            return;
        };
        match client(&selection) {
            Ok(client) => {
                self.client = client;
                self.server_name = name.into();
                self.history.clear();
                self.screen = Screen::new(root_kind(self.tab), self.tab.title());
                self.reload().await;
            }
            Err(error) => self.status = error,
        }
    }

    async fn add(&mut self) {
        match &self.screen.kind {
            ScreenKind::Servers => {
                self.editor = Some(Editor::server(
                    None,
                    String::new(),
                    "https://".into(),
                    Provider::Gitea,
                ));
            }
            ScreenKind::Issues(repository) => {
                self.editor = Some(Editor::issue(
                    repository.clone(),
                    None,
                    [
                        String::new(),
                        String::new(),
                        String::new(),
                        String::new(),
                        String::new(),
                        "open".into(),
                    ],
                ));
            }
            ScreenKind::Milestones(repository) => {
                self.editor = Some(Editor::milestone(
                    repository.clone(),
                    None,
                    String::new(),
                    String::new(),
                    String::new(),
                    "open".into(),
                ));
            }
            _ => self.status = "Add is available in Servers, Issues, and Milestones".into(),
        }
    }

    async fn edit(&mut self) {
        let target = self.screen.selected_item().map(|item| item.target.clone());
        match (&self.screen.kind, target) {
            (ScreenKind::Servers, Some(Target::Server(name))) => {
                let Some(server) = self.config.servers.get(&name) else {
                    self.status = "Server no longer exists".into();
                    return;
                };
                self.editor = Some(Editor::server(
                    Some(name.clone()),
                    name,
                    server.url.clone(),
                    server.provider,
                ));
            }
            (ScreenKind::Issue(repository, number), _) => {
                self.open_issue_editor(repository.clone(), *number).await;
            }
            (_, Some(Target::Issue(repository, number, _))) => {
                self.open_issue_editor(repository, number).await;
            }
            (ScreenKind::Milestone(repository, id), _) => {
                self.open_milestone_editor(repository.clone(), *id).await;
            }
            (_, Some(Target::Milestone(repository, id, _))) => {
                self.open_milestone_editor(repository, id).await;
            }
            (_, Some(Target::Comment(repository, number, id, body, true))) => {
                self.editor = Some(Editor::comment(repository, number, Some(id), body));
            }
            _ => self.status = "Nothing editable is selected".into(),
        }
    }

    async fn open_issue_editor(&mut self, repository: RepositoryId, number: i64) {
        match self.client.issue_editor(&repository, Some(number)).await {
            Ok(data) => {
                let issue = data.issue.unwrap_or_default();
                let labels = issue
                    .labels
                    .unwrap_or_default()
                    .into_iter()
                    .filter_map(|label| label.name)
                    .collect::<Vec<_>>()
                    .join(", ");
                let milestone = issue
                    .milestone
                    .and_then(|milestone| milestone.title)
                    .unwrap_or_default();
                self.editor = Some(Editor::issue(
                    repository,
                    Some(number),
                    [
                        issue.title.unwrap_or_default(),
                        issue.body.unwrap_or_default(),
                        labels,
                        milestone,
                        issue
                            .due_date
                            .unwrap_or_default()
                            .chars()
                            .take(10)
                            .collect(),
                        issue.state.unwrap_or_else(|| "open".into()),
                    ],
                ));
            }
            Err(error) => self.status = error.to_string(),
        }
    }

    async fn open_milestone_editor(&mut self, repository: RepositoryId, id: i64) {
        match self.client.milestone(&repository, id).await {
            Ok(milestone) => {
                self.editor = Some(Editor::milestone(
                    repository,
                    Some(id),
                    milestone.title.unwrap_or_default(),
                    milestone.description.unwrap_or_default(),
                    milestone
                        .due_on
                        .unwrap_or_default()
                        .chars()
                        .take(10)
                        .collect(),
                    milestone.state.unwrap_or_else(|| "open".into()),
                ));
            }
            Err(error) => self.status = error.to_string(),
        }
    }

    fn comment(&mut self) {
        if let ScreenKind::Issue(repository, number) = &self.screen.kind {
            self.editor = Some(Editor::comment(
                repository.clone(),
                *number,
                None,
                String::new(),
            ));
        } else {
            self.status = "Open an issue before adding a comment".into();
        }
    }

    fn filter(&mut self) {
        match self.screen.kind {
            ScreenKind::Issues(_) => {
                self.editor = Some(Editor::issue_filter(
                    &self.issue_state,
                    &self.issue_labels,
                    &self.issue_milestone,
                    &self.issue_search,
                ));
            }
            ScreenKind::Pulls => {
                self.editor = Some(Editor::pull_filter(
                    &self.pull_state,
                    &self.pull_milestone,
                    &self.pull_search,
                ));
            }
            _ => self.status = "Filters are available in issue and pull-request lists".into(),
        }
    }

    async fn cycle_activity_filter(&mut self) {
        if self.screen.kind != ScreenKind::Home {
            self.status = "Activity filters are available on Home".into();
            return;
        }
        self.activity_filter = match self.activity_filter {
            ActivityFilter::All => ActivityFilter::Issues,
            ActivityFilter::Issues => ActivityFilter::PullRequests,
            ActivityFilter::PullRequests => ActivityFilter::All,
        };
        self.screen.page = 1;
        self.reload().await;
    }

    async fn cycle_branch(&mut self) {
        let ScreenKind::Commits(repository) = &self.screen.kind else {
            self.status = "Branch switching is available in a repository commit list".into();
            return;
        };
        let repository = repository.clone();
        let data = match self
            .client
            .repository(&repository.owner, &repository.repository)
            .await
        {
            Ok(data) => data,
            Err(error) => {
                self.status = error.to_string();
                return;
            }
        };
        let default = data.default_branch.unwrap_or_else(|| "main".into());
        let mut branches = match self.client.branches(&repository, &default).await {
            Ok(branches) => branches,
            Err(error) => {
                self.status = error.to_string();
                return;
            }
        };
        if branches.is_empty() {
            return;
        }
        branches.push(ALL_BRANCHES.into());
        let key = repository_key(&repository);
        let current = self
            .branches
            .get(&key)
            .map(String::as_str)
            .unwrap_or(&default);
        let index = branches
            .iter()
            .position(|branch| branch == current)
            .unwrap_or(0);
        self.branches
            .insert(key, branches[(index + 1) % branches.len()].clone());
        self.screen.page = 1;
        self.reload().await;
    }

    async fn toggle_favorite(&mut self) {
        let ScreenKind::Repositories(destination) = self.screen.kind else {
            self.status = "Favorites are available in repository lists".into();
            return;
        };
        let Some(Target::Repository(repository, _)) =
            self.screen.selected_item().map(|item| item.target.clone())
        else {
            return;
        };
        let Some(server_url) = self
            .config
            .servers
            .get(&self.server_name)
            .map(|server| server.url.clone())
        else {
            self.status = "Select a server first".into();
            return;
        };
        match self.config.toggle_tui_favorite(
            repository_pane(destination),
            &server_url,
            &repository,
        ) {
            Ok(()) => self.reload().await,
            Err(error) => self.status = error,
        }
    }

    async fn toggle_state(&mut self) {
        let target = self.screen.selected_item().map(|item| item.target.clone());
        let result = match (&self.screen.kind, target) {
            (ScreenKind::Issue(repository, number), _) => {
                let state = state_from_detail(&self.screen.detail);
                self.client
                    .set_issue_state(repository, &[*number], opposite_state(state))
                    .await
                    .map(|_| ())
            }
            (_, Some(Target::Issue(repository, number, state))) => self
                .client
                .set_issue_state(&repository, &[number], opposite_state(&state))
                .await
                .map(|_| ()),
            (ScreenKind::Milestone(repository, id), _) => {
                let closed = state_from_detail(&self.screen.detail) != "closed";
                self.client
                    .set_milestone_closed(repository, *id, closed)
                    .await
                    .map(|_| ())
            }
            (_, Some(Target::Milestone(repository, id, state))) => self
                .client
                .set_milestone_closed(&repository, id, state != "closed")
                .await
                .map(|_| ()),
            _ => {
                self.status = "Nothing with an open/closed state is selected".into();
                return;
            }
        };
        match result {
            Ok(()) => self.reload().await,
            Err(error) => self.status = error.to_string(),
        }
    }

    fn request_delete(&mut self) {
        let target = self.screen.selected_item().map(|item| item.target.clone());
        let action = match (&self.screen.kind, target) {
            (ScreenKind::Servers, Some(Target::Server(name))) => Some((
                format!("Delete server profile {name:?}?"),
                ConfirmAction::Server(name),
            )),
            (ScreenKind::Issue(repository, number), _) => Some((
                format!("Permanently delete issue #{number}?"),
                ConfirmAction::Issue(repository.clone(), *number),
            )),
            (_, Some(Target::Issue(repository, number, _))) => Some((
                format!("Permanently delete issue #{number}?"),
                ConfirmAction::Issue(repository, number),
            )),
            (ScreenKind::Milestone(repository, id), _) => Some((
                format!("Permanently delete milestone {id}?"),
                ConfirmAction::Milestone(repository.clone(), *id),
            )),
            (_, Some(Target::Milestone(repository, id, _))) => Some((
                format!("Permanently delete milestone {id}?"),
                ConfirmAction::Milestone(repository, id),
            )),
            _ => None,
        };
        if let Some(action) = action {
            self.confirm = Some(action);
        } else {
            self.status = "Nothing deletable is selected".into();
        }
    }

    async fn perform_confirmed(&mut self, action: ConfirmAction) {
        self.confirm = None;
        let result = match action {
            ConfirmAction::Issue(repository, number) => {
                self.client.delete_issue(&repository, number).await
            }
            ConfirmAction::Milestone(repository, id) => {
                self.client
                    .delete_milestone(&repository, &id.to_string())
                    .await
            }
            ConfirmAction::Server(name) => {
                let result = self
                    .config
                    .logout(&name)
                    .map_err(gotcha_gitea::Error::Configuration);
                if result.is_ok() && name == self.server_name {
                    if let Some(next) = self.config.servers.keys().next().cloned() {
                        self.select_server(&next).await;
                    } else {
                        self.server_name = "No server".into();
                        self.client = Client::new("https://no-server.invalid", None)
                            .expect("static placeholder URL is valid");
                        self.history.clear();
                        self.screen = Screen::new(ScreenKind::Servers, "Servers");
                    }
                }
                result
            }
        };
        match result {
            Ok(()) => {
                if !self.history.is_empty() && !self.screen.kind.is_overview() {
                    self.back();
                }
                self.reload().await;
            }
            Err(error) => self.status = error.to_string(),
        }
    }

    async fn open_files(&mut self) {
        let repository = match &self.screen.kind {
            ScreenKind::Commits(repository) | ScreenKind::Commit(repository, _) => {
                Some(repository.clone())
            }
            _ => None,
        };
        if let Some(repository) = repository {
            self.open(ScreenKind::Files(repository, String::new()))
                .await;
        } else {
            self.status = "Open a repository commit view before browsing files".into();
        }
    }

    async fn submit_editor(&mut self) {
        let Some(editor) = self.editor.clone() else {
            return;
        };
        let values = editor.values();
        let result: Result<(), String> = match editor.action {
            EditorAction::Issue { repository, number } => {
                self.save_issue(&repository, number, &values).await
            }
            EditorAction::Comment {
                repository,
                number,
                comment_id,
            } => self
                .client
                .save_issue_comment(&repository, number, comment_id, values[0].clone())
                .await
                .map(|_| ())
                .map_err(|error| error.to_string()),
            EditorAction::Milestone { repository, id } => self
                .client
                .save_milestone(
                    &repository,
                    id,
                    MilestoneDraft {
                        title: values[0].clone(),
                        description: values[1].clone(),
                        due_on: due_date(&values[2]),
                        state: values[3].trim().into(),
                    },
                )
                .await
                .map(|_| ())
                .map_err(|error| error.to_string()),
            EditorAction::Server { original_name } => {
                self.save_server(original_name.as_deref(), &values).await
            }
            EditorAction::Settings => values[0]
                .trim()
                .parse::<u64>()
                .map_err(|_| "Auto-reload seconds must be a non-negative integer.".to_string())
                .and_then(|seconds| self.config.set_tui_refresh_seconds(seconds)),
            EditorAction::IssueFilter => {
                if !matches!(values[0].trim(), "open" | "closed" | "all") {
                    Err("Issue state must be open, closed, or all.".into())
                } else {
                    self.issue_state = values[0].trim().into();
                    self.issue_labels = values[1].trim().into();
                    self.issue_milestone = values[2].trim().into();
                    self.issue_search = values[3].trim().into();
                    self.screen.page = 1;
                    Ok(())
                }
            }
            EditorAction::PullFilter => {
                if !matches!(values[0].trim(), "open" | "closed") {
                    Err("Pull-request state must be open or closed.".into())
                } else {
                    self.pull_state = values[0].trim().into();
                    self.pull_milestone = values[1].trim().into();
                    self.pull_search = values[2].trim().into();
                    self.screen.page = 1;
                    Ok(())
                }
            }
        };
        match result {
            Ok(()) => {
                self.editor = None;
                self.reload().await;
            }
            Err(error) => self.status = error,
        }
    }

    async fn save_issue(
        &self,
        repository: &RepositoryId,
        number: Option<i64>,
        values: &[String],
    ) -> Result<(), String> {
        let labels = values[2]
            .split(',')
            .map(str::trim)
            .filter(|label| !label.is_empty())
            .map(str::to_owned)
            .collect::<Vec<_>>();
        let label_ids = self
            .client
            .resolve_label_ids(repository, &labels)
            .await
            .map_err(|error| error.to_string())?;
        let milestone = values[3].trim();
        let milestone_id = if milestone.is_empty() {
            None
        } else {
            Some(
                self.client
                    .resolve_milestone_id(repository, milestone)
                    .await
                    .map_err(|error| error.to_string())?,
            )
        };
        let draft = IssueDraft {
            title: values[0].clone(),
            body: values[1].clone(),
            label_ids,
            milestone_id,
            due_date: due_date(&values[4]),
            closed: values[5].trim() == "closed",
        };
        match number {
            Some(number) => {
                self.client
                    .edit_issue_draft(repository, number, draft)
                    .await
            }
            None => self.client.create_issue_draft(repository, draft).await,
        }
        .map(|_| ())
        .map_err(|error| error.to_string())
    }

    async fn save_server(
        &mut self,
        original_name: Option<&str>,
        values: &[String],
    ) -> Result<(), String> {
        let name = values[0].trim();
        let url = values[1].trim();
        let fallback = values[3]
            .trim()
            .parse::<Provider>()
            .map_err(|error| error.to_string())?;
        let existing_token = original_name
            .and_then(|name| self.config.servers.get(name))
            .map(|server| server.token.clone())
            .unwrap_or_default();
        let token = if values[2].is_empty() {
            existing_token
        } else {
            values[2].clone()
        };
        if token.is_empty() {
            return Err("Token must not be empty.".into());
        }
        let discovered = Client::discover(url, Some(&token), fallback)
            .await
            .map_err(|error| error.to_string())?;
        discovered
            .current_user()
            .await
            .map_err(|error| error.to_string())?;
        if let Some(original) = original_name {
            self.config
                .replace_server(original, name, url, &token, discovered.provider())?;
        } else {
            self.config
                .save_server(name, url, &token, discovered.provider())?;
        }
        if original_name == Some(self.server_name.as_str()) {
            self.server_name = name.into();
            self.client = discovered;
        }
        Ok(())
    }

    async fn load_screen(&mut self, kind: ScreenKind, page: u32) -> Result<Screen, String> {
        match kind {
            ScreenKind::Servers => self.load_servers(),
            ScreenKind::Home => self.load_home(page).await,
            ScreenKind::Repositories(destination) => {
                self.load_repositories(destination, page).await
            }
            ScreenKind::Issues(repository) => self.load_issues(repository, page).await,
            ScreenKind::Issue(repository, number) => self.load_issue(repository, number).await,
            ScreenKind::Commits(repository) => self.load_commits(repository, page).await,
            ScreenKind::Commit(repository, sha) => self.load_commit(repository, sha).await,
            ScreenKind::Files(repository, path) => self.load_files(repository, path).await,
            ScreenKind::File(repository, path) => self.load_file(repository, path).await,
            ScreenKind::Text(title, text) => {
                let mut screen = Screen::new(ScreenKind::Text(title.clone(), text.clone()), title);
                screen.detail = text;
                Ok(screen)
            }
            ScreenKind::Pulls => self.load_pulls(page).await,
            ScreenKind::Pull(repository, number) => self.load_pull(repository, number, page).await,
            ScreenKind::Milestones(repository) => self.load_milestones(repository).await,
            ScreenKind::Milestone(repository, id) => {
                self.load_milestone(repository, id, page).await
            }
        }
    }

    fn load_servers(&self) -> Result<Screen, String> {
        let mut screen = Screen::new(ScreenKind::Servers, "Servers");
        screen.detail = "Enter switches server. a adds, e edits, d deletes.".into();
        screen.items = self
            .config
            .servers
            .iter()
            .map(|(name, server)| Item {
                key: name.clone(),
                title: if *name == self.server_name {
                    format!("● {name}")
                } else {
                    name.clone()
                },
                graph_lane: None,
                decoration: ItemDecoration::None,
                meta: format!("{} · {}", server.provider, server.url),
                detail: format!(
                    "Profile: {name}\nProvider: {}\nURL: {}\nToken: ••••••••",
                    server.provider, server.url
                ),
                target: Target::Server(name.clone()),
            })
            .collect();
        Ok(screen)
    }

    async fn load_home(&mut self, page: u32) -> Result<Screen, String> {
        let data = self
            .client
            .home(page as i32, self.activity_filter)
            .await
            .map_err(err)?;
        self.load_activity_titles(&data.activities).await;
        let contributions: i64 = data
            .heatmap
            .iter()
            .map(|cell| cell.contributions.unwrap_or_default())
            .sum();
        let mut screen = Screen::new(ScreenKind::Home, format!("Home · {}", self.server_name));
        screen.page = page;
        screen.has_more = data.next_page.is_some();
        screen.detail = format!(
            "{contributions} contributions in the available heatmap range\nFilter: {:?}\n\n[v] cycle activity filter · 1–5 switch panes · s servers · , preferences",
            self.activity_filter
        );
        screen.items = data
            .activities
            .into_iter()
            .enumerate()
            .map(|(index, activity)| {
                let repository = activity
                    .repo
                    .as_ref()
                    .and_then(|repo| repo.full_name.as_deref())
                    .unwrap_or("repository");
                let activity_kind = activity_kind(&activity);
                let resolved_title = activity_title_key(&self.server_name, &activity)
                    .and_then(|key| self.activity_titles.get(&key))
                    .and_then(Option::as_deref);
                let title = activity_title(&activity, resolved_title, repository);
                let detail = activity_detail(&activity);
                let target = match gotcha_gitea::activity::target(&activity) {
                    Some(gotcha_gitea::activity::Target::Repository { owner, repository }) => {
                        Target::Repository(
                            RepositoryId { owner, repository },
                            RepositoryDestination::Commits,
                        )
                    }
                    Some(gotcha_gitea::activity::Target::Issue {
                        owner,
                        repository,
                        number,
                    }) => Target::Issue(RepositoryId { owner, repository }, number, "open".into()),
                    Some(gotcha_gitea::activity::Target::Pull {
                        owner,
                        repository,
                        number,
                    }) => Target::Pull(RepositoryId { owner, repository }, number),
                    Some(gotcha_gitea::activity::Target::Commit {
                        owner,
                        repository,
                        sha,
                    }) => Target::Commit(RepositoryId { owner, repository }, sha),
                    None => Target::None,
                };
                Item {
                    key: format!(
                        "{}-{index}",
                        activity.created.as_deref().unwrap_or_default()
                    ),
                    title,
                    graph_lane: None,
                    decoration: ItemDecoration::Activity(activity_kind),
                    meta: activity.created.unwrap_or_default(),
                    detail,
                    target,
                }
            })
            .collect();
        Ok(screen)
    }

    async fn load_activity_titles(&mut self, activities: &[models::Activity]) {
        let mut requests = tokio::task::JoinSet::new();
        let mut pending = BTreeSet::new();
        for key in activities
            .iter()
            .filter_map(|activity| activity_title_key(&self.server_name, activity))
        {
            if self.activity_titles.contains_key(&key) || !pending.insert(key.clone()) {
                continue;
            }
            let client = self.client.clone();
            requests.spawn(async move {
                let title = match &key {
                    ActivityTitleKey::Issue(_, owner, repository, number) => client
                        .issue(
                            &RepositoryId {
                                owner: owner.clone(),
                                repository: repository.clone(),
                            },
                            *number,
                        )
                        .await
                        .ok()
                        .and_then(|issue| issue.title),
                    ActivityTitleKey::Pull(_, owner, repository, number) => client
                        .pull(
                            &RepositoryId {
                                owner: owner.clone(),
                                repository: repository.clone(),
                            },
                            *number,
                        )
                        .await
                        .ok()
                        .and_then(|pull| pull.title),
                };
                (key, title)
            });
        }
        while let Some(Ok((key, title))) = requests.join_next().await {
            self.activity_titles.insert(key, title);
        }
    }

    async fn load_repositories(
        &self,
        destination: RepositoryDestination,
        page: u32,
    ) -> Result<Screen, String> {
        let repositories = self
            .client
            .owned_repositories_page(page as i32, 30)
            .await
            .map_err(err)?;
        let server_url = self
            .config
            .servers
            .get(&self.server_name)
            .map(|server| server.url.as_str())
            .unwrap_or_default();
        let pane = repository_pane(destination);
        let mut screen = Screen::new(
            ScreenKind::Repositories(destination),
            match destination {
                RepositoryDestination::Issues => "Issue repositories",
                RepositoryDestination::Commits => "Repositories",
                RepositoryDestination::Milestones => "Milestone repositories",
            },
        );
        screen.page = page;
        screen.has_more = repositories.has_more;
        let mut items = repositories
            .items
            .into_iter()
            .filter_map(|repository| {
                let owner = repository.owner.as_ref()?.login.clone()?;
                let name = repository.name.clone()?;
                let id = RepositoryId {
                    owner,
                    repository: name.clone(),
                };
                let favorite = self.config.is_tui_favorite(pane, server_url, &id);
                Some((
                    favorite,
                    Item {
                        key: format!("{}/{}", id.owner, id.repository),
                        title: format!("{} {name}", if favorite { "★" } else { " " }),
                        graph_lane: None,
                        decoration: ItemDecoration::None,
                        meta: format!(
                            "{} · {} open · {}",
                            repository.language.as_deref().unwrap_or(""),
                            repository.open_issues_count.unwrap_or_default(),
                            repository.updated_at.as_deref().unwrap_or("")
                        ),
                        detail: repository
                            .description
                            .unwrap_or_else(|| "No description".into()),
                        target: Target::Repository(id, destination),
                    },
                ))
            })
            .collect::<Vec<_>>();
        items.sort_by(|(left_favorite, left), (right_favorite, right)| {
            right_favorite
                .cmp(left_favorite)
                .then_with(|| left.title.to_lowercase().cmp(&right.title.to_lowercase()))
        });
        screen.items = items.into_iter().map(|(_, item)| item).collect();
        screen.detail = "[*] favorite · Enter opens the selected repository".into();
        Ok(screen)
    }

    async fn load_issues(&self, repository: RepositoryId, page: u32) -> Result<Screen, String> {
        let query = IssueQuery {
            state: self.issue_state.clone(),
            labels: (!self.issue_labels.is_empty()).then(|| self.issue_labels.clone()),
            keyword: (!self.issue_search.is_empty()).then(|| self.issue_search.clone()),
            milestones: (!self.issue_milestone.is_empty()).then(|| self.issue_milestone.clone()),
            page: page as i32,
            ..Default::default()
        };
        let result = self.client.issues(&repository, &query).await.map_err(err)?;
        let mut screen = Screen::new(
            ScreenKind::Issues(repository.clone()),
            format!("{}/{} · Issues", repository.owner, repository.repository),
        );
        screen.page = page;
        screen.has_more = result.has_more;
        screen.detail = format!(
            "Filters: state={} labels={} milestone={} search={}\n\n[/] filters · a creates · e edits · x closes/reopens · d deletes",
            self.issue_state,
            empty_as_all(&self.issue_labels),
            empty_as_all(&self.issue_milestone),
            empty_as_all(&self.issue_search)
        );
        screen.items = result
            .items
            .into_iter()
            .filter_map(|issue| issue_item(&repository, issue))
            .collect();
        Ok(screen)
    }

    async fn load_issue(&self, repository: RepositoryId, number: i64) -> Result<Screen, String> {
        let details = self
            .client
            .issue_details(&repository, number)
            .await
            .map_err(err)?;
        let issue = details.issue;
        let state = issue.state.as_deref().unwrap_or("unknown");
        let mut screen = Screen::new(
            ScreenKind::Issue(repository.clone(), number),
            format!(
                "Issue #{number} · {}",
                issue.title.as_deref().unwrap_or("Untitled")
            ),
        );
        screen.detail = format!(
            "State: {state}  \nAuthor: {}  \nUpdated: {}\n\n{}\n\n[e] edit  [c] comment  [x] state  [d] delete",
            issue
                .user
                .as_ref()
                .and_then(|user| user.login.as_deref())
                .unwrap_or("unknown"),
            issue.updated_at.as_deref().unwrap_or(""),
            issue.body.as_deref().unwrap_or("No description")
        );
        let viewer_id = details.viewer_id;
        screen.items = details
            .comments
            .into_iter()
            .enumerate()
            .map(|(index, comment)| {
                let editable = gotcha_gitea::comment_can_edit(&comment, viewer_id);
                let id = comment.id;
                let body = comment.body.unwrap_or_default();
                Item {
                    key: id.map_or_else(|| index.to_string(), |id| id.to_string()),
                    title: format!(
                        "{} commented",
                        comment
                            .user
                            .as_ref()
                            .and_then(|user| user.login.as_deref())
                            .unwrap_or("unknown")
                    ),
                    graph_lane: None,
                    decoration: ItemDecoration::None,
                    meta: comment.updated_at.unwrap_or_default(),
                    detail: body.clone(),
                    target: id.map_or(Target::None, |id| {
                        Target::Comment(repository.clone(), number, id, body, editable)
                    }),
                }
            })
            .collect();
        Ok(screen)
    }

    async fn load_commits(&self, repository: RepositoryId, page: u32) -> Result<Screen, String> {
        let repository_data = self
            .client
            .repository(&repository.owner, &repository.repository)
            .await
            .map_err(err)?;
        let default_branch = repository_data
            .default_branch
            .unwrap_or_else(|| "main".into());
        let branch = self
            .branches
            .get(&repository_key(&repository))
            .cloned()
            .unwrap_or_else(|| default_branch.clone());
        let branches = self
            .client
            .branches(&repository, &default_branch)
            .await
            .map_err(err)?;
        let result = if branch == ALL_BRANCHES {
            self.client
                .all_history(&repository, &branches, None, page)
                .await
        } else {
            self.client
                .branch_history(&repository, &branch, None, page)
                .await
        }
        .map_err(err)?;
        let mut screen = Screen::new(
            ScreenKind::Commits(repository.clone()),
            format!("{}/{} · {branch}", repository.owner, repository.repository),
        );
        screen.page = page;
        screen.has_more = result.has_more;
        screen.detail = format!(
            "Branches: {}\n\n[b] next branch · [f] browse repository files",
            branches
                .iter()
                .map(String::as_str)
                .collect::<Vec<_>>()
                .join(", ")
        );
        screen.items = result
            .items
            .into_iter()
            .filter_map(|history| commit_item(&repository, history))
            .collect();
        Ok(screen)
    }

    async fn load_commit(&self, repository: RepositoryId, sha: String) -> Result<Screen, String> {
        let commit = self.client.commit(&repository, &sha).await.map_err(err)?;
        let diff = self
            .client
            .commit_diff(&repository, &sha)
            .await
            .map_err(err)?;
        let details = commit.commit.as_ref();
        let message = details
            .and_then(|commit| commit.message.as_deref())
            .unwrap_or("Commit");
        let mut screen = Screen::new(
            ScreenKind::Commit(repository, sha.clone()),
            format!("Commit {}", short_sha(&sha)),
        );
        screen.detail = format!(
            "{message}\n\nAuthor: {}\nCommit: {sha}",
            details
                .and_then(|commit| commit.author.as_ref())
                .and_then(|author| author.name.as_deref())
                .unwrap_or("unknown")
        );
        screen.items = diff_files(&diff);
        Ok(screen)
    }

    async fn load_files(&self, repository: RepositoryId, path: String) -> Result<Screen, String> {
        let contents = self
            .client
            .repository_contents(&repository, &path)
            .await
            .map_err(err)?;
        let mut screen = Screen::new(
            ScreenKind::Files(repository.clone(), path.clone()),
            if path.is_empty() {
                format!("{}/{} · Files", repository.owner, repository.repository)
            } else {
                path.clone()
            },
        );
        screen.items = contents
            .into_iter()
            .filter_map(|content| {
                let name = content.name?;
                let path = content.path?;
                let directory = content.r#type.as_deref() == Some("dir");
                Some(Item {
                    key: path.clone(),
                    title: format!("{} {name}", if directory { "▸" } else { " " }),
                    graph_lane: None,
                    decoration: ItemDecoration::None,
                    meta: if directory {
                        "directory".into()
                    } else {
                        format!("{} bytes", content.size.unwrap_or_default())
                    },
                    detail: path.clone(),
                    target: Target::Content(repository.clone(), path, directory),
                })
            })
            .collect();
        screen
            .items
            .sort_by_key(|item| (!item.title.starts_with('▸'), item.title.to_lowercase()));
        Ok(screen)
    }

    async fn load_file(&self, repository: RepositoryId, path: String) -> Result<Screen, String> {
        let data = self
            .client
            .repository_file(&repository, &path)
            .await
            .map_err(err)?;
        let mut screen = Screen::new(ScreenKind::File(repository, path.clone()), path);
        screen.detail = String::from_utf8(data)
            .unwrap_or_else(|error| format!("Binary file ({} bytes)", error.as_bytes().len()));
        Ok(screen)
    }

    async fn load_pulls(&self, page: u32) -> Result<Screen, String> {
        let result = self
            .client
            .search_pulls(
                &self.pull_state,
                (!self.pull_milestone.is_empty()).then_some(self.pull_milestone.as_str()),
                (!self.pull_search.is_empty()).then_some(self.pull_search.as_str()),
                page as i32,
                30,
            )
            .await
            .map_err(err)?;
        let mut screen = Screen::new(ScreenKind::Pulls, "Pull requests");
        screen.page = page;
        screen.has_more = result.has_more;
        screen.detail = format!(
            "Filters: state={} milestone={} search={}\n\n[/] filters",
            self.pull_state,
            empty_as_all(&self.pull_milestone),
            empty_as_all(&self.pull_search)
        );
        screen.items = result.items.into_iter().filter_map(pull_item).collect();
        Ok(screen)
    }

    async fn load_pull(
        &self,
        repository: RepositoryId,
        number: i64,
        page: u32,
    ) -> Result<Screen, String> {
        let details = self
            .client
            .pull_details(&repository, number, page as i32)
            .await
            .map_err(err)?;
        let diff = self
            .client
            .pull_diff(&repository, number)
            .await
            .map_err(err)?;
        let pull = details.pull;
        let mut screen = Screen::new(
            ScreenKind::Pull(repository, number),
            format!(
                "PR #{number} · {}",
                pull.title.as_deref().unwrap_or("Untitled")
            ),
        );
        screen.page = page;
        screen.has_more = details.has_more;
        screen.detail = format!(
            "State: {}  \n{} → {}  \nFiles: {}  +{} -{}\n\n{}",
            gotcha_gitea::pull_state(&pull),
            pull.head
                .as_ref()
                .and_then(|head| head.label.as_deref().or(head.r#ref.as_deref()))
                .unwrap_or("head"),
            pull.base
                .as_ref()
                .and_then(|base| base.label.as_deref().or(base.r#ref.as_deref()))
                .unwrap_or("base"),
            pull.changed_files.unwrap_or_default(),
            pull.additions.unwrap_or_default(),
            pull.deletions.unwrap_or_default(),
            pull.body.as_deref().unwrap_or("No description")
        );
        screen.items = details
            .files
            .into_iter()
            .filter_map(|file| {
                let path = file.filename?;
                Some(Item {
                    key: path.clone(),
                    title: path.clone(),
                    graph_lane: None,
                    decoration: ItemDecoration::None,
                    meta: file.status.unwrap_or_else(|| "modified".into()),
                    detail: format!(
                        "+{} -{}",
                        file.additions.unwrap_or_default(),
                        file.deletions.unwrap_or_default()
                    ),
                    target: Target::Text(path.clone(), diff_for_file(&diff, &path)),
                })
            })
            .chain(
                details
                    .comments
                    .into_iter()
                    .enumerate()
                    .map(|(index, comment)| Item {
                        key: format!("comment-{index}"),
                        title: format!(
                            "Comment by {}",
                            comment
                                .user
                                .as_ref()
                                .and_then(|user| user.login.as_deref())
                                .unwrap_or("unknown")
                        ),
                        graph_lane: None,
                        decoration: ItemDecoration::None,
                        meta: comment.updated_at.unwrap_or_default(),
                        detail: comment.body.unwrap_or_default(),
                        target: Target::None,
                    }),
            )
            .collect();
        Ok(screen)
    }

    async fn load_milestones(&self, repository: RepositoryId) -> Result<Screen, String> {
        let mut milestones = self.client.milestones(&repository).await.map_err(err)?;
        sort_milestones(&mut milestones);
        let mut screen = Screen::new(
            ScreenKind::Milestones(repository.clone()),
            format!(
                "{}/{} · Milestones",
                repository.owner, repository.repository
            ),
        );
        screen.detail = "a creates · e edits · x closes/reopens · d deletes".into();
        screen.items = milestones
            .into_iter()
            .filter_map(|milestone| milestone_item(&repository, milestone))
            .collect();
        Ok(screen)
    }

    async fn load_milestone(
        &self,
        repository: RepositoryId,
        id: i64,
        page: u32,
    ) -> Result<Screen, String> {
        let details = self
            .client
            .milestone_details(&repository, id, page as i32)
            .await
            .map_err(err)?;
        let milestone = details.milestone;
        let mut screen = Screen::new(
            ScreenKind::Milestone(repository.clone(), id),
            milestone
                .title
                .clone()
                .unwrap_or_else(|| "Milestone".into()),
        );
        screen.page = page;
        screen.has_more = details.has_more;
        screen.detail = format!(
            "State: {}  \nDue: {}  \nOpen: {}  Closed: {}\n\n{}\n\n[e] edit  [x] state  [d] delete",
            milestone.state.as_deref().unwrap_or("unknown"),
            milestone.due_on.as_deref().unwrap_or("none"),
            milestone.open_issues.unwrap_or_default(),
            milestone.closed_issues.unwrap_or_default(),
            milestone.description.as_deref().unwrap_or("No description")
        );
        screen.items = details
            .issues
            .into_iter()
            .filter_map(|issue| issue_item(&repository, issue))
            .chain(details.pulls.into_iter().filter_map(pull_item))
            .collect();
        Ok(screen)
    }
}

fn list_item_at(
    area: ratatui::layout::Rect,
    offset: usize,
    column: u16,
    row: u16,
    item_count: usize,
) -> Option<usize> {
    let inner = area.inner(ratatui::layout::Margin {
        horizontal: 1,
        vertical: 1,
    });
    inner
        .contains((column, row).into())
        .then(|| offset + row.saturating_sub(inner.y) as usize / 2)
        .filter(|index| *index < item_count)
}

fn initial_selection(config: &Config, requested: Option<&str>) -> Result<Selection, String> {
    if let Some(name) = requested {
        return config.select(Some(name), None);
    }
    if let Some(name) = config
        .tui
        .last_server
        .as_deref()
        .filter(|name| config.servers.contains_key(*name))
    {
        return config.select(Some(name), None);
    }
    config.select(None, None).or_else(|_| {
        let name = config
            .servers
            .keys()
            .next()
            .ok_or("no servers configured; run `gotcha auth login SERVER`")?;
        config.select(Some(name), None)
    })
}

fn client(selection: &Selection) -> Result<Client, String> {
    Client::with_provider(
        &selection.url,
        selection.token.as_deref(),
        selection.provider,
    )
    .map_err(|error| error.to_string())
}

fn root_kind(tab: Tab) -> ScreenKind {
    match tab {
        Tab::Home => ScreenKind::Home,
        Tab::Issues => ScreenKind::Repositories(RepositoryDestination::Issues),
        Tab::Repositories => ScreenKind::Repositories(RepositoryDestination::Commits),
        Tab::Pulls => ScreenKind::Pulls,
        Tab::Milestones => ScreenKind::Repositories(RepositoryDestination::Milestones),
    }
}

fn repository_pane(destination: RepositoryDestination) -> &'static str {
    match destination {
        RepositoryDestination::Issues => "issues",
        RepositoryDestination::Commits => "commits",
        RepositoryDestination::Milestones => "milestones",
    }
}

fn issue_item(repository: &RepositoryId, issue: models::Issue) -> Option<Item> {
    let number = issue.number?;
    let state = issue.state.unwrap_or_else(|| "unknown".into());
    Some(Item {
        key: number.to_string(),
        title: format!("#{number} {}", issue.title.as_deref().unwrap_or("Untitled")),
        graph_lane: None,
        decoration: ItemDecoration::State(work_item_state(&state)),
        meta: format!(
            "{} · {} · {} comments",
            state,
            issue.updated_at.as_deref().unwrap_or(""),
            issue.comments.unwrap_or_default()
        ),
        detail: issue.body.unwrap_or_else(|| "No description".into()),
        target: Target::Issue(repository.clone(), number, state),
    })
}

fn pull_item(issue: models::Issue) -> Option<Item> {
    let repository = issue.repository.as_ref()?;
    let owner = repository.owner.clone()?;
    let name = repository.name.clone()?;
    let number = issue.number?;
    Some(Item {
        key: format!("{owner}/{name}#{number}"),
        title: format!(
            "{name} #{number} {}",
            issue.title.as_deref().unwrap_or("Untitled")
        ),
        graph_lane: None,
        decoration: ItemDecoration::None,
        meta: format!(
            "{} · {} comments",
            issue.state.as_deref().unwrap_or("unknown"),
            issue.comments.unwrap_or_default()
        ),
        detail: issue.body.unwrap_or_else(|| "No description".into()),
        target: Target::Pull(
            RepositoryId {
                owner,
                repository: name,
            },
            number,
        ),
    })
}

fn milestone_item(repository: &RepositoryId, milestone: models::Milestone) -> Option<Item> {
    let id = milestone.id?;
    let state = milestone.state.unwrap_or_else(|| "unknown".into());
    let open = milestone.open_issues.unwrap_or_default();
    let closed = milestone.closed_issues.unwrap_or_default();
    let total = open.saturating_add(closed);
    let due = milestone
        .due_on
        .as_deref()
        .and_then(|date| date.get(..10))
        .map_or_else(|| "no due date".into(), |date| format!("due {date}"));
    Some(Item {
        key: id.to_string(),
        title: milestone
            .title
            .unwrap_or_else(|| "Untitled milestone".into()),
        graph_lane: None,
        decoration: ItemDecoration::Milestone {
            state: work_item_state(&state),
            closed,
            total,
        },
        meta: format!("{state} · {closed} of {total} closed · {due}"),
        detail: milestone
            .description
            .unwrap_or_else(|| "No description".into()),
        target: Target::Milestone(repository.clone(), id, state),
    })
}

fn sort_milestones(milestones: &mut [models::Milestone]) {
    milestones.sort_by_key(|milestone| {
        (
            milestone.state.as_deref() == Some("closed"),
            milestone
                .title
                .as_deref()
                .unwrap_or_default()
                .to_lowercase(),
        )
    });
}

fn commit_item(repository: &RepositoryId, history: HistoryCommit) -> Option<Item> {
    let HistoryCommit {
        commit,
        node_lane,
        refs,
        ..
    } = history;
    let sha = commit.sha?;
    let details = commit.commit.as_ref();
    let message = details
        .and_then(|details| details.message.as_deref())
        .unwrap_or("Commit");
    Some(Item {
        key: sha.clone(),
        title: message.lines().next().unwrap_or("Commit").into(),
        graph_lane: node_lane,
        decoration: ItemDecoration::None,
        meta: format!(
            "{} · {}{}",
            short_sha(&sha),
            details
                .and_then(|details| details.author.as_ref())
                .and_then(|author| author.name.as_deref())
                .unwrap_or("unknown"),
            if refs.is_empty() {
                String::new()
            } else {
                format!(" · {}", refs.join(", "))
            }
        ),
        detail: message.into(),
        target: Target::Commit(repository.clone(), sha),
    })
}

fn diff_files(diff: &str) -> Vec<Item> {
    diff.lines()
        .filter_map(|line| line.strip_prefix("diff --git a/"))
        .filter_map(|line| line.split_once(" b/"))
        .map(|(path, _)| Item {
            key: path.into(),
            title: path.into(),
            graph_lane: None,
            decoration: ItemDecoration::None,
            meta: "changed file".into(),
            detail: diff_for_file(diff, path),
            target: Target::Text(path.into(), diff_for_file(diff, path)),
        })
        .collect()
}

fn diff_for_file(diff: &str, path: &str) -> String {
    diff.split("diff --git ")
        .find(|section| section.starts_with(&format!("a/{path} ")))
        .map_or_else(String::new, |section| format!("diff --git {section}"))
}

fn repository_key(repository: &RepositoryId) -> String {
    format!("{}/{}", repository.owner, repository.repository)
}

fn empty_as_all(value: &str) -> &str {
    if value.is_empty() { "all" } else { value }
}

fn due_date(value: &str) -> Option<String> {
    let value = value.trim();
    (!value.is_empty()).then(|| format!("{value}T00:00:00Z"))
}

fn state_from_detail(detail: &str) -> &str {
    detail
        .lines()
        .next()
        .and_then(|line| line.strip_prefix("State: "))
        .unwrap_or("open")
}

fn work_item_state(state: &str) -> WorkItemState {
    match state {
        "open" => WorkItemState::Open,
        "closed" => WorkItemState::Closed,
        _ => WorkItemState::Unknown,
    }
}

fn opposite_state(state: &str) -> &'static str {
    if state == "closed" { "open" } else { "closed" }
}

fn short_sha(sha: &str) -> &str {
    sha.get(..8).unwrap_or(sha)
}

fn activity_kind(activity: &models::Activity) -> ActivityKind {
    use gotcha_gitea::activity::Target;
    use models::activity::OpType;

    match gotcha_gitea::activity::target(activity) {
        Some(Target::Issue { .. }) => ActivityKind::Issue,
        Some(Target::Pull { .. }) => ActivityKind::PullRequest,
        Some(Target::Commit { .. }) => ActivityKind::Push,
        Some(Target::Repository { .. }) => ActivityKind::Repository,
        None => match activity.op_type {
            Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => {
                ActivityKind::Branch
            }
            Some(OpType::CommitRepo | OpType::MirrorSyncPush) => ActivityKind::Push,
            Some(OpType::PushTag | OpType::DeleteTag) => ActivityKind::Tag,
            Some(OpType::DeleteBranch) => ActivityKind::Branch,
            Some(OpType::PublishRelease) => ActivityKind::Release,
            Some(_) | None => ActivityKind::Repository,
        },
    }
}

fn activity_title_key(server: &str, activity: &models::Activity) -> Option<ActivityTitleKey> {
    match gotcha_gitea::activity::target(activity)? {
        gotcha_gitea::activity::Target::Issue {
            owner,
            repository,
            number,
        } => Some(ActivityTitleKey::Issue(
            server.into(),
            owner,
            repository,
            number,
        )),
        gotcha_gitea::activity::Target::Pull {
            owner,
            repository,
            number,
        } => Some(ActivityTitleKey::Pull(
            server.into(),
            owner,
            repository,
            number,
        )),
        _ => None,
    }
}

fn activity_title(
    activity: &models::Activity,
    resolved_title: Option<&str>,
    repository: &str,
) -> String {
    let title = resolved_title
        .filter(|title| !title.is_empty())
        .map(str::to_owned)
        .or_else(|| {
            gotcha_gitea::activity::commit_activity(activity).and_then(|payload| {
                payload
                    .commits
                    .last()
                    .map(|commit| commit.message.lines().next().unwrap_or("Commit").to_owned())
            })
        })
        .unwrap_or_else(|| activity_kind(activity).label().into());
    format!("{title} · {repository}")
}

fn activity_detail(activity: &models::Activity) -> String {
    if let Some(body) = activity
        .comment
        .as_ref()
        .and_then(|comment| comment.body.as_deref())
    {
        return body.to_string();
    }
    if let Some(payload) = gotcha_gitea::activity::commit_activity(activity) {
        let noun = if payload.count == 1 {
            "commit"
        } else {
            "commits"
        };
        let mut detail = format!("**{} {noun}**", payload.count);
        for commit in payload.commits {
            let message = commit.message.lines().next().unwrap_or("Commit");
            detail.push_str("\n\n- ");
            if !commit.sha.is_empty() {
                detail.push('`');
                detail.push_str(short_sha(&commit.sha));
                detail.push_str("` ");
            }
            detail.push_str(message);
            let metadata = [commit.author.as_str(), commit.timestamp.as_str()]
                .into_iter()
                .filter(|value| !value.is_empty())
                .collect::<Vec<_>>()
                .join(" · ");
            if !metadata.is_empty() {
                detail.push_str("  \n  ");
                detail.push_str(&metadata);
            }
        }
        return detail;
    }
    activity
        .content
        .clone()
        .filter(|content| !content.is_empty())
        .unwrap_or_else(|| "Server activity".into())
}

fn err(error: gotcha_gitea::Error) -> String {
    error.to_string()
}

fn clock() -> String {
    let seconds = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
        % 86_400;
    format!(
        "{:02}:{:02}:{:02}",
        seconds / 3600,
        seconds / 60 % 60,
        seconds % 60
    )
}

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

    fn config_with_servers() -> Config {
        let mut config = Config::default();
        config.servers = [
            (
                "first.example".into(),
                gotcha_gitea::ServerProfile {
                    url: "https://first.example".into(),
                    token: "first".into(),
                    provider: Provider::Gitea,
                },
            ),
            (
                "second.example".into(),
                gotcha_gitea::ServerProfile {
                    url: "https://second.example".into(),
                    token: "second".into(),
                    provider: Provider::Forgejo,
                },
            ),
        ]
        .into();
        config
    }

    #[test]
    fn changed_files_open_only_their_own_diff() {
        let diff = "diff --git a/one.txt b/one.txt\n--- a/one.txt\n+++ b/one.txt\n+one\ndiff --git a/two.txt b/two.txt\n--- a/two.txt\n+++ b/two.txt\n+two\n";
        let files = diff_files(diff);
        assert_eq!(files.len(), 2);
        assert!(files[0].detail.contains("+one"));
        assert!(!files[0].detail.contains("+two"));
        assert!(
            matches!(&files[1].target, Target::Text(path, text) if path == "two.txt" && text.contains("+two"))
        );
    }

    #[test]
    fn startup_prefers_the_last_server_unless_explicitly_overridden() {
        let mut config = config_with_servers();
        config.tui.last_server = Some("second.example".into());

        assert_eq!(
            initial_selection(&config, None).unwrap().name.as_deref(),
            Some("second.example")
        );
        assert_eq!(
            initial_selection(&config, Some("first.example"))
                .unwrap()
                .name
                .as_deref(),
            Some("first.example")
        );
        config.tui.last_server = Some("removed.example".into());
        assert_eq!(
            initial_selection(&config, None).unwrap().name.as_deref(),
            Some("first.example")
        );
    }

    #[test]
    fn list_clicks_follow_the_rendered_offset_and_rows() {
        let area = ratatui::layout::Rect::new(10, 4, 30, 10);
        assert_eq!(list_item_at(area, 3, 11, 5, 10), Some(3));
        assert_eq!(list_item_at(area, 3, 11, 6, 10), Some(3));
        assert_eq!(list_item_at(area, 3, 11, 7, 10), Some(4));
        assert_eq!(list_item_at(area, 3, 10, 5, 10), None);
        assert_eq!(list_item_at(area, 9, 11, 7, 10), None);
    }

    #[tokio::test]
    async fn auto_reload_waits_for_idle_overview_without_an_editor() {
        let mut app = App::new(Config::default(), None).await.unwrap();
        assert!(app.visible_status().unwrap().starts_with("Updated "));
        app.status_deadline = Some(Instant::now() - Duration::from_secs(1));
        assert_eq!(app.visible_status(), None);
        app.status = "Network unavailable".into();
        app.status_deadline = None;
        assert_eq!(app.visible_status(), Some("Network unavailable"));

        app.last_reload = Instant::now() - Duration::from_secs(6);
        app.last_input = Instant::now() - Duration::from_secs(6);
        assert!(app.should_auto_reload());

        app.editor = Some(Editor::settings(5));
        assert!(!app.should_auto_reload());
        app.editor = None;
        app.last_input = Instant::now();
        assert!(!app.should_auto_reload());
    }

    #[tokio::test]
    async fn q_quits_from_nested_screens_and_confirmations() {
        let mut app = App::new(Config::default(), None).await.unwrap();
        app.history.push(Screen::new(ScreenKind::Home, "Home"));
        app.confirm = Some((
            "Delete server?".into(),
            ConfirmAction::Server("test".into()),
        ));

        app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
            .await;

        assert!(app.should_quit);
        assert_eq!(app.history.len(), 1);
        assert!(app.confirm.is_some());
    }

    #[test]
    fn commit_activity_preview_formats_the_payload() {
        let activity = models::Activity {
            content: Some(
                r#"{"Commits":[{"Sha1":"0123456789abcdef","Message":"Fix preview\n\nDetails","AuthorName":"Octo Cat","Timestamp":"2026-08-04T19:04:30+02:00"}],"Len":1}"#
                    .into(),
            ),
            ..Default::default()
        };

        let detail = activity_detail(&activity);

        assert_eq!(
            detail,
            "**1 commit**\n\n- `01234567` Fix preview  \n  Octo Cat · 2026-08-04T19:04:30+02:00"
        );
        assert!(!detail.contains("\"Commits\""));
    }

    #[test]
    fn issue_activity_uses_the_target_title_instead_of_comment_content() {
        let activity = models::Activity {
            op_type: Some(models::activity::OpType::CommentIssue),
            content: Some("67|Implemented in bac15ab".into()),
            repo: Some(Box::new(models::Repository {
                full_name: Some("hugo/Gotcha".into()),
                ..Default::default()
            })),
            ..Default::default()
        };

        assert_eq!(
            activity_title_key("gitea", &activity),
            Some(ActivityTitleKey::Issue(
                "gitea".into(),
                "hugo".into(),
                "Gotcha".into(),
                67,
            ))
        );
        assert_eq!(
            activity_title(&activity, Some("Proper release workflow"), "hugo/Gotcha"),
            "Proper release workflow · hugo/Gotcha"
        );
    }

    #[test]
    fn milestones_follow_app_order_and_expose_progress() {
        let milestone = |title: &str, state: &str, open, closed| models::Milestone {
            id: Some(if state == "open" { 1 } else { 2 }),
            title: Some(title.into()),
            state: Some(state.into()),
            open_issues: Some(open),
            closed_issues: Some(closed),
            ..Default::default()
        };
        let mut milestones = vec![
            milestone("Archived", "closed", 0, 5),
            milestone("Version 2", "open", 3, 2),
            milestone("Version 1", "open", 1, 0),
        ];

        sort_milestones(&mut milestones);
        assert_eq!(
            milestones
                .iter()
                .filter_map(|milestone| milestone.title.as_deref())
                .collect::<Vec<_>>(),
            ["Version 1", "Version 2", "Archived"]
        );
        let item = milestone_item(
            &RepositoryId {
                owner: "hugo".into(),
                repository: "Gotcha".into(),
            },
            milestones.remove(1),
        )
        .unwrap();
        assert_eq!(
            item.decoration,
            ItemDecoration::Milestone {
                state: WorkItemState::Open,
                closed: 2,
                total: 5,
            }
        );
        assert_eq!(item.meta, "open · 2 of 5 closed · no due date");
    }
}
