//! Embedded Markdown documentation. No filesystem, process or network access.
use crate::{
    app::{App, Mode, WindowKind},
    commands::Command,
};
use anyhow::{anyhow, ensure, Result};
use crossterm::event::{KeyCode as K, KeyEvent, KeyModifiers as M};
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use ratatui::{
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
    Frame,
};
use std::{collections::BTreeSet, sync::OnceLock};
use unicode_width::UnicodeWidthChar;

const DOCUMENTS: &[(&str, &str)] = &[
    ("docs/hilfe.md", include_str!("../../../docs/hilfe.md")),
    (
        "docs/tutorial.md",
        include_str!("../../../docs/tutorial.md"),
    ),
    (
        "docs/tastatur.md",
        include_str!("../../../docs/tastatur.md"),
    ),
    (
        "docs/ide-bedienung.md",
        include_str!("../../../docs/ide-bedienung.md"),
    ),
    (
        "docs/sprachreferenz.md",
        include_str!("../../../docs/sprachreferenz.md"),
    ),
    (
        "docs/bibliothek.md",
        include_str!("../../../docs/bibliothek.md"),
    ),
    (
        "docs/forms-referenz.md",
        include_str!("../../../docs/forms-referenz.md"),
    ),
    (
        "docs/dateiformate.md",
        include_str!("../../../docs/dateiformate.md"),
    ),
    (
        "docs/inventar.md",
        include_str!("../../../docs/inventar.md"),
    ),
    (
        "docs/ide-referenz.md",
        include_str!("../../../docs/ide-referenz.md"),
    ),
    (
        "docs/tbvm-design.md",
        include_str!("../../../docs/tbvm-design.md"),
    ),
    ("PLAN.md", include_str!("../../../PLAN.md")),
];
#[derive(Clone, Debug)]
pub struct Glyph {
    pub ch: char,
    pub style: Style,
    pub link: Option<usize>,
}
#[derive(Clone, Debug, Default)]
pub struct Block {
    pub glyphs: Vec<Glyph>,
    pub pre: bool,
}
impl Block {
    pub fn text(&self) -> String {
        self.glyphs.iter().map(|g| g.ch).collect()
    }
}
#[derive(Clone, Debug)]
pub struct Link {
    pub label: String,
    pub target: String,
}
#[derive(Clone, Debug)]
pub struct Heading {
    pub title: String,
    pub anchor: String,
    pub block: usize,
    pub level: usize,
}
#[derive(Clone, Debug)]
pub struct Page {
    pub path: String,
    pub title: String,
    pub blocks: Vec<Block>,
    pub links: Vec<Link>,
    pub headings: Vec<Heading>,
}
pub fn slug(text: &str) -> String {
    text.to_lowercase()
        .chars()
        .filter_map(|c| {
            if c.is_alphanumeric() || c == '_' || c == '-' {
                Some(c)
            } else if c.is_whitespace() {
                Some('-')
            } else {
                None
            }
        })
        .collect()
}
pub fn normalize(text: &str) -> String {
    text.trim()
        .trim_end_matches(['$', '%', '&', '!', '#'])
        .to_uppercase()
}
fn words(text: &str) -> Vec<String> {
    text.split(|c: char| !c.is_alphanumeric() && !matches!(c, '_' | '$' | '%' | '&' | '!' | '#'))
        .filter(|s| !s.is_empty())
        .map(normalize)
        .collect()
}
fn append(block: &mut Block, text: &str, style: Style, link: Option<usize>) {
    block
        .glyphs
        .extend(text.chars().map(|ch| Glyph { ch, style, link }));
}
fn flush(blocks: &mut Vec<Block>, block: &mut Block) {
    if !block.glyphs.is_empty() {
        blocks.push(std::mem::take(block));
    }
}
impl Page {
    pub fn parse(path: &str, markdown: &str) -> Self {
        let mut page = Self {
            path: path.into(),
            title: path.into(),
            blocks: vec![],
            links: vec![],
            headings: vec![],
        };
        let mut block = Block::default();
        let mut pre = false;
        let mut heading = None;
        let mut link = None;
        let mut strong = 0usize;
        let mut emphasis = 0usize;
        let mut lists = Vec::<Option<u64>>::new();
        let mut anchors = BTreeSet::new();
        for event in Parser::new_ext(markdown, Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS) {
            let mut style = Style::default();
            if strong > 0 || heading.is_some() {
                style = style.add_modifier(Modifier::BOLD);
            }
            if emphasis > 0 {
                style = style.add_modifier(Modifier::ITALIC);
            }
            if heading.is_some() {
                style = style.fg(Color::Yellow);
            }
            match event {
                Event::Start(Tag::Heading { level, .. }) => {
                    flush(&mut page.blocks, &mut block);
                    heading = Some(level as usize);
                }
                Event::End(TagEnd::Heading(_)) => {
                    let title = block.text();
                    let base = slug(&title);
                    let mut anchor = base.clone();
                    let mut suffix = 0;
                    while !anchors.insert(anchor.clone()) {
                        suffix += 1;
                        anchor = format!("{base}-{suffix}");
                    }
                    if page.headings.is_empty() {
                        page.title = title.clone();
                    }
                    page.headings.push(Heading {
                        title,
                        anchor,
                        block: page.blocks.len(),
                        level: heading.take().unwrap(),
                    });
                    flush(&mut page.blocks, &mut block);
                }
                Event::Start(Tag::CodeBlock(_)) => {
                    flush(&mut page.blocks, &mut block);
                    pre = true;
                    block.pre = true;
                }
                Event::End(TagEnd::CodeBlock) => {
                    flush(&mut page.blocks, &mut block);
                    pre = false;
                }
                Event::Start(Tag::Table(_)) => {
                    flush(&mut page.blocks, &mut block);
                    pre = true;
                    block.pre = true;
                }
                Event::End(TagEnd::Table) => {
                    flush(&mut page.blocks, &mut block);
                    pre = false;
                }
                Event::End(TagEnd::TableHead | TagEnd::TableRow) => {
                    flush(&mut page.blocks, &mut block);
                    block.pre = true;
                }
                Event::Start(Tag::TableCell) => append(&mut block, " | ", style, None),
                Event::End(TagEnd::TableCell) => {}
                Event::Start(Tag::List(start)) => {
                    flush(&mut page.blocks, &mut block);
                    lists.push(start);
                }
                Event::End(TagEnd::List(_)) => {
                    flush(&mut page.blocks, &mut block);
                    lists.pop();
                }
                Event::Start(Tag::Item) => {
                    flush(&mut page.blocks, &mut block);
                    let prefix = match lists.last_mut() {
                        Some(Some(n)) => {
                            let p = format!("{n}. ");
                            *n += 1;
                            p
                        }
                        _ => "• ".into(),
                    };
                    append(
                        &mut block,
                        &format!("{}{prefix}", "  ".repeat(lists.len().saturating_sub(1))),
                        style,
                        None,
                    );
                }
                Event::End(TagEnd::Item | TagEnd::Paragraph) => flush(&mut page.blocks, &mut block),
                Event::Start(Tag::Strong) => strong += 1,
                Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
                Event::Start(Tag::Emphasis) => emphasis += 1,
                Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
                Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => {
                    link = Some(page.links.len());
                    page.links.push(Link {
                        label: String::new(),
                        target: dest_url.into_string(),
                    });
                    append(&mut block, "◄", style, link);
                }
                Event::End(TagEnd::Link | TagEnd::Image) => {
                    append(&mut block, "►", style, link);
                    if let Some(id) = link.take() {
                        if external(&page.links[id].target) {
                            append(
                                &mut block,
                                &format!(" [extern: {}]", page.links[id].target),
                                style,
                                Some(id),
                            );
                        }
                    }
                }
                Event::Text(text)
                | Event::Code(text)
                | Event::InlineHtml(text)
                | Event::Html(text) => {
                    if let Some(id) = link {
                        page.links[id].label.push_str(&text);
                    }
                    if pre {
                        for (n, line) in text.split('\n').enumerate() {
                            if n > 0 {
                                page.blocks.push(std::mem::take(&mut block));
                                block.pre = true;
                            }
                            append(&mut block, &line.replace('\t', "    "), style, link);
                        }
                    } else {
                        append(&mut block, &text, style, link);
                    }
                }
                Event::SoftBreak => append(&mut block, " ", style, link),
                Event::HardBreak => {
                    flush(&mut page.blocks, &mut block);
                    block.pre = pre;
                }
                Event::Rule => {
                    flush(&mut page.blocks, &mut block);
                    append(&mut block, "────────", style, None);
                    flush(&mut page.blocks, &mut block);
                }
                Event::TaskListMarker(checked) => append(
                    &mut block,
                    if checked { "[x] " } else { "[ ] " },
                    style,
                    None,
                ),
                _ => {}
            }
        }
        flush(&mut page.blocks, &mut block);
        page
    }
}
pub fn external(target: &str) -> bool {
    target.contains(':') || target.starts_with("//")
}
fn decode(text: &str) -> Result<String> {
    let mut bytes = vec![];
    let mut iter = text.bytes();
    while let Some(c) = iter.next() {
        if c == b'%' {
            let hi = iter.next().and_then(|c| (c as char).to_digit(16));
            let lo = iter.next().and_then(|c| (c as char).to_digit(16));
            bytes.push(
                (hi.ok_or_else(|| anyhow!("Ungültige URL-Kodierung"))? * 16
                    + lo.ok_or_else(|| anyhow!("Ungültige URL-Kodierung"))?) as u8,
            );
        } else {
            bytes.push(c);
        }
    }
    Ok(String::from_utf8(bytes)?)
}
#[derive(Debug)]
pub struct Catalog {
    pub pages: Vec<Page>,
}
impl Catalog {
    pub fn embedded() -> &'static Self {
        static CATALOG: OnceLock<Catalog> = OnceLock::new();
        CATALOG.get_or_init(|| Self::new(DOCUMENTS))
    }
    pub fn new(documents: &[(&str, &str)]) -> Self {
        let mut pages: Vec<_> = documents.iter().map(|(p, md)| Page::parse(p, md)).collect();
        let mut contents = String::from("# Contents\n\n");
        let mut entries = vec![];
        for p in &pages {
            contents.push_str(&format!("- [{}]({})\n", p.title, p.path));
            entries.push((p.title.clone(), p.path.clone()));
            for h in p.headings.iter().skip(1) {
                let target = format!("{}#{}", p.path, h.anchor);
                contents.push_str(&format!(
                    "{}- [{}]({target})\n",
                    "  ".repeat(h.level.saturating_sub(1)),
                    h.title
                ));
                entries.push((format!("{} — {}", h.title, p.title), target));
            }
        }
        entries.sort_by_key(|(label, _)| label.to_lowercase());
        let index = format!(
            "# Index\n\n{}",
            entries
                .into_iter()
                .map(|(l, t)| format!("- [{l}]({t})\n"))
                .collect::<String>()
        );
        pages.push(Page::parse("contents", &contents));
        pages.push(Page::parse("index", &index));
        Self { pages }
    }
    pub fn resolve(&self, source: &str, target: &str) -> Result<(usize, usize)> {
        ensure!(
            !external(target),
            "Externes Ziel: {target} — wird nicht automatisch geöffnet"
        );
        let (path, anchor) = target.split_once('#').unwrap_or((target, ""));
        let path = decode(path)?;
        ensure!(
            !path.starts_with('/') && !path.contains('\\') && !path.contains(':'),
            "Ziel außerhalb des Hilfekatalogs"
        );
        let mut parts: Vec<&str> = if path.is_empty() {
            source.split('/').collect()
        } else {
            source
                .rsplit_once('/')
                .map(|(p, _)| p.split('/').collect())
                .unwrap_or_default()
        };
        for part in path.split('/').filter(|s| !s.is_empty() && *s != ".") {
            if part == ".." {
                ensure!(parts.pop().is_some(), "Ziel außerhalb des Hilfekatalogs");
            } else {
                parts.push(part);
            }
        }
        let path = parts.join("/");
        let page = self
            .pages
            .iter()
            .position(|p| p.path == path)
            .ok_or_else(|| anyhow!("Hilfedatei fehlt: {path}"))?;
        let anchor = decode(anchor)?;
        let block = if anchor.is_empty() {
            0
        } else {
            self.pages[page]
                .headings
                .iter()
                .find(|h| h.anchor == anchor)
                .ok_or_else(|| anyhow!("Hilfeanker fehlt: {path}#{anchor}"))?
                .block
        };
        Ok((page, block))
    }
    pub fn validate(&self) -> Result<()> {
        for page in &self.pages {
            for link in &page.links {
                if !external(&link.target) {
                    self.resolve(&page.path, &link.target)?;
                }
            }
        }
        Ok(())
    }
    pub fn search(&self, query: &str, only: Option<&str>) -> Vec<(usize, usize)> {
        let query = normalize(query);
        if query.is_empty() {
            return vec![];
        }
        let mut result = vec![];
        for (id, page) in
            self.pages.iter().enumerate().filter(|(_, p)| {
                p.path.starts_with("docs/") && only.is_none_or(|path| p.path == path)
            })
        {
            for h in &page.headings {
                let end = page
                    .headings
                    .iter()
                    .find(|next| next.block > h.block)
                    .map_or(page.blocks.len(), |next| next.block);
                if page.blocks[h.block..end]
                    .iter()
                    .any(|b| words(&b.text()).contains(&query))
                {
                    result.push((id, h.block));
                }
            }
        }
        result
    }
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Position {
    pub page: usize,
    pub block: usize,
    pub offset: usize,
    pub link: Option<usize>,
    pub horizontal: usize,
    pub query: Option<String>,
}
#[derive(Default)]
pub struct Help {
    pub position: Position,
    pub history: Vec<Position>,
    pub input: String,
    pub notice: String,
    pub return_focus: Option<(u64, bool, bool, bool)>,
}
#[derive(Debug)]
pub struct Row {
    pub block: usize,
    pub start: usize,
    pub end: usize,
}
pub fn layout(page: &Page, width: usize) -> Vec<Row> {
    let width = width.max(1);
    let mut rows = vec![];
    for (index, block) in page.blocks.iter().enumerate() {
        if block.pre || block.glyphs.is_empty() {
            rows.push(Row {
                block: index,
                start: 0,
                end: block.glyphs.len(),
            });
            continue;
        }
        let mut start = 0;
        while start < block.glyphs.len() {
            let mut end = start;
            let mut cells = 0;
            let mut space = None;
            while end < block.glyphs.len() {
                let ch = block.glyphs[end].ch;
                let w = ch.width().unwrap_or(0);
                if cells + w > width && end > start {
                    break;
                }
                cells += w;
                end += 1;
                if ch.is_whitespace() {
                    space = Some(end);
                }
            }
            if end < block.glyphs.len() {
                if let Some(s) = space {
                    end = s;
                }
            }
            rows.push(Row {
                block: index,
                start,
                end,
            });
            start = end;
        }
        rows.push(Row {
            block: index,
            start: block.glyphs.len(),
            end: block.glyphs.len(),
        });
    }
    rows
}
impl Help {
    pub fn page(&self) -> std::borrow::Cow<'_, Page> {
        let catalog = Catalog::embedded();
        if let Some(query) = &self.position.query {
            let matches = catalog.search(query, None);
            let mut md = format!("# Index: {}\n\n", query.replace(['[', ']', '<', '>'], ""));
            if matches.is_empty() {
                md.push_str("Kein passendes Thema. Suche ändern oder im Index wählen.\n\n");
            }
            for (p, b) in matches {
                let page = &catalog.pages[p];
                let h = page.headings.iter().find(|h| h.block == b).unwrap();
                md.push_str(&format!(
                    "- [{} — {}]({}#{})\n",
                    h.title, page.title, page.path, h.anchor
                ));
            }
            md.push_str("\n[Alphabetischer Index](index) · [Contents](contents)\n");
            std::borrow::Cow::Owned(Page::parse("search", &md))
        } else {
            std::borrow::Cow::Borrowed(&catalog.pages[self.position.page])
        }
    }
    pub fn visit(&mut self, page: usize, block: usize, query: Option<String>) {
        if self.history.len() == 20 {
            self.history.remove(0);
        }
        self.history.push(self.position.clone());
        self.position = Position {
            page,
            block,
            query,
            ..Default::default()
        };
        self.notice.clear();
        self.input.clear();
    }
    pub fn open(&mut self, target: &str) -> Result<()> {
        let source = self.page().path.clone();
        let (page, block) = Catalog::embedded().resolve(&source, target)?;
        self.visit(page, block, None);
        Ok(())
    }
    pub fn follow(&mut self) -> Result<()> {
        let target = self
            .page()
            .links
            .get(
                self.position
                    .link
                    .ok_or_else(|| anyhow!("Zuerst mit Tab einen Link wählen"))?,
            )
            .ok_or_else(|| anyhow!("Link fehlt"))?
            .target
            .clone();
        self.open(&target)
    }
    pub fn back(&mut self) {
        if let Some(p) = self.history.pop() {
            self.position = p;
            self.notice.clear();
            self.input.clear();
        } else {
            self.notice = "Keine ältere Hilfeseite; maximal 20 Rücksprünge".into();
        }
    }
    pub fn next(&mut self) {
        let end = DOCUMENTS.len();
        if self.position.page + 1 < end && self.position.query.is_none() {
            self.visit(self.position.page + 1, 0, None);
        } else if self.position.page >= end || self.position.query.is_some() {
            self.visit(0, 0, None);
        } else {
            self.notice = "Ende der Contents-Themenreihenfolge".into();
        }
    }
    pub fn focus_link(&mut self, backwards: bool) {
        let page = self.page();
        let n = page.links.len();
        if n == 0 {
            return;
        }
        let id = match self.position.link {
            Some(i) => (i + n + if backwards { n - 1 } else { 1 }) % n,
            None => {
                if backwards {
                    n - 1
                } else {
                    0
                }
            }
        };
        let location = page.blocks.iter().enumerate().find_map(|(b, block)| {
            block
                .glyphs
                .iter()
                .position(|g| g.link == Some(id))
                .map(|offset| (b, offset, block.pre))
        });
        self.position.link = Some(id);
        if let Some((b, o, pre)) = location {
            self.position.block = b;
            self.position.offset = o;
            self.position.horizontal = if pre {
                self.page().blocks[b].glyphs[..o]
                    .iter()
                    .map(|g| g.ch.width().unwrap_or(0))
                    .sum()
            } else {
                0
            };
        }
    }
    pub fn scroll(&mut self, delta: isize, width: usize) {
        let rows = layout(&self.page(), width);
        if rows.is_empty() {
            return;
        }
        let current = self.row(&rows);
        let index = current.saturating_add_signed(delta).min(rows.len() - 1);
        self.position.block = rows[index].block;
        self.position.offset = rows[index].start;
    }
    fn row(&self, rows: &[Row]) -> usize {
        rows.iter()
            .position(|r| {
                r.block == self.position.block
                    && r.start <= self.position.offset
                    && (self.position.offset < r.end || r.start == r.end)
            })
            .unwrap_or_else(|| {
                rows.iter()
                    .position(|r| r.block >= self.position.block)
                    .unwrap_or(0)
            })
    }
}
impl App {
    fn help_show(&mut self) {
        if self
            .active_window()
            .is_none_or(|w| w.kind != WindowKind::Help)
        {
            self.help.return_focus = Some((
                self.active,
                self.properties,
                self.value_focus,
                self.session.fullscreen,
            ));
        }
        self.properties = false;
        self.value_focus = false;
        self.session.fullscreen = false;
        self.show_tool(WindowKind::Help);
    }
    pub(crate) fn help_close(&mut self) {
        self.windows.retain(|w| w.kind != WindowKind::Help);
        if let Some((id, p, v, fullscreen)) = self.help.return_focus.take() {
            self.active = if self.windows.iter().any(|w| w.id == id) {
                id
            } else {
                self.windows.first().map_or(0, |w| w.id)
            };
            self.properties = p;
            self.value_focus = v;
            self.session.fullscreen = fullscreen;
        }
    }
    pub fn help_context(&mut self, token: &str, class: Option<&str>) {
        let catalog = Catalog::embedded();
        let matches = if let Some(class) = class {
            let page = catalog
                .pages
                .iter()
                .position(|p| p.path == "docs/forms-referenz.md")
                .unwrap();
            let p = &catalog.pages[page];
            let heading = p
                .headings
                .iter()
                .find(|h| words(&h.title).contains(&normalize(class)));
            heading
                .map(|h| {
                    let end = p
                        .headings
                        .iter()
                        .find(|next| next.block > h.block)
                        .map_or(p.blocks.len(), |next| next.block);
                    let block = (h.block..end)
                        .find(|b| words(&p.blocks[*b].text()).contains(&normalize(token)))
                        .unwrap_or(h.block);
                    vec![(page, block)]
                })
                .unwrap_or_default()
        } else {
            let library = catalog.search(token, Some("docs/bibliothek.md"));
            if !library.is_empty() {
                library
            } else {
                catalog.search(token, None)
            }
        };
        self.help_show();
        if matches.len() == 1 {
            self.help.visit(matches[0].0, matches[0].1, None);
        } else if token.is_empty() {
            self.help.visit(catalog.pages.len() - 1, 0, None);
        } else {
            self.help
                .visit(catalog.pages.len() - 1, 0, Some(token.into()));
        }
    }
    pub(crate) fn help_command(&mut self, command: Command) -> Result<bool> {
        use Command::*;
        let target = match command {
            HelpIndex => "index",
            HelpContents => "contents",
            Keyboard => "docs/tastatur.md",
            UsingHelp => "docs/hilfe.md",
            Tutorial => "docs/tutorial.md",
            HelpWindow => {
                self.help_show();
                return Ok(true);
            }
            Topic => {
                if self
                    .active_window()
                    .is_some_and(|w| w.kind == WindowKind::Help)
                {
                    self.help_context("", None);
                    return Ok(true);
                }
                if self.mode == Mode::Designer {
                    if let Ok((class, property)) = self.design_help_context() {
                        self.help_context(&property, Some(&class));
                        return Ok(true);
                    }
                }
                let token = self
                    .editor_view()
                    .ok()
                    .and_then(|v| self.project.view(v).ok())
                    .and_then(|v| {
                        self.project.document(v.document()).ok().map(|d| {
                            token_at(
                                self.editor
                                    .expansions
                                    .get(&self.editor_view().unwrap())
                                    .map_or(d.code(), String::as_str),
                                v.cursor,
                            )
                        })
                    })
                    .unwrap_or_default();
                self.help_context(&token, None);
                return Ok(true);
            }
            CloseWindow
                if self
                    .active_window()
                    .is_some_and(|w| w.kind == WindowKind::Help) =>
            {
                self.help_close();
                return Ok(true);
            }
            _ => return Ok(false),
        };
        let (page, block) = Catalog::embedded().resolve("", target)?;
        self.help_show();
        self.help.visit(page, block, None);
        Ok(true)
    }
    pub(crate) fn help_menu_context(&mut self, command: Command) {
        let target = command_target(command, self.mode == Mode::Designer);
        self.menu = None;
        self.control_menu = false;
        self.help_show();
        match Catalog::embedded().resolve("", target) {
            Ok((p, b)) => self.help.visit(p, b, None),
            Err(e) => self.help.notice = e.to_string(),
        }
    }
    pub(crate) fn help_key(&mut self, key: KeyEvent) -> bool {
        if self
            .active_window()
            .is_none_or(|w| w.kind != WindowKind::Help)
        {
            return false;
        }
        let r = self.rect(self.active_window().unwrap());
        let width = r.width.saturating_sub(2) as usize;
        let page = r.height.saturating_sub(4).max(1) as isize;
        match key.code {
            K::Esc => self.help_close(),
            K::F(1) if key.modifiers.contains(M::ALT) => self.help.back(),
            K::F(1) if key.modifiers.contains(M::CONTROL) => self.help.next(),
            K::Tab => self.help.focus_link(key.modifiers.contains(M::SHIFT)),
            K::BackTab => self.help.focus_link(true),
            K::Enter => {
                if self.help.input.is_empty() {
                    if let Err(e) = self.help.follow() {
                        self.help.notice = e.to_string();
                    }
                } else {
                    let query = std::mem::take(&mut self.help.input);
                    self.help
                        .visit(Catalog::embedded().pages.len() - 1, 0, Some(query));
                }
            }
            K::Up => self.help.scroll(-1, width),
            K::Down => self.help.scroll(1, width),
            K::PageUp => self.help.scroll(-page, width),
            K::PageDown => self.help.scroll(page, width),
            K::Home => {
                self.help.position.block = 0;
                self.help.position.offset = 0;
            }
            K::End => self.help.scroll(isize::MAX, width),
            K::Left if !key.modifiers.contains(M::CONTROL) => {
                self.help.position.horizontal = self.help.position.horizontal.saturating_sub(4)
            }
            K::Right if !key.modifiers.contains(M::CONTROL) => {
                self.help.position.horizontal = self.help.position.horizontal.saturating_add(4)
            }
            K::Backspace => {
                self.help.input.pop();
            }
            K::Char(c) if !key.modifiers.intersects(M::ALT | M::CONTROL) => self.help.input.push(c),
            K::Delete => {}
            _ => return false,
        }
        true
    }
    pub(crate) fn help_render(&self, f: &mut Frame, area: Rect) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        let page = self.help.page();
        let rows = layout(&page, area.width as usize);
        let start = self.help.row(&rows);
        f.render_widget(
            Paragraph::new(format!("{} · Suche: {}", page.title, self.help.input))
                .style(Style::default().fg(Color::Yellow)),
            Rect::new(area.x, area.y, area.width, 1),
        );
        for (y, row) in rows
            .iter()
            .skip(start)
            .take(area.height.saturating_sub(2) as usize)
            .enumerate()
        {
            let block = &page.blocks[row.block];
            let mut x = 0;
            let shift = if block.pre {
                self.help.position.horizontal
            } else {
                0
            };
            let mut spans: Vec<Span> = vec![];
            for g in &block.glyphs[row.start..row.end] {
                let w = g.ch.width().unwrap_or(0);
                let end = x + w;
                if x >= shift && end <= shift + area.width as usize {
                    let mut style = g.style;
                    if let Some(link) = g.link {
                        style = style.fg(Color::Cyan).add_modifier(Modifier::UNDERLINED);
                        if self.help.position.link == Some(link) {
                            style = style.bg(Color::Blue).fg(Color::White);
                        }
                    }
                    if let Some(last) = spans.last_mut().filter(|s| s.style == style) {
                        last.content.to_mut().push(g.ch);
                    } else {
                        spans.push(Span::styled(g.ch.to_string(), style));
                    }
                } else if x < shift && end > shift {
                    spans.push(Span::raw(" ".repeat(end - shift)));
                }
                x = end;
            }
            f.render_widget(
                Paragraph::new(Line::from(spans)),
                Rect::new(area.x, area.y + 1 + y as u16, area.width, 1),
            );
        }
        if area.height > 1 {
            let status = if self.help.notice.is_empty() {
                "Tab: Link · Enter: folgen · Alt+F1: zurück · Esc: schließen"
            } else {
                &self.help.notice
            };
            f.render_widget(
                Paragraph::new(status),
                Rect::new(area.x, area.bottom() - 1, area.width, 1),
            );
        }
    }
}
pub fn token_at(code: &str, cursor: usize) -> String {
    let mut cursor = cursor.min(code.len());
    while !code.is_char_boundary(cursor) {
        cursor -= 1;
    }
    let valid = |c: char| c.is_alphanumeric() || matches!(c, '_' | '$' | '%' | '&' | '!' | '#');
    let start = code[..cursor]
        .char_indices()
        .rev()
        .take_while(|(_, c)| valid(*c))
        .last()
        .map_or(cursor, |(i, _)| i);
    let end = code[cursor..]
        .char_indices()
        .find(|(_, c)| !valid(*c))
        .map_or(code.len(), |(i, _)| cursor + i);
    code[start..end].into()
}

/// Context aliases for concrete command IDs; every target is checked with the catalog.
pub fn command_target(command: Command, designer: bool) -> &'static str {
    use Command::*;
    match command {
        MakeExe | MakeLibrary => {
            "docs/ide-bedienung.md#native-exporte-ui-in-phase-5-erzeugung-in-phase-6"
        }
        NewForm | Form | Events | MenuBar | Grid | Palette | MenuDesign | Toolbox | Tool(_) => {
            "docs/ide-bedienung.md#formulare-gestalten"
        }
        Undo | Cut | Copy | Paste | Clear if designer => {
            "docs/ide-bedienung.md#formulare-gestalten"
        }
        Undo | Cut | Copy | Paste | Clear | Procedures | PreviousCode | NewSub | NewFunction
        | Code | IncludedFile | IncludedLines | Find | SelectedText | FindNext | Replace
        | LoadText | SaveText => "docs/ide-bedienung.md#editor-suche-und-quelltextpflege",
        Diagnostics | SyntaxChecking => "docs/ide-bedienung.md#übersetzung-und-revisionsbindung",
        Start | Restart | Continue | Pause | CommandLine | Startup | Output | OutputScreen
        | Shell => "docs/ide-bedienung.md#programme-ausführen",
        AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches | Trace | History
        | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement | RunToCursor | Step
        | ProcedureStep | HistoryBack | HistoryForward | NextStatement | Calls | Debug
        | Immediate => "docs/ide-bedienung.md#debugger-und-direktfenster",
        Display | Paths | SaveOptions => "docs/ide-bedienung.md#display-und-benutzereinstellungen",
        RightMouse | Topic | UsingHelp | HelpIndex | HelpContents | HelpWindow | About => {
            "docs/hilfe.md"
        }
        Keyboard => "docs/tastatur.md",
        Tutorial | NewProject | NewModule => "docs/tutorial.md#projekt-und-formular-erstellen",
        OpenProject | SaveProject | AddFile | RemoveFile | SaveFile | SaveAs | Exit => {
            "docs/ide-bedienung.md#speichern-und-wechseln"
        }
        Print => "docs/ide-bedienung.md#programme-ausführen",
        Project | NewWindow | Arrange | NextWindow | PreviousWindow | CloseWindow | MoveWindow
        | SizeWindow | Minimize | Maximize | Restore | ControlMenu | FocusWindow(_) => {
            "docs/ide-bedienung.md#menüs-fenster-und-dialoge"
        }
    }
}
