use super::*;

impl App {
    pub(super) fn clear_a2ui(&mut self) {
        self.a2ui.clear();
        self.a2ui_history.clear();
        self.a2ui_history_index = None;
        self.a2ui_tabs.clear();
        self.a2ui_modals.clear();
        self.a2ui_editors.clear();
        self.a2ui_markdown.clear();
        self.a2ui_choice_filters.clear();
        self.a2ui_images.clear();
        self.a2ui_image_requests.clear();
        self.a2ui_image_loading = false;
        self.pending_a2ui_dismissal = None;
    }

    pub(super) fn load_next_a2ui_image(&mut self) -> Task<Message> {
        if self.a2ui_image_loading {
            return Task::none();
        }
        let urls = self.displayed_a2ui_store().image_urls().collect::<Vec<_>>();
        let Some(url) = urls
            .into_iter()
            .find(|url| !self.a2ui_image_requests.contains(url))
        else {
            return Task::none();
        };
        self.a2ui_image_requests.insert(url.clone());
        self.a2ui_image_loading = true;
        let request_url = url.clone();
        let session_id = self.selected_session;
        Task::perform(
            async move {
                let mut response = ureq::get(&request_url)
                    .call()
                    .map_err(|error| error.to_string())?;
                if !response
                    .headers()
                    .get("content-type")
                    .and_then(|value| value.to_str().ok())
                    .is_some_and(|value| value.starts_with("image/"))
                {
                    return Err("the URL did not return an image".to_owned());
                }
                response
                    .body_mut()
                    .read_to_vec()
                    .map_err(|error| error.to_string())
            },
            move |result| Message::A2uiImageLoaded(session_id, url, result),
        )
    }

    pub(super) fn displayed_a2ui_store(&self) -> &crate::a2ui::Store {
        self.a2ui_history_index
            .and_then(|index| self.a2ui_history.get(index))
            .unwrap_or(&self.a2ui)
    }

    pub(super) fn has_previous_a2ui_surface(&self) -> bool {
        self.a2ui_history_index
            .map_or(!self.a2ui_history.is_empty(), |index| index > 0)
    }

    pub(super) fn has_next_a2ui_surface(&self) -> bool {
        self.a2ui_history_index.is_some_and(|index| {
            index + 1 < self.a2ui_history.len() || self.a2ui.active_surface().is_some()
        })
    }

    pub(super) fn change_a2ui_data(
        &mut self,
        surface_id: String,
        path: String,
        value: serde_json::Value,
    ) -> Task<Message> {
        if self.a2ui_history_index.is_some() {
            return Task::none();
        }
        let owner = self
            .a2ui
            .surface(&surface_id)
            .map(|surface| surface.owner_message_id);
        match self.a2ui.local_update(&surface_id, &path, value) {
            Ok(raw) => {
                if let (Some(session_id), Some(message_id), Some(database)) =
                    (self.selected_session, owner, &mut self.database)
                    && let Err(error) = database.insert_a2ui_message(session_id, message_id, &raw)
                {
                    self.error = Some(format!("Could not save the A2UI edit: {error}"));
                }
            }
            Err(error) => self.error = Some(error),
        }
        self.sync_a2ui_renderer_state();
        self.load_next_a2ui_image()
    }

    pub(super) fn prepare_project(&mut self, path: PathBuf) {
        let Ok(path) = fs::canonicalize(path) else {
            self.error = Some("The selected folder is no longer available.".into());
            return;
        };
        let Some(path_text) = path.to_str() else {
            self.error = Some("The selected folder path is not valid UTF-8.".into());
            return;
        };
        if self
            .projects
            .iter()
            .any(|item| item.project.path == path_text)
        {
            self.error = Some("That project is already in the sidebar.".into());
            return;
        }

        self.project_name_input = path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("Project")
            .to_owned();
        self.pending_project_path = Some(path);
        self.error = None;
    }

    pub(super) fn create_project(&mut self) {
        let name = self.project_name_input.trim();
        if name.is_empty() {
            self.error = Some("Project name cannot be empty.".into());
            return;
        }
        let Some(path) = &self.pending_project_path else {
            return;
        };
        let Some(path) = path.to_str() else {
            self.error = Some("The selected folder path is not valid UTF-8.".into());
            return;
        };
        let Some(database) = &mut self.database else {
            return;
        };

        match database.create_project(name, path) {
            Ok(project) => {
                self.remember_project(project.id);
                self.selected_session = None;
                self.clear_a2ui();
                self.system_prompt_seen_at = 0;
                self.pending_project_path = None;
                self.project_name_input.clear();
                self.error = None;
                self.reload_projects();
                self.invalidate_dev_brain_context();
                self.refresh_git_state();
            }
            Err(error) => self.error = Some(error),
        }
    }

    /// Opens an unsaved session on a project and selects it. Nothing reaches the
    /// database until the first chat turn is stored by [`App::persist_session`].
    pub(super) fn create_session(&mut self, project_id: i32) {
        if self.database.is_none() {
            return;
        }
        #[cfg(target_os = "macos")]
        self.leave_current_chat();
        let title = draft_title(&self.projects, project_id);
        self.drafts.entry(project_id).or_insert(title);
        self.remember_project(project_id);
        self.selected_session = None;
        self.permission_mode = self.config.default_permission_mode;
        self.conversation.clear();
        self.chat_follow_tail = true;
        self.context_notice = None;
        self.clear_a2ui();
        self.composer = text_editor::Content::new();
        self.queued_inputs.clear();
        self.system_prompt_seen_at = 0;
        self.context_used = 0;
        self.context_limit = self.config.active_generation().context_tokens.max(0) as u32;
        self.tokens_per_second = None;
        self.error = None;
    }

    pub(super) fn discard_session(&mut self, project_id: i32) {
        if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) {
            self.conversation.clear();
            self.chat_follow_tail = true;
            self.context_notice = None;
            self.clear_a2ui();
            self.composer = text_editor::Content::new();
            self.queued_inputs.clear();
            self.system_prompt_seen_at = 0;
            self.context_used = 0;
            self.tokens_per_second = None;
        }
        self.error = None;
    }

    /// Turns the draft on `project_id` into a real session row. Called when the
    /// first chat turn is about to be written, never before.
    pub(super) fn persist_session(&mut self, project_id: i32) -> Result<i32, String> {
        let title = self
            .drafts
            .get(&project_id)
            .cloned()
            .unwrap_or_else(|| draft_title(&self.projects, project_id));
        let database = self
            .database
            .as_mut()
            .ok_or_else(|| "The project database is unavailable.".to_owned())?;
        let session = database.create_session(project_id, &title, self.permission_mode)?;
        self.drafts.remove(&project_id);
        self.remember_project(project_id);
        self.selected_session = Some(session.id);
        self.reload_projects();
        Ok(session.id)
    }

    /// Selects a project and stores it as the one to reopen on the next launch.
    pub(super) fn remember_project(&mut self, project_id: i32) {
        if self.selected_project != Some(project_id) {
            self.git_selected_project = None;
            self.git_selected_files.clear();
            self.git_diff = None;
            self.git_commit_all_confirmation = false;
        }
        self.selected_project = Some(project_id);
        self.refresh_git_state();
        if self.detail_tab == DetailTab::Git {
            self.refresh_git_worktree(project_id);
        }
        if self.config.interface.last_project_id == Some(project_id) {
            return;
        }
        self.config.interface.last_project_id = Some(project_id);
        self.store_config();
    }

    pub(super) fn move_draft_to_project(&mut self, project_id: i32) {
        if self.selected_session.is_some() {
            self.error = Some("Saved chats cannot be moved to another project.".into());
            return;
        }
        if self.selected_project == Some(project_id) {
            return;
        }
        if !self
            .projects
            .iter()
            .any(|item| item.project.id == project_id)
        {
            self.error = Some("The selected project is unavailable.".into());
            return;
        }
        if let Some(previous) = self.selected_project {
            self.drafts.remove(&previous);
        }
        let title = draft_title(&self.projects, project_id);
        self.drafts.insert(project_id, title);
        self.remember_project(project_id);
        self.error = None;
    }

    pub(super) fn refresh_git_state(&mut self) {
        let Some(project_id) = self.selected_project else {
            return;
        };
        let Some(path) = self
            .projects
            .iter()
            .find(|item| item.project.id == project_id)
            .map(|item| item.project.path.clone())
        else {
            self.git_states.remove(&project_id);
            return;
        };
        match read_git_state(Path::new(&path)) {
            Some(state) => {
                self.git_states.insert(project_id, state);
            }
            None => {
                self.git_states.remove(&project_id);
                self.git_worktrees.remove(&project_id);
            }
        }
    }

    pub(super) fn switch_git_branch(&mut self, branch: &str) {
        let Some(project_id) = self.selected_project else {
            return;
        };
        if self
            .git_operation
            .as_ref()
            .is_some_and(|operation| operation.project_id == project_id)
        {
            self.error =
                Some("Wait for the current Git operation before switching branches.".into());
            return;
        }
        if self.project_has_active_chat(project_id) {
            self.error = Some("Stop active chats before switching branches.".into());
            return;
        }
        if !self
            .git_states
            .get(&project_id)
            .is_some_and(|state| state.branches.iter().any(|candidate| candidate == branch))
        {
            self.error = Some("The selected Git branch is unavailable.".into());
            return;
        }
        let Some(path) = self
            .projects
            .iter()
            .find(|item| item.project.id == project_id)
            .map(|item| PathBuf::from(&item.project.path))
        else {
            self.error = Some("The selected project is unavailable.".into());
            return;
        };
        match git_switch(&path, branch) {
            Ok(()) => {
                self.error = None;
                self.refresh_git_state();
                self.refresh_git_worktree(project_id);
            }
            Err(error) => self.error = Some(error),
        }
    }

    /// True when the sidebar row for this project's draft is the active chat.
    pub(super) fn draft_selected(&self, project_id: i32) -> bool {
        self.selected_project == Some(project_id) && self.selected_session.is_none()
    }

    pub(super) fn reload_projects(&mut self) {
        if let Some(database) = &mut self.database {
            match database.load_projects() {
                Ok(projects) => {
                    self.projects = projects;
                    if super::sweep_orphan_checkpoints(&super::kv_cache_path(), &self.projects) {
                        self.finish_cache_change();
                    }
                }
                Err(error) => self.error = Some(error),
            }
        }
    }

    pub(super) fn invalidate_dev_brain_context(&mut self) {
        if !self.config.dev_brain.enabled {
            return;
        }
        #[cfg(target_os = "macos")]
        {
            self.agent_tools = None;
            for chat in self.background_chats.values_mut() {
                chat.agent_tools = None;
                chat.system_prompt_seen_at = 0;
            }
        }
        self.system_prompt_seen_at = 0;
    }
}

fn read_git_state(path: &Path) -> Option<GitState> {
    let repository = git2::Repository::discover(path).ok()?;
    let mut branches = repository
        .branches(Some(git2::BranchType::Local))
        .ok()?
        .map(|branch| {
            branch.and_then(|(branch, _)| {
                branch
                    .name_bytes()
                    .map(|name| String::from_utf8_lossy(name).into_owned())
            })
        })
        .collect::<Result<Vec<_>, _>>()
        .ok()?;
    branches.sort();
    branches.dedup();

    let head = repository.find_reference("HEAD").ok()?;
    let current = head.symbolic_target_bytes().and_then(|target| {
        target
            .strip_prefix(b"refs/heads/")
            .map(|name| String::from_utf8_lossy(name).into_owned())
    });
    let label = current.clone().unwrap_or_else(|| {
        head.target()
            .map(|id| format!("detached @ {}", &id.to_string()[..7]))
            .unwrap_or_else(|| "No branch".to_owned())
    });
    Some(GitState {
        current,
        label,
        branches,
    })
}

fn git_switch(path: &Path, branch: &str) -> Result<(), String> {
    let repository = git2::Repository::discover(path)
        .map_err(|error| format!("Could not switch Git branches: {error}"))?;
    let reference = format!("refs/heads/{branch}");
    let previous = repository.find_reference("HEAD").ok().map(|head| {
        (
            head.symbolic_target().ok().flatten().map(str::to_owned),
            head.target(),
        )
    });
    repository
        .set_head(&reference)
        .map_err(|error| format!("Could not switch Git branches: {error}"))?;
    if let Err(error) = repository.checkout_head(Some(git2::build::CheckoutBuilder::new().safe())) {
        if let Some((Some(reference), _)) = &previous {
            let _ = repository.set_head(reference);
        } else if let Some((_, Some(target))) = previous {
            let _ = repository.set_head_detached(target);
        }
        return Err(format!("Could not switch Git branches: {error}"));
    }
    Ok(())
}

pub(super) fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String {
    let stored = projects
        .iter()
        .find(|item| item.project.id == project_id)
        .map_or(0, |item| item.sessions.len());
    format!("Session {}", stored + 1)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::database::{Project, Session};
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn session_states_are_exclusive_and_ordered() {
        assert_eq!(SessionState::from_id("pinned"), Some(SessionState::Pinned));
        assert_eq!(SessionState::from_id("nonsense"), None);
        assert_eq!(SessionState::default(), SessionState::Normal);
        let mut ranks = [
            SessionState::Archived,
            SessionState::Normal,
            SessionState::Pinned,
        ];
        ranks.sort_by_key(|state| state.rank());
        assert_eq!(
            ranks,
            [
                SessionState::Pinned,
                SessionState::Normal,
                SessionState::Archived
            ]
        );
    }

    #[test]
    fn draft_titles_follow_the_stored_session_count() {
        let projects = vec![
            ProjectWithSessions {
                project: project(1, "First"),
                sessions: vec![session(1, 1), session(2, 1)],
            },
            ProjectWithSessions {
                project: project(2, "Second"),
                sessions: Vec::new(),
            },
        ];
        assert_eq!(draft_title(&projects, 1), "Session 3");
        assert_eq!(draft_title(&projects, 2), "Session 1");
        assert_eq!(draft_title(&projects, 99), "Session 1");
    }

    #[test]
    fn sweeping_removes_checkpoints_of_sessions_that_no_longer_exist() {
        let directory =
            std::env::temp_dir().join(format!("ds4-server-sweep-{}", std::process::id()));
        std::fs::create_dir_all(&directory).unwrap();
        for name in [
            "1.bin",
            "2.bin",
            "notes.bin",
            "7.bin",
            "1.compacting",
            "2.tmp",
        ] {
            std::fs::write(directory.join(name), b"payload").unwrap();
        }
        let projects = vec![ProjectWithSessions {
            project: project(1, "First"),
            sessions: vec![session(1, 1), session(2, 1)],
        }];

        assert!(super::super::sweep_orphan_checkpoints(
            &directory, &projects
        ));

        assert!(directory.join("1.bin").exists());
        assert!(directory.join("2.bin").exists());
        // Not a session checkpoint, so not ours to delete.
        assert!(directory.join("notes.bin").exists());
        assert!(!directory.join("7.bin").exists());
        assert!(!directory.join("1.compacting").exists());
        assert!(!directory.join("2.tmp").exists());
        // Nothing left to sweep on the next pass.
        assert!(!super::super::sweep_orphan_checkpoints(
            &directory, &projects
        ));

        std::fs::remove_dir_all(directory).unwrap();
    }

    #[test]
    fn discarding_a_session_removes_every_checkpoint_stage() {
        let directory =
            std::env::temp_dir().join(format!("ds4-server-discard-{}", std::process::id()));
        std::fs::create_dir_all(&directory).unwrap();
        for name in ["4.bin", "4.tmp", "4.compacting", "5.bin"] {
            std::fs::write(directory.join(name), b"payload").unwrap();
        }

        assert!(super::super::discard_session_checkpoint_files(&directory, 4).unwrap());
        assert!(!directory.join("4.bin").exists());
        assert!(!directory.join("4.tmp").exists());
        assert!(!directory.join("4.compacting").exists());
        assert!(directory.join("5.bin").exists());
        assert!(!super::super::discard_session_checkpoint_files(&directory, 4).unwrap());

        std::fs::remove_dir_all(directory).unwrap();
    }

    #[test]
    fn git_state_lists_and_switches_local_branches() {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let directory = std::env::temp_dir().join(format!(
            "ds4-server-git-state-{}-{nonce}",
            std::process::id()
        ));
        std::fs::create_dir(&directory).unwrap();
        let mut options = git2::RepositoryInitOptions::new();
        options.initial_head("main");
        let repository = git2::Repository::init_opts(&directory, &options).unwrap();
        let mut config = repository.config().unwrap();
        config.set_str("user.name", "DS4Server Test").unwrap();
        config
            .set_str("user.email", "test@ds4server.invalid")
            .unwrap();
        drop(config);
        std::fs::write(directory.join("tracked.txt"), "test").unwrap();
        let mut index = repository.index().unwrap();
        index.add_path(Path::new("tracked.txt")).unwrap();
        let tree_id = index.write_tree().unwrap();
        index.write().unwrap();
        let tree = repository.find_tree(tree_id).unwrap();
        let signature = repository.signature().unwrap();
        let commit = repository
            .commit(Some("HEAD"), &signature, &signature, "Initial", &tree, &[])
            .unwrap();
        let commit = repository.find_commit(commit).unwrap();
        repository.branch("feature", &commit, false).unwrap();
        drop(commit);
        drop(tree);
        drop(repository);

        let state = read_git_state(&directory).unwrap();
        assert_eq!(state.current.as_deref(), Some("main"));
        assert_eq!(state.branches, ["feature", "main"]);

        git_switch(&directory, "feature").unwrap();
        let state = read_git_state(&directory).unwrap();
        assert_eq!(state.current.as_deref(), Some("feature"));

        std::fs::remove_dir_all(directory).unwrap();
    }

    fn project(id: i32, name: &str) -> Project {
        Project {
            id,
            name: name.into(),
            path: format!("/tmp/{name}"),
            collapsed: false,
        }
    }

    fn session(id: i32, project_id: i32) -> Session {
        Session::fixture(
            id,
            project_id,
            &format!("Session {id}"),
            SessionState::Normal,
        )
    }
}
