//! Editoraktionen auf gemeinsamen Dokumenten und revisionsgebundene Übersetzung.
use crate::{
    app::{App, Dialog, DialogKind, Field, WindowKind},
    commands::Command,
    documents::{DocumentId, ViewId},
    export::ProjectStamp,
};
use anyhow::{anyhow, ensure, Result};
use crossterm::event::{KeyCode as K, KeyEvent, KeyModifiers as M};
use std::{collections::BTreeMap, ops::Range, path::PathBuf};
use tb_frontend::{
    lexer::{self, TokenKind},
    source::SourceUnit,
    Diagnostic,
};
use tb_vm::{bytecode::CompiledModule, project::ProjectCompiler};
use unicode_width::UnicodeWidthChar;

#[derive(Default, Clone)]
pub struct Search {
    pub text: String,
    pub replacement: String,
    pub backwards: bool,
    pub case_sensitive: bool,
    pub whole_word: bool,
    pub scope: Option<Range<usize>>,
    pub document: Option<DocumentId>,
    pub wrap: bool,
}
pub(crate) type Revision = (ProjectStamp, Vec<SourceUnit>, Vec<String>);

#[derive(Default)]
pub struct Editor {
    pub clipboard: String,
    pub chord: Option<char>,
    pub search: Search,
    pub history: Vec<(DocumentId, usize)>,
    pub expansions: BTreeMap<ViewId, String>,
    pub checked: BTreeMap<DocumentId, u64>,
    pub compiler: ProjectCompiler,
    pub diagnostics: Vec<Diagnostic>,
    pub(crate) revision: Option<Revision>,
    compiled: Option<CompiledModule>,
}

pub fn line_start(code: &str, at: usize) -> usize {
    code[..at].rfind('\n').map_or(0, |p| p + 1)
}
pub fn line_end(code: &str, at: usize) -> usize {
    code[at..].find('\n').map_or(code.len(), |p| at + p)
}
pub fn column(code: &str, tab: usize) -> usize {
    code.chars().fold(0, |n, c| {
        n + if c == '\t' {
            tab - n % tab
        } else {
            c.width().unwrap_or(0)
        }
    })
}
pub fn offset(code: &str, col: usize, tab: usize) -> usize {
    let mut width = 0;
    for (i, c) in code.char_indices() {
        let w = if c == '\t' {
            tab - width % tab
        } else {
            c.width().unwrap_or(0)
        };
        if width + w > col {
            return i;
        }
        width += w;
    }
    code.len()
}
fn word(c: char) -> bool {
    c.is_alphanumeric() || c == '_' || "%&!#$@".contains(c)
}
fn previous(code: &str, at: usize) -> usize {
    if code[..at].ends_with("\r\n") {
        return at - 2;
    }
    code[..at].char_indices().last().map_or(0, |(i, _)| i)
}
fn next(code: &str, at: usize) -> usize {
    if code[at..].starts_with("\r\n") {
        return at + 2;
    }
    at + code[at..].chars().next().map_or(0, char::len_utf8)
}
fn matches(code: &str, search: &Search, scope: Range<usize>) -> Vec<Range<usize>> {
    if search.text.is_empty() {
        return vec![];
    }
    let mut result = Vec::new();
    let mut end_previous = scope.start;
    for (i, _) in code[scope.clone()].char_indices() {
        let start = scope.start + i;
        if start < end_previous {
            continue;
        }
        let end = code[start..scope.end]
            .char_indices()
            .nth(search.text.chars().count())
            .map_or(scope.end, |(i, _)| start + i);
        let value = &code[start..end];
        let equal = if search.case_sensitive {
            value == search.text
        } else {
            value.to_lowercase() == search.text.to_lowercase()
        };
        if equal
            && (!search.whole_word
                || (!code[..start].chars().next_back().is_some_and(word)
                    && !code[end..].chars().next().is_some_and(word)))
        {
            result.push(start..end);
            end_previous = end;
        }
    }
    result
}
impl App {
    pub(crate) fn editor_view(&self) -> Result<ViewId> {
        match self.active_window().map(|w| w.kind) {
            Some(WindowKind::Code(v)) => Ok(v),
            _ => Err(anyhow!("Kein Codefenster aktiv")),
        }
    }
    pub(crate) fn editor_insert(&mut self, text: &str) -> Result<()> {
        let v = self.editor_view()?;
        ensure!(
            !self.editor.expansions.contains_key(&v),
            "Included Lines ist schreibgeschützt; Included File öffnet die Originaldatei"
        );
        let view = self.project.view(v)?;
        let id = view.document();
        let range = view.selection().unwrap_or(view.cursor..view.cursor);
        let cursor = range.start + text.len();
        self.project.replace_text(id, range, text)?;
        let view = self.project.view_mut(v)?;
        view.cursor = cursor;
        view.anchor = None;
        self.editor_scroll()?;
        Ok(())
    }
    pub(crate) fn editor_scroll(&mut self) -> Result<()> {
        let v = self.editor_view()?;
        let rect = self.rect(self.active_window().unwrap());
        let view = self.project.view(v)?;
        let code = self.project.document(view.document())?.code();
        let row = code[..view.cursor].bytes().filter(|b| *b == b'\n').count();
        let col = column(
            &code[line_start(code, view.cursor)..view.cursor],
            self.options.tab_width,
        );
        let view = self.project.view_mut(v)?;
        let height = rect.height.saturating_sub(2).max(1) as usize;
        let width = rect.width.saturating_sub(2).max(1) as usize;
        if row < view.scroll_line {
            view.scroll_line = row;
        } else if row >= view.scroll_line + height {
            view.scroll_line = row + 1 - height;
        }
        if col < view.scroll_column {
            view.scroll_column = col;
        } else if col >= view.scroll_column + width {
            view.scroll_column = col + 1 - width;
        }
        Ok(())
    }
    pub(crate) fn editor_key(&mut self, key: KeyEvent) -> Result<()> {
        let v = self.editor_view()?;
        if let Some(expansion) = self.editor.expansions.get(&v) {
            let count = expansion.lines().count();
            let step = self
                .rect(self.active_window().unwrap())
                .height
                .saturating_sub(3)
                .max(1) as usize;
            let view = self.project.view_mut(v)?;
            match key.code {
                K::Up => view.scroll_line = view.scroll_line.saturating_sub(1),
                K::Down => view.scroll_line = (view.scroll_line + 1).min(count.saturating_sub(1)),
                K::PageUp => view.scroll_line = view.scroll_line.saturating_sub(step),
                K::PageDown => {
                    view.scroll_line = (view.scroll_line + step).min(count.saturating_sub(1))
                }
                K::Home => view.scroll_line = 0,
                K::End => view.scroll_line = count.saturating_sub(step),
                _ => {}
            }
            return Ok(());
        }
        let view = self.project.view(v)?.clone();
        let id = view.document();
        let code = self.project.document(id)?.code().to_owned();
        let at = view.cursor;
        let ctrl = key.modifiers.contains(M::CONTROL);
        let shift = key.modifiers.contains(M::SHIFT);
        let mut destination;
        if let Some(chord) = self.editor.chord.take() {
            if let K::Char(c) = key.code {
                match (chord, c.to_ascii_lowercase()) {
                    ('q', 's') => destination = line_start(&code, at),
                    ('q', 'd') => destination = line_end(&code, at),
                    ('q', 'r') => destination = 0,
                    ('q', 'c') => destination = code.len(),
                    ('k', c @ '0'..='3') => {
                        self.project.view_mut(v)?.bookmarks[c as usize - '0' as usize] = Some(at);
                        return Ok(());
                    }
                    ('q', c @ '0'..='3') => {
                        destination = view.bookmarks[c as usize - '0' as usize].unwrap_or(at)
                    }
                    _ => return Ok(()),
                }
            } else {
                return Ok(());
            }
        } else {
            match key.code {
                K::Char(c @ ('q' | 'k')) if ctrl => {
                    self.editor.chord = Some(c);
                    self.message = format!("Ctrl+{} … (Esc bricht ab)", c.to_ascii_uppercase());
                    return Ok(());
                }
                K::Char('a') if ctrl => {
                    let v = self.project.view_mut(v)?;
                    v.anchor = Some(0);
                    v.cursor = code.len();
                    return self.editor_scroll();
                }
                K::Char('y') if ctrl => {
                    let range = line_start(&code, at)..(line_end(&code, at) + 1).min(code.len());
                    self.project.replace_text(id, range.clone(), "")?;
                    let v = self.project.view_mut(v)?;
                    v.cursor = range.start;
                    v.anchor = None;
                    return self.editor_scroll();
                }
                K::Insert => {
                    self.project.view_mut(v)?.overwrite = !view.overwrite;
                    self.message = if view.overwrite {
                        "Einfügen"
                    } else {
                        "Überschreiben"
                    }
                    .into();
                    return Ok(());
                }
                K::Char(c) if !key.modifiers.intersects(M::ALT | M::CONTROL) => {
                    if view.overwrite
                        && view.selection().is_none()
                        && at < line_end(&code, at)
                        && !code[at..].starts_with("\r\n")
                    {
                        self.project
                            .replace_text(id, at..next(&code, at), &c.to_string())?;
                        self.project.view_mut(v)?.cursor = at + c.len_utf8();
                        return self.editor_scroll();
                    }
                    return self.editor_insert(&c.to_string());
                }
                K::Enter => {
                    return self.editor_insert(if code.contains("\r\n") { "\r\n" } else { "\n" })
                }
                K::Tab => return self.editor_insert("\t"),
                K::Backspace | K::Delete => {
                    let range = view.selection().unwrap_or_else(|| {
                        if key.code == K::Backspace {
                            let p = previous(&code, at);
                            if code.get(p..at) == Some("\n")
                                && p > 0
                                && code.as_bytes()[p - 1] == b'\r'
                            {
                                p - 1..at
                            } else {
                                p..at
                            }
                        } else {
                            at..if code[at..].starts_with("\r\n") {
                                at + 2
                            } else {
                                next(&code, at)
                            }
                        }
                    });
                    self.project.replace_text(id, range.clone(), "")?;
                    let view = self.project.view_mut(v)?;
                    view.cursor = range.start;
                    view.anchor = None;
                    return self.editor_scroll();
                }
                K::Left | K::Right => {
                    destination = if key.code == K::Left {
                        previous(&code, at)
                    } else {
                        next(&code, at)
                    };
                    if ctrl {
                        if key.code == K::Left {
                            while destination > 0
                                && !code[destination..].chars().next().is_some_and(word)
                            {
                                destination = previous(&code, destination);
                            }
                            while destination > 0
                                && code[..destination].chars().next_back().is_some_and(word)
                            {
                                destination = previous(&code, destination);
                            }
                        } else {
                            destination = at;
                            while destination < code.len()
                                && code[destination..].chars().next().is_some_and(word)
                            {
                                destination = next(&code, destination);
                            }
                            while destination < code.len()
                                && !code[destination..].chars().next().is_some_and(word)
                            {
                                destination = next(&code, destination);
                            }
                        }
                    }
                }
                K::Home => destination = if ctrl { 0 } else { line_start(&code, at) },
                K::End => {
                    destination = if ctrl {
                        code.len()
                    } else {
                        line_end(&code, at)
                    };
                    if code[..destination].ends_with('\r') {
                        destination -= 1;
                    }
                }
                K::Up | K::Down | K::PageUp | K::PageDown => {
                    let starts: Vec<_> = std::iter::once(0)
                        .chain(code.match_indices('\n').map(|(i, _)| i + 1))
                        .collect();
                    let row = starts.partition_point(|i| *i <= at) - 1;
                    let col = column(&code[starts[row]..at], self.options.tab_width);
                    let step = if matches!(key.code, K::PageUp | K::PageDown) {
                        self.rect(self.active_window().unwrap())
                            .height
                            .saturating_sub(3)
                            .max(1) as usize
                    } else {
                        1
                    };
                    let row = if matches!(key.code, K::Up | K::PageUp) {
                        row.saturating_sub(step)
                    } else {
                        (row + step).min(starts.len() - 1)
                    };
                    let start = starts[row];
                    let end = line_end(&code, start);
                    destination = start
                        + offset(
                            code[start..end].trim_end_matches('\r'),
                            col,
                            self.options.tab_width,
                        );
                }
                K::Esc => {
                    self.project.view_mut(v)?.anchor = None;
                    return Ok(());
                }
                _ => return Ok(()),
            }
        }
        if !shift && !ctrl {
            if let Some(range) = view.selection() {
                if key.code == K::Left {
                    destination = range.start;
                }
                if key.code == K::Right {
                    destination = range.end;
                }
            }
        }
        let view = self.project.view_mut(v)?;
        if shift {
            view.anchor.get_or_insert(at);
        } else {
            view.anchor = None;
        }
        view.cursor = destination;
        self.editor_scroll()
    }
    pub(crate) fn editor_command(&mut self, command: Command) -> Result<()> {
        use Command::*;
        let v = self.editor_view()?;
        let view = self.project.view(v)?.clone();
        let id = view.document();
        let code = self.project.document(id)?.code().to_owned();
        match command {
            Copy | Cut => {
                if let Some(range) = view.selection() {
                    self.editor.clipboard = code[range].into();
                    if command == Cut {
                        self.editor_insert("")?;
                    }
                }
            }
            Paste => self.editor_insert(&self.editor.clipboard.clone())?,
            Clear => self.editor_key(KeyEvent::new(K::Delete, M::NONE))?,
            Find | Replace => {
                let s = &self.editor.search;
                self.open_dialog(
                    if command == Find { "Find" } else { "Change" },
                    DialogKind::Search {
                        view: v,
                        selection: view.selection(),
                    },
                    vec![
                        Field::text("Suchtext", s.text.clone()),
                        Field::text("Ersetzung", s.replacement.clone()),
                        Field::toggle("Rückwärts", s.backwards),
                        Field::toggle("Groß-/Kleinschreibung", s.case_sensitive),
                        Field::toggle("Ganzes Wort", s.whole_word),
                        Field::toggle("Nur Auswahl", false),
                        Field::choice(
                            "Aktion",
                            vec![
                                "Suchen".into(),
                                "Einzeln ersetzen".into(),
                                "Alle ersetzen".into(),
                            ],
                            if command == Find { 0 } else { 1 },
                        ),
                    ],
                );
            }
            SelectedText => {
                let range = view
                    .selection()
                    .ok_or_else(|| anyhow!("Kein Text ausgewählt"))?;
                self.editor.search.text = code[range].into();
                self.editor.search.document = Some(id);
                self.editor.search.scope = None;
                self.editor.search.wrap = false;
                self.find_next()?;
            }
            FindNext => self.find_next()?,
            NewSub | NewFunction => self.open_dialog(
                if command == NewSub {
                    "New Sub"
                } else {
                    "New Function"
                },
                DialogKind::Procedure(command == NewFunction),
                vec![Field::text("Name", "")],
            ),
            Procedures => {
                let parsed = tb_frontend::parser::parse("EDITOR", &lexer::lex(&code).tokens);
                let mut positions = vec![0];
                let mut names = vec!["Modulrumpf".into()];
                for p in parsed.module.procs {
                    names.push(tb_frontend::editing::signature(&p.sig));
                    positions.push(line_offset(&code, p.pos.line));
                }
                self.open_dialog(
                    "Prozeduren",
                    DialogKind::Procedures(positions),
                    vec![Field::choice("Prozedur", names, 0)],
                );
            }
            PreviousCode => {
                if let Some((id, at)) = self.editor.history.pop() {
                    self.show_document(id, false)?;
                    let v = self.editor_view()?;
                    let code = self.project.document(id)?.code();
                    let mut at = at.min(code.len());
                    while !code.is_char_boundary(at) {
                        at -= 1;
                    }
                    self.project.view_mut(v)?.cursor = at;
                    self.editor_scroll()?;
                }
            }
            IncludedFile => {
                let line = &code[line_start(&code, view.cursor)..line_end(&code, view.cursor)];
                let path = lexer::lex(line)
                    .tokens
                    .into_iter()
                    .find_map(|t| {
                        if let TokenKind::MetaInclude(p) = t.kind {
                            Some(p)
                        } else {
                            None
                        }
                    })
                    .ok_or_else(|| anyhow!("Keine Include-Anweisung auf dieser Zeile"))?;
                let doc = self.project.document(id)?;
                let path = self
                    .project
                    .loader()?
                    .resolve(doc.source_path().parent().unwrap(), &path)
                    .map_err(|e| anyhow!(e))?;
                self.editor.history.push((id, view.cursor));
                let id = self.project.open_document(&path)?;
                self.show_document(id, false)?;
            }
            IncludedLines => {
                if self.editor.expansions.remove(&v).is_none() {
                    let sources = self.project.sources()?;
                    let path = self
                        .project
                        .document(id)?
                        .source_path()
                        .display()
                        .to_string();
                    let unit = sources
                        .units
                        .iter()
                        .find(|u| u.segments.iter().any(|s| s.file == path))
                        .ok_or_else(|| anyhow!("Dokument gehört zu keiner Übersetzungseinheit"))?;
                    let mut text = String::from("Included Lines · SCHREIBGESCHÜTZT\n");
                    for s in &unit.segments {
                        text.push_str(&format!("' {}:{}\n{}\n", s.file, s.first_line, s.text));
                    }
                    self.editor.expansions.insert(v, text);
                    self.project.view_mut(v)?.scroll_line = 0;
                    self.project.view_mut(v)?.scroll_column = 0;
                } else {
                    self.editor_scroll()?;
                }
            }
            Diagnostics => self.diagnostic_dialog(),
            _ => unreachable!(),
        }
        Ok(())
    }
    pub(crate) fn editor_submit(&mut self, d: &mut Dialog) -> Result<bool> {
        match d.kind.clone() {
            DialogKind::Search { view, selection } => {
                let id = self.project.view(view)?.document();
                ensure!(!d.fields[0].string().is_empty(), "Suchtext fehlt");
                ensure!(
                    !d.fields[5].flag() || selection.is_some(),
                    "Keine Auswahl vorhanden"
                );
                self.editor.search = Search {
                    text: d.fields[0].string(),
                    replacement: d.fields[1].string(),
                    backwards: d.fields[2].flag(),
                    case_sensitive: d.fields[3].flag(),
                    whole_word: d.fields[4].flag(),
                    scope: if d.fields[5].flag() { selection } else { None },
                    document: Some(id),
                    wrap: false,
                };
                match d.fields[6].index() {
                    0 => self.find_next()?,
                    1 => self.replace_matches(false)?,
                    _ => self.replace_matches(true)?,
                }
            }
            DialogKind::Procedure(function) => {
                let v = self.editor_view()?;
                let view = self.project.view(v)?.clone();
                let id = view.document();
                let addition = self.new_procedure_text(
                    id,
                    view.cursor,
                    &d.fields[0].string(),
                    function,
                    false,
                )?;
                let code = self.project.document(id)?.code();
                let end = code.len();
                self.project.replace_text(id, end..end, &addition)?;
                self.project.view_mut(v)?.cursor = end + 1;
                self.editor_scroll()?;
            }
            DialogKind::Procedures(positions) => {
                let v = self.editor_view()?;
                let view = self.project.view(v)?;
                self.editor.history.push((view.document(), view.cursor));
                self.project.view_mut(v)?.cursor = positions[d.fields[0].index()];
                self.editor_scroll()?;
            }
            DialogKind::Diagnostics => {
                ensure!(
                    self.revision_current(),
                    "Quellen geändert; Diagnosen erneut öffnen"
                );
                let diagnostic = self
                    .editor
                    .diagnostics
                    .get(d.fields[0].index())
                    .cloned()
                    .ok_or_else(|| anyhow!("Keine Diagnose"))?;
                self.goto_diagnostic(&diagnostic)?;
            }
            _ => unreachable!(),
        }
        Ok(false)
    }
    fn find_next(&mut self) -> Result<()> {
        let v = self.editor_view()?;
        let view = self.project.view(v)?.clone();
        let id = view.document();
        let code = self.project.document(id)?.code();
        let s = &mut self.editor.search;
        ensure!(!s.text.is_empty(), "Noch keine Suche; Find verwenden");
        if s.document != Some(id) {
            s.document = Some(id);
            s.scope = None;
            s.wrap = false;
        }
        let scope = s
            .scope
            .clone()
            .filter(|r| code.get(r.clone()).is_some())
            .unwrap_or(0..code.len());
        let ranges = matches(code, s, scope);
        let found = if s.backwards {
            let at = view.selection().map_or(view.cursor, |r| r.start);
            ranges.iter().rev().find(|r| s.wrap || r.end <= at)
        } else {
            let at = view.selection().map_or(view.cursor, |r| r.end);
            ranges.iter().find(|r| s.wrap || r.start >= at)
        };
        if let Some(r) = found {
            let view = self.project.view_mut(v)?;
            view.anchor = Some(r.start);
            view.cursor = r.end;
            s.wrap = false;
            self.message = "Treffer".into();
            self.editor_scroll()?;
        } else {
            s.wrap = true;
            self.message = "Suchbereich beendet · F3 bietet Rücksprung zum anderen Ende".into();
        }
        Ok(())
    }
    fn replace_matches(&mut self, all: bool) -> Result<()> {
        let v = self.editor_view()?;
        let view = self.project.view(v)?.clone();
        let id = view.document();
        ensure!(
            !self.editor.expansions.contains_key(&v),
            "Included Lines ist schreibgeschützt"
        );
        let code = self.project.document(id)?.code().to_owned();
        let s = &self.editor.search;
        let scope = s.scope.clone().unwrap_or(0..code.len());
        ensure!(
            code.get(scope.clone()).is_some(),
            "Suchbereich nicht mehr gültig"
        );
        let ranges = matches(&code, s, scope.clone());
        let ranges = if all {
            ranges
        } else {
            let found = if s.backwards {
                ranges.into_iter().rev().find(|r| r.start <= view.cursor)
            } else {
                ranges
                    .into_iter()
                    .find(|r| r.end > view.cursor || view.selection().as_ref() == Some(r))
            };
            found.into_iter().collect()
        };
        if ranges.is_empty() {
            self.message = "Keine Treffer".into();
            return Ok(());
        }
        let mut replacement = code[scope.clone()].to_owned();
        for r in ranges.iter().rev() {
            replacement.replace_range(r.start - scope.start..r.end - scope.start, &s.replacement);
        }
        let first = &ranges[0];
        let cursor = first.start + s.replacement.len();
        self.project.replace_text(id, scope.clone(), &replacement)?;
        if self.editor.search.scope.is_some() {
            self.editor.search.scope = Some(scope.start..scope.start + replacement.len());
        }
        let view = self.project.view_mut(v)?;
        view.cursor = cursor;
        view.anchor = None;
        self.message = format!("{} Treffer ersetzt", ranges.len());
        self.editor_scroll()
    }
    pub(crate) fn new_procedure_text(
        &self,
        id: DocumentId,
        cursor: usize,
        name: &str,
        function: bool,
        module_local: bool,
    ) -> Result<String> {
        let code = self.project.document(id)?.code();
        let input = self.project.sources()?;
        for unit in &input.units {
            if module_local
                && !unit.segments.iter().any(|s| {
                    s.file
                        == self
                            .project
                            .document(id)
                            .unwrap()
                            .source_path()
                            .display()
                            .to_string()
                })
            {
                continue;
            }
            let (module, _) = unit.parse(0, &mut Vec::new());
            ensure!(
                !module
                    .procs
                    .iter()
                    .any(|p| p.sig.name.eq_ignore_ascii_case(name)),
                "Prozedurname bereits im Projekt vorhanden"
            );
        }
        let path = self
            .project
            .document(id)?
            .source_path()
            .display()
            .to_string();
        let unit = input
            .units
            .iter()
            .find(|u| u.segments.iter().any(|s| s.file == path))
            .ok_or_else(|| anyhow!("Keine Übersetzungseinheit"))?;
        let first = match self.project.document(id)?.content() {
            tb_vm::project_io::Content::Form(f) => {
                tb_ui::frm::read_text(&path, &tb_ui::frm::write_text(f))?.code_line()
            }
            _ => 1,
        };
        let line = first + code[..cursor].bytes().filter(|b| *b == b'\n').count() as u32;
        let col = cursor - line_start(code, cursor);
        let mut expanded = String::new();
        let mut at = None;
        for segment in &unit.segments {
            for (i, text) in segment.text.split_inclusive('\n').enumerate() {
                if segment.file == path && segment.first_line + i as u32 == line {
                    at = Some(expanded.len() + col.min(text.trim_end_matches(['\n', '\r']).len()));
                }
                expanded.push_str(text);
                if !text.ends_with('\n') {
                    expanded.push('\n');
                }
            }
        }
        tb_frontend::editing::new_procedure(&expanded, at.unwrap_or(expanded.len()), name, function)
            .map_err(|e| anyhow!(e))
    }
    pub(crate) fn diagnostic_dialog(&mut self) {
        if !self.revision_current() {
            if let Err(e) = self.compile_current() {
                self.message = e.to_string();
            }
        }
        if self.editor.diagnostics.is_empty() {
            self.message = "Keine Diagnosen".into();
            return;
        }
        self.open_dialog(
            "Diagnosen",
            DialogKind::Diagnostics,
            vec![Field::choice(
                "Datei:Zeile:Spalte",
                self.editor
                    .diagnostics
                    .iter()
                    .map(ToString::to_string)
                    .collect(),
                0,
            )],
        );
    }
    pub fn goto_diagnostic(&mut self, d: &Diagnostic) -> Result<()> {
        let path = PathBuf::from(
            d.file
                .as_ref()
                .ok_or_else(|| anyhow!("Diagnose ohne Quelldatei"))?,
        );
        let id = self.project.open_document(&path)?;
        self.show_document(id, false)?;
        let v = self.editor_view()?;
        let doc = self.project.document(id)?;
        let first = if let tb_vm::project_io::Content::Form(f) = doc.content() {
            tb_ui::frm::read_text(&path.display().to_string(), &tb_ui::frm::write_text(f))?
                .code_line()
                .saturating_sub(1)
        } else {
            0
        };
        let code = doc.code();
        let start = line_offset(code, d.pos.line.saturating_sub(first));
        let at = start
            + code[start..line_end(code, start)]
                .char_indices()
                .nth(d.pos.column.saturating_sub(1) as usize)
                .map_or(line_end(code, start) - start, |(i, _)| i);
        self.editor.expansions.remove(&v);
        self.project.view_mut(v)?.cursor = at;
        self.editor_scroll()
    }
    /// Immer aktuelle Quellen einschließlich externer Includes prüfen; alte Erfolge werden nie herausgegeben.
    pub fn compile_current(&mut self) -> Result<&CompiledModule> {
        self.editor.compiled = None;
        self.editor.revision = None;
        self.editor.diagnostics.clear();
        let sources = self.project.sources()?;
        self.editor.revision = Some((
            ProjectStamp::capture(&self.project),
            sources.units.clone(),
            sources.forms.iter().map(tb_ui::frm::write_text).collect(),
        ));
        match sources.compile(&mut self.editor.compiler, "IDE") {
            Ok(code) => {
                self.editor.compiled = Some(code);
                Ok(self.editor.compiled.as_ref().unwrap())
            }
            Err(errors) => {
                let text = errors
                    .iter()
                    .map(ToString::to_string)
                    .collect::<Vec<_>>()
                    .join("\n");
                self.editor.diagnostics = errors;
                Err(anyhow!(text))
            }
        }
    }
    fn revision_current(&self) -> bool {
        let Some((stamp, units, forms)) = &self.editor.revision else {
            return false;
        };
        if stamp != &ProjectStamp::capture(&self.project) {
            return false;
        }
        let Ok(current) = self.project.sources() else {
            return false;
        };
        units == &current.units
            && forms
                == &current
                    .forms
                    .iter()
                    .map(tb_ui::frm::write_text)
                    .collect::<Vec<_>>()
    }
    pub fn current_compilation(&self) -> Option<&CompiledModule> {
        self.revision_current()
            .then_some(self.editor.compiled.as_ref())
            .flatten()
    }
    pub fn current_diagnostics(&self) -> Option<&[Diagnostic]> {
        self.revision_current()
            .then_some(self.editor.diagnostics.as_slice())
    }
    pub(crate) fn editor_position(&self) -> Option<(ViewId, DocumentId, usize)> {
        if self.mode == crate::app::Mode::Designer {
            return None;
        }
        let v = self.editor_view().ok()?;
        let view = self.project.view(v).ok()?;
        let code = self.project.document(view.document()).ok()?.code();
        Some((v, view.document(), line_start(code, view.cursor)))
    }
    pub(crate) fn line_leave(&mut self, old: Option<(ViewId, DocumentId, usize)>) {
        let Some((v, id, start)) = old else {
            return;
        };
        if !self.options.syntax_checking
            || self.editor.expansions.contains_key(&v)
            || old == self.editor_position()
        {
            return;
        }
        let Ok(doc) = self.project.document(id) else {
            return;
        };
        if doc.revision() == 0 || self.editor.checked.get(&id) == Some(&doc.revision()) {
            return;
        }
        let code = doc.code().to_owned();
        let revision = doc.revision();
        let lexed = lexer::lex(&code);
        let parsed = tb_frontend::parser::parse("EDITOR", &lexed.tokens);
        if lexed.diagnostics.is_empty()
            && parsed.diagnostics.is_empty()
            && code.is_char_boundary(start)
            && start <= code.len()
        {
            let end = line_end(&code, start);
            let edits = tb_frontend::editing::normalization_edits(&code[start..end])
                .into_iter()
                .map(|(r, t)| (start + r.start..start + r.end, t))
                .collect::<Vec<_>>();
            if let Err(e) = self.project.replace_ranges(id, &edits) {
                self.message = e.to_string();
                return;
            }
        }

        self.editor.checked.insert(
            id,
            self.project.document(id).map_or(revision, |d| d.revision()),
        );
        let result = self.compile_current().map(|_| ());
        if parsed.incomplete && lexed.diagnostics.is_empty() {
            self.message = "Quelltext noch unvollständig · Eingabe fortsetzen".into();
        } else if let Err(e) = result {
            self.message = e.to_string();
            if self.dialog.is_none() {
                self.diagnostic_dialog();
            }
        } else {
            self.message = format!(
                "Geprüft · {} Module übersetzt, {} wiederverwendet",
                self.editor.compiler.stats.compiled, self.editor.compiler.stats.reused
            );
        }
    }
}
pub fn line_offset(code: &str, line: u32) -> usize {
    if line <= 1 {
        0
    } else {
        code.match_indices('\n')
            .nth(line as usize - 2)
            .map_or(code.len(), |(i, _)| i + 1)
    }
}
