//! Dokumentaktionen für Editor und Designer. Kein Terminal und keine zweite Quellkopie.
use anyhow::{anyhow, bail, ensure, Context, Result};
use std::{
    collections::BTreeMap,
    fs::{self, OpenOptions},
    io::Write,
    ops::Range,
    path::{Path, PathBuf},
    sync::atomic::{AtomicU64, Ordering},
};
use tb_ui::frm::{self, FormFile, FormNode};
use tb_vm::project_io::{
    has_extension, identity, read_document, Content, Manifest, ProjectLine, ProjectSources,
    SourceLoader,
};

static NEXT_ID: AtomicU64 = AtomicU64::new(1);
fn next_id() -> u64 {
    NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
fn loaded<T>(result: std::result::Result<T, String>) -> Result<T> {
    result.map_err(|e| anyhow!(e))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct DocumentId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ViewId(u64);

#[derive(Debug, Clone)]
pub struct View {
    document: DocumentId,
    pub cursor: usize,
    pub anchor: Option<usize>,
    pub bookmarks: [Option<usize>; 4],
    pub overwrite: bool,
    pub scroll_line: usize,
    pub scroll_column: usize,
}
impl View {
    pub fn selection(&self) -> Option<Range<usize>> {
        self.anchor
            .filter(|a| *a != self.cursor)
            .map(|a| a.min(self.cursor)..a.max(self.cursor))
    }
    pub fn document(&self) -> DocumentId {
        self.document
    }
}

#[derive(Debug)]
struct Edit {
    content: Content,
    design_ids: BTreeMap<(String, Option<i32>), u64>,
    group: Option<(u64, Vec<DocumentId>)>,
    views: Vec<(ViewId, View)>,
}
#[derive(Debug)]
pub struct Document {
    source_path: PathBuf,
    path: Option<PathBuf>,
    content: Content,
    revision: u64,
    saved: Option<Content>,
    disk: Option<Vec<u8>>,
    binary_source: Option<PathBuf>,
    warnings: Vec<frm::BinaryWarning>,
    undo: Vec<Edit>,
    design_ids: BTreeMap<(String, Option<i32>), u64>,
}
impl Document {
    pub fn path(&self) -> Option<&Path> {
        self.path.as_deref()
    }
    pub fn source_path(&self) -> &Path {
        &self.source_path
    }
    pub fn content(&self) -> &Content {
        &self.content
    }
    pub fn code(&self) -> &str {
        self.content.code()
    }
    pub fn revision(&self) -> u64 {
        self.revision
    }
    pub fn is_dirty(&self) -> bool {
        self.saved.as_ref() != Some(&self.content)
    }
    pub fn import_warnings(&self) -> &[frm::BinaryWarning] {
        &self.warnings
    }
}

#[derive(Debug, Clone)]
pub struct Destination {
    pub path: PathBuf,
    /// Ausdrückliche Entscheidung des Benutzers für dieses Ziel, kein globaler Default.
    pub overwrite: bool,
}
impl Destination {
    pub fn new(path: impl Into<PathBuf>) -> Self {
        Self {
            path: path.into(),
            overwrite: false,
        }
    }
}
#[derive(Debug, Default)]
pub struct SavePlan {
    pub project: Option<Destination>,
    pub files: BTreeMap<DocumentId, Destination>,
}
pub enum Decision<'a> {
    Save(&'a SavePlan),
    Discard,
    Cancel,
}
pub enum StartupRemoval {
    Default,
    Replace(DocumentId),
    Cancel,
}

#[derive(Debug)]
pub struct Project {
    base: PathBuf,
    path: Option<PathBuf>,
    manifest: Manifest,
    saved_manifest: Option<Manifest>,
    manifest_disk: Option<Vec<u8>>,
    documents: BTreeMap<DocumentId, Document>,
    views: BTreeMap<ViewId, View>,
    pub include_paths: Vec<PathBuf>,
    pub save_notices: Vec<String>,
}
impl Project {
    pub fn directory(&self) -> &Path {
        &self.base
    }
    pub fn new(base: &Path) -> Result<Self> {
        Ok(Self {
            base: identity(base)?,
            path: None,
            manifest: Manifest::default(),
            saved_manifest: None,
            manifest_disk: None,
            documents: BTreeMap::new(),
            views: BTreeMap::new(),
            include_paths: Vec::new(),
            save_notices: Vec::new(),
        })
    }
    pub fn open(path: &Path, include_paths: Vec<PathBuf>) -> Result<Self> {
        let mut loader = SourceLoader::default();
        loader.include_paths = include_paths.clone();
        let path = identity(&loaded(
            loader.resolve(Path::new("."), &path.to_string_lossy()),
        )?)?;
        let sources = loaded(loader.load(&path))?; // Alle Includes prüfen, bevor das alte Projekt ersetzt wird.
        let mut project = Self::new(path.parent().unwrap_or(Path::new(".")))?;
        project.include_paths = include_paths;
        let mut manifest = sources.manifest;
        for line in &mut manifest.lines {
            if let ProjectLine::File(p) = line {
                *p = identity(p)?;
            }
        }
        manifest.startup = manifest.startup.as_deref().map(identity).transpose()?;
        let paths: Vec<_> = manifest
            .members()
            .cloned()
            .chain(
                sources
                    .units
                    .iter()
                    .flat_map(|u| u.segments.iter().map(|s| PathBuf::from(&s.file))),
            )
            .collect();
        for member in paths {
            project.open_document(&member)?;
        }
        if has_extension(&path, "mak") {
            project.manifest_disk = Some(fs::read(&path)?);
            project.saved_manifest = Some(manifest.clone());
            project.path = Some(path);
        }
        project.manifest = manifest;
        Ok(project)
    }
    pub fn path(&self) -> Option<&Path> {
        self.path.as_deref()
    }
    pub fn manifest(&self) -> &Manifest {
        &self.manifest
    }
    pub fn document(&self, id: DocumentId) -> Result<&Document> {
        self.documents
            .get(&id)
            .ok_or_else(|| anyhow!("Dokument nicht geöffnet"))
    }
    pub fn documents(&self) -> impl Iterator<Item = (DocumentId, &Document)> {
        self.documents.iter().map(|(id, d)| (*id, d))
    }
    pub fn find_document(&self, path: &Path) -> Option<DocumentId> {
        let key = identity(path).ok()?;
        self.documents
            .iter()
            .find_map(|(id, d)| (d.source_path == key).then_some(*id))
    }
    pub fn members(&self) -> Vec<DocumentId> {
        self.manifest
            .members()
            .filter_map(|p| self.find_document(p))
            .collect()
    }
    pub fn startup(&self) -> Option<DocumentId> {
        self.manifest
            .startup
            .as_deref()
            .and_then(|p| self.find_document(p))
    }
    pub fn is_dirty(&self) -> bool {
        self.saved_manifest.as_ref() != Some(&self.manifest)
            || self.documents.values().any(Document::is_dirty)
    }
    pub fn loader(&self) -> Result<SourceLoader> {
        let mut loader = SourceLoader::default();
        loader.include_paths = self.include_paths.clone();
        for doc in self.documents.values() {
            loaded(loader.insert(&doc.source_path, doc.content.clone()))?;
        }
        Ok(loader)
    }
    pub fn sources(&self) -> Result<ProjectSources> {
        loaded(self.loader()?.load_manifest(self.manifest.clone()))
    }

    /// Auch Includes sind eigenständige Originaldokumente, niemals die Expansion.
    pub fn open_document(&mut self, path: &Path) -> Result<DocumentId> {
        let loader = self.loader()?;
        let path = identity(&loaded(
            loader.resolve(&self.base, &path.to_string_lossy()),
        )?)?;
        if let Some(id) = self.find_document(&path) {
            return Ok(id);
        }
        ensure!(!has_extension(&path, "mak"), "MAK über Open Project öffnen");
        let read = loaded(read_document(&path))?;
        let id = DocumentId(next_id());
        self.documents.insert(
            id,
            Document {
                source_path: path.clone(),
                path: Some(path.clone()),
                saved: Some(read.content.clone()),
                content: read.content,
                revision: 0,
                disk: Some(read.bytes),
                binary_source: read.binary.then_some(path),
                warnings: read.warnings,
                undo: Vec::new(),
                design_ids: BTreeMap::new(),
            },
        );
        Ok(id)
    }
    pub fn add_file(&mut self, path: &Path) -> Result<DocumentId> {
        ensure!(
            has_extension(path, "bas") || has_extension(path, "frm"),
            "Projektmitglied muss BAS oder FRM sein"
        );
        // Auch die hinzugefügte Include-Kette muss vor jeder Änderung gültig sein.
        let loader = self.loader()?;
        let path = identity(&loaded(
            loader.resolve(&self.base, &path.to_string_lossy()),
        )?)?;
        loaded(loader.load(&path))?;
        let id = self.open_document(&path)?;
        if !self.manifest.members().any(|p| p == &path) {
            self.manifest.lines.push(ProjectLine::File(path));
        }
        Ok(id)
    }
    fn create(&mut self, name: &str, content: Content, extension: &str) -> Result<DocumentId> {
        ensure!(
            !name.is_empty()
                && name.trim() == name
                && !name.contains(['/', '\\', '\n', '\r', '\0', '"'])
                && name != "."
                && name != "..",
            "Ungültiger Dokumentname"
        );
        let path = identity(&self.base.join(format!("{name}.{extension}")))?;
        ensure!(
            self.loader()?
                .resolve(&self.base, &path.to_string_lossy())
                .is_err(),
            "{} existiert bereits",
            path.display()
        );
        let id = DocumentId(next_id());
        self.documents.insert(
            id,
            Document {
                source_path: path.clone(),
                path: None,
                content,
                revision: 0,
                saved: None,
                disk: None,
                binary_source: None,
                warnings: Vec::new(),
                undo: Vec::new(),
                design_ids: BTreeMap::new(),
            },
        );
        self.manifest.lines.push(ProjectLine::File(path));
        Ok(id)
    }
    pub fn new_module(&mut self, name: &str) -> Result<DocumentId> {
        self.create(name, Content::Text(String::new()), "bas")
    }
    pub fn new_form(&mut self, name: &str) -> Result<DocumentId> {
        let tokens = tb_frontend::lexer::lex(name);
        ensure!(
            matches!(
                tokens.tokens.as_slice(),
                [
                    tb_frontend::lexer::Token {
                        kind: tb_frontend::lexer::TokenKind::Ident { suffix: None, .. },
                        ..
                    },
                    tb_frontend::lexer::Token {
                        kind: tb_frontend::lexer::TokenKind::Eol,
                        ..
                    },
                    tb_frontend::lexer::Token {
                        kind: tb_frontend::lexer::TokenKind::Eof,
                        ..
                    }
                ]
            ),
            "Ungültiger Formularname"
        );
        let root = FormNode {
            class: tb_frontend::forms::ObjectClass::Form,
            name: name.into(),
            properties: BTreeMap::new(),
            children: Vec::new(),
        };
        let mut root = root;
        for (property, value) in [("WIDTH", 40), ("HEIGHT", 15)] {
            let p = tb_frontend::forms::property(root.class, property)
                .unwrap()
                .0;
            root.properties
                .insert(p, tb_ui::forms::PropertyValue::Integer(value));
        }
        self.create(
            name,
            Content::Form(Box::new(FormFile::new("1.00", root, ""))),
            "frm",
        )
    }
    pub fn set_startup(&mut self, id: Option<DocumentId>) -> Result<()> {
        let path = id
            .map(|id| self.document(id).map(|d| d.source_path.clone()))
            .transpose()?;
        if let Some(path) = &path {
            ensure!(
                self.manifest.members().any(|p| p == path),
                "Startdatei ist kein Projektmitglied"
            );
        }
        self.manifest.startup = path;
        Ok(())
    }
    pub fn remove_file(&mut self, id: DocumentId, choice: Option<StartupRemoval>) -> Result<bool> {
        let path = self.document(id)?.source_path.clone();
        ensure!(
            self.members().contains(&id),
            "Datei ist kein Projektmitglied"
        );
        if self.startup() == Some(id) {
            match choice.ok_or_else(|| {
                anyhow!("Startdatei entfernen: Ersatz, Standard oder Abbrechen wählen")
            })? {
                StartupRemoval::Cancel => return Ok(false),
                StartupRemoval::Default => self.set_startup(None)?,
                StartupRemoval::Replace(other) => {
                    ensure!(other != id, "Ersatz muss ein anderes Mitglied sein");
                    self.set_startup(Some(other))?;
                }
            }
        }
        self.manifest
            .lines
            .retain(|line| !matches!(line,ProjectLine::File(p) if p==&path));
        // Geöffnete Ansichten und ungespeicherte Änderungen bleiben erhalten.
        Ok(true)
    }
    pub fn open_view(&mut self, document: DocumentId) -> Result<ViewId> {
        self.document(document)?;
        let id = ViewId(next_id());
        self.views.insert(
            id,
            View {
                document,
                cursor: 0,
                anchor: None,
                bookmarks: [None; 4],
                overwrite: false,
                scroll_line: 0,
                scroll_column: 0,
            },
        );
        Ok(id)
    }
    pub fn view(&self, id: ViewId) -> Result<&View> {
        self.views
            .get(&id)
            .ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))
    }
    pub fn view_mut(&mut self, id: ViewId) -> Result<&mut View> {
        self.views
            .get_mut(&id)
            .ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))
    }
    pub fn close_view(&mut self, id: ViewId) -> Result<()> {
        self.views
            .remove(&id)
            .ok_or_else(|| anyhow!("Ansicht nicht geöffnet"))?;
        Ok(())
    }
    pub fn view_code(&self, id: ViewId) -> Result<&str> {
        Ok(self.document(self.view(id)?.document)?.code())
    }
    pub fn design_ids(&mut self, id: DocumentId) -> Result<&BTreeMap<(String, Option<i32>), u64>> {
        self.document(id)?;
        let doc = self.documents.get_mut(&id).unwrap();
        let mut keys = Vec::new();
        if let Content::Form(f) = &doc.content {
            fn visit(n: &FormNode, keys: &mut Vec<(String, Option<i32>)>) {
                keys.push(crate::designer::node_key(n));
                for child in &n.children {
                    visit(child, keys);
                }
            }
            visit(&f.root, &mut keys);
        }
        doc.design_ids.retain(|k, _| keys.contains(k));
        for key in keys {
            doc.design_ids.entry(key).or_insert_with(next_id);
        }
        Ok(&doc.design_ids)
    }
    /// Alle Kandidaten zuerst prüfen; danach eine gemeinsame Undo-Einheit.
    pub(crate) fn commit_transaction(&mut self, changes: Vec<(DocumentId, Content)>) -> Result<()> {
        let mut ids = Vec::new();
        for (id, content) in &changes {
            let old = self.document(*id)?;
            ensure!(!ids.contains(id), "Doppeltes Dokument in Transaktion");
            if let Content::Form(f) = content {
                frm::read_text(&old.source_path.display().to_string(), &frm::write_text(f))?;
            }
            ids.push(*id);
        }
        let changes: Vec<_> = changes
            .into_iter()
            .filter(|(id, c)| self.documents[id].content != *c)
            .collect();
        let ids: Vec<_> = changes.iter().map(|(id, _)| *id).collect();
        let group = next_id();
        for (id, content) in changes {
            self.commit_edit(id, content)?;
            self.documents
                .get_mut(&id)
                .unwrap()
                .undo
                .last_mut()
                .unwrap()
                .group = Some((group, ids.clone()));
        }
        Ok(())
    }
    pub(crate) fn rekey_design_id(
        &mut self,
        id: DocumentId,
        old: &(String, Option<i32>),
        new: (String, Option<i32>),
        stable: u64,
    ) {
        let doc = self.documents.get_mut(&id).unwrap();
        doc.design_ids.remove(old);
        doc.design_ids.insert(new, stable);
    }
    pub(crate) fn rename_design_ids(
        &mut self,
        id: DocumentId,
        old: &str,
        new: &str,
        before: &BTreeMap<(String, Option<i32>), u64>,
    ) {
        let doc = self.documents.get_mut(&id).unwrap();
        for ((name, index), value) in before {
            if name.eq_ignore_ascii_case(old) {
                doc.design_ids.remove(&(name.clone(), *index));
                doc.design_ids.insert((new.to_uppercase(), *index), *value);
            }
        }
    }
    fn commit_edit(&mut self, id: DocumentId, content: Content) -> Result<()> {
        self.design_ids(id)?;
        let old = self.document(id)?;
        if old.content == content {
            return Ok(());
        }
        let before = old.code();
        let after = content.code();
        let prefix = before
            .chars()
            .zip(after.chars())
            .take_while(|(a, b)| a == b)
            .map(|(c, _)| c.len_utf8())
            .sum::<usize>();
        let suffix = before[prefix..]
            .chars()
            .rev()
            .zip(after[prefix..].chars().rev())
            .take_while(|(a, b)| a == b)
            .map(|(c, _)| c.len_utf8())
            .sum::<usize>();
        let removed = prefix..before.len() - suffix;
        let inserted = after.len() - prefix - suffix;
        let remap = |at: usize| {
            if at >= removed.end {
                at - removed.len() + inserted
            } else if at > removed.start {
                removed.start + (at - removed.start).min(inserted)
            } else {
                at
            }
        };
        let undo = Edit {
            content: old.content.clone(),
            design_ids: old.design_ids.clone(),
            group: None,
            views: self
                .views
                .iter()
                .filter(|(_, v)| v.document == id)
                .map(|(i, v)| (*i, v.clone()))
                .collect(),
        };
        let doc = self.documents.get_mut(&id).unwrap();
        doc.undo.push(undo);
        doc.content = content;
        doc.revision += 1;
        let code = doc.code();
        for view in self.views.values_mut().filter(|v| v.document == id) {
            view.cursor = boundary(code, remap(view.cursor));
            view.anchor = view.anchor.map(|at| boundary(code, remap(at)));
            view.bookmarks = view
                .bookmarks
                .map(|at| at.map(|at| boundary(code, remap(at))));
        }
        Ok(())
    }
    pub fn replace_text(&mut self, id: DocumentId, range: Range<usize>, text: &str) -> Result<()> {
        self.replace_ranges(id, &[(range, text.to_owned())])
    }
    /// Geordnete, disjunkte Ersetzungen: eine Undo-Einheit und positionsgetreue Ansichten.
    pub fn replace_ranges(
        &mut self,
        id: DocumentId,
        edits: &[(Range<usize>, String)],
    ) -> Result<()> {
        let mut content = self.document(id)?.content.clone();
        let mut end = 0;
        for (range, _) in edits {
            ensure!(
                range.start >= end
                    && range.start <= range.end
                    && content.code().get(range.clone()).is_some(),
                "Ungültiger Textbereich"
            );
            end = range.end;
        }
        let views: Vec<_> = self
            .views
            .iter()
            .filter(|(_, v)| v.document == id)
            .map(|(id, v)| (*id, v.clone()))
            .collect();
        for (range, text) in edits.iter().rev() {
            content.code_mut().replace_range(range.clone(), text);
        }
        self.commit_edit(id, content)?;
        for (view_id, old) in views {
            let map = |mut at: usize| {
                for (range, text) in edits.iter().rev() {
                    if at > range.start {
                        at = if at >= range.end {
                            at - range.len() + text.len()
                        } else {
                            range.start + (at - range.start).min(text.len())
                        };
                    }
                }
                boundary(self.documents[&id].code(), at)
            };
            let cursor = map(old.cursor);
            let anchor = old.anchor.map(map);
            let bookmarks = old.bookmarks.map(|p| p.map(map));
            let view = self.views.get_mut(&view_id).unwrap();
            view.cursor = cursor;
            view.anchor = anchor;
            view.bookmarks = bookmarks;
        }
        Ok(())
    }
    pub fn edit_form(
        &mut self,
        id: DocumentId,
        edit: impl FnOnce(&mut FormFile) -> Result<()>,
    ) -> Result<()> {
        let Content::Form(mut form) = self.document(id)?.content.clone() else {
            bail!("Kein Formulardokument");
        };
        edit(&mut form)?;
        frm::read_text(
            &self.document(id)?.source_path.display().to_string(),
            &frm::write_text(&form),
        )?;
        self.commit_edit(id, Content::Form(form))
    }
    pub fn undo(&mut self, id: DocumentId) -> Result<bool> {
        let group = self.document(id)?.undo.last().and_then(|e| e.group.clone());
        if let Some((number, ids)) = group {
            ensure!(
                ids.iter().all(|id| self.documents[id]
                    .undo
                    .last()
                    .and_then(|e| e.group.as_ref())
                    .is_some_and(|g| g.0 == number)),
                "Spätere Änderungen in verbundenen Dokumenten zuerst rückgängig machen"
            );
            for id in ids {
                self.undo_one(id)?;
            }
            return Ok(true);
        }
        self.undo_one(id)
    }
    fn undo_one(&mut self, id: DocumentId) -> Result<bool> {
        self.document(id)?;
        let doc = self.documents.get_mut(&id).unwrap();
        let Some(edit) = doc.undo.pop() else {
            return Ok(false);
        };
        doc.content = edit.content;
        doc.design_ids = edit.design_ids;
        doc.revision += 1;
        for (id, view) in edit.views {
            if let Some(current) = self.views.get_mut(&id) {
                *current = view;
            }
        }
        let code = doc.code();
        for view in self.views.values_mut().filter(|v| v.document == id) {
            view.cursor = boundary(code, view.cursor);
            view.anchor = view.anchor.map(|p| boundary(code, p));
            view.bookmarks = view.bookmarks.map(|p| p.map(|p| boundary(code, p)));
        }
        Ok(true)
    }
    pub fn load_text(&mut self, id: DocumentId, at: usize, path: &Path) -> Result<()> {
        let text = fs::read_to_string(path).with_context(|| format!("{} lesen", path.display()))?;
        self.replace_text(id, at..at, &text)
    }
    pub fn save_text(
        &self,
        id: DocumentId,
        range: Option<Range<usize>>,
        target: &Destination,
    ) -> Result<()> {
        let doc = self.document(id)?;
        let text = match range {
            Some(r) => doc
                .code()
                .get(r)
                .ok_or_else(|| anyhow!("Ungültiger Textbereich"))?,
            None => doc.code(),
        };
        let path = self.target(&target.path)?;
        ensure!(
            self.documents.values().all(|d| d.source_path != path),
            "Save Text darf kein geöffnetes Dokument überschreiben; Save File verwenden"
        );
        self.protect_binary(&path)?;
        ensure!(
            self.path.as_deref() != Some(&path),
            "Save Text darf die Projektdatei nicht überschreiben"
        );
        atomic_write(&path, text.as_bytes(), None, target.overwrite)
    }
    fn target(&self, path: &Path) -> Result<PathBuf> {
        ensure!(!path.as_os_str().is_empty(), "Ausgabepfad fehlt");
        let target = identity(&self.base.join(path))?;
        ensure!(
            target
                .to_str()
                .is_some_and(|s| !s.contains(['\n', '\r', '\0'])),
            "Ausgabepfad ist nicht als UTF-8-Dateiverweis darstellbar"
        );
        Ok(target)
    }
    /// Gemeinsame Zielprüfung für die Exportdialoge; schreibt keine Datei.
    pub fn validate_export_target(&self, destination: &Destination) -> Result<PathBuf> {
        let path = self.target(&destination.path)?;
        ensure!(
            path.parent().is_some_and(Path::is_dir),
            "Zielverzeichnis fehlt"
        );
        ensure!(
            self.path.as_deref() != Some(&path),
            "Projektdatei ist kein Exportziel"
        );
        ensure!(
            self.documents.values().all(|d| d.source_path != path),
            "Geöffnetes Dokument ist kein Exportziel"
        );
        self.protect_binary(&path)?;
        check_destination(&path, None, destination.overwrite)?;
        Ok(path)
    }
    fn protect_binary(&self, path: &Path) -> Result<()> {
        ensure!(
            !fs::read(path).is_ok_and(|b| b.starts_with(&[0xfc, 0x08, 1, 0])),
            "{}: binäre FRM-Quelle ist geschützt; anderes Textziel wählen",
            path.display()
        );
        ensure!(
            !self
                .documents
                .values()
                .any(|d| d.binary_source.as_deref() == Some(path)),
            "{}: binäre FRM-Quelle ist geschützt; anderes Textziel wählen",
            path.display()
        );
        Ok(())
    }
    fn file_target(&self, id: DocumentId, target: Option<&Destination>) -> Result<(PathBuf, bool)> {
        let doc = self.document(id)?;
        let path = match target {
            Some(t) => self.target(&t.path)?,
            None => doc.path.clone().ok_or_else(|| {
                anyhow!("{}: Save-As-Ziel erforderlich", doc.source_path.display())
            })?,
        };
        self.protect_binary(&path)?;
        ensure!(
            self.path.as_deref() != Some(&path),
            "Dokument darf die Projektdatei nicht überschreiben"
        );
        ensure!(
            !self
                .documents
                .iter()
                .any(|(other, d)| *other != id && d.source_path == path),
            "{} ist in einem anderen Dokument geöffnet",
            path.display()
        );
        match &doc.content {
            Content::Form(_) => ensure!(has_extension(&path, "frm"), "Formularziel muss .frm sein"),
            Content::Text(_) if self.members().contains(&id) => {
                ensure!(has_extension(&path, "bas"), "Modulziel muss .bas sein")
            }
            Content::Text(_) => ensure!(
                !has_extension(&path, "frm") && !has_extension(&path, "mak"),
                "Textziel darf kein FRM/MAK sein"
            ),
        }
        Ok((path, target.is_some_and(|t| t.overwrite)))
    }
    pub fn save_file(&mut self, id: DocumentId, target: Option<&Destination>) -> Result<()> {
        let (path, overwrite) = self.file_target(id, target)?;
        let doc = self.document(id)?;
        let mut content = loaded(
            self.loader()?
                .relocate(&doc.content, &doc.source_path, &path),
        )?;
        let prospective = |code: &str| -> std::result::Result<ProjectSources, String> {
            let mut loader = self.loader().map_err(|e| e.to_string())?;
            let mut candidate = content.clone();
            *candidate.code_mut() = code.to_owned();
            loader.insert(&path, candidate)?;
            let mut manifest = self.manifest.clone();
            manifest.rename(&doc.source_path, &path);
            // Includes werden im Kontext ihrer Verbraucher geprüft.
            loader.load_manifest(manifest)
        };
        let (code, notice) = tb_frontend::editing::maintain_declarations_with(
            content.code(),
            |code| {
                let input = prospective(code)?;
                let unit = input
                    .units
                    .iter()
                    .find(|u| {
                        u.segments
                            .iter()
                            .any(|s| s.file == path.display().to_string())
                    })
                    .ok_or_else(|| "Keine Übersetzungseinheit für dieses Dokument".to_owned())?;
                let mut sources = Vec::new();
                let (mut module, mut diagnostics) = unit.parse(0, &mut sources);
                tb_frontend::source::locate_diagnostics(&mut diagnostics, &sources);
                if let Some(d) = diagnostics.first() {
                    return Err(d.to_string());
                }
                // Nur Prozeduren dieses physischen Dokuments pflegen, nicht die eines Includes.
                module.procs.retain(|p| {
                    sources
                        .get(p.pos.source as usize)
                        .is_some_and(|s| s.path == path.display().to_string())
                });
                Ok(module)
            },
            |code| {
                let input = prospective(code)?;
                let mut catalog = tb_frontend::forms::FormCatalog::default();
                for form in &input.forms {
                    catalog.append(&form.catalog());
                }
                tb_vm::compile_project("SAVE", &input.units, &catalog, &input.forms)
                    .map(|_| ())
                    .map_err(|d| {
                        d.iter()
                            .map(ToString::to_string)
                            .collect::<Vec<_>>()
                            .join("; ")
                    })
            },
        );
        *content.code_mut() = code;
        let text = content.text();
        let expected = if doc.path.as_deref() == Some(&path) {
            doc.disk.as_deref()
        } else {
            None
        };
        atomic_write(&path, text.as_bytes(), expected, overwrite)?;
        self.save_notices = notice
            .into_iter()
            .map(|n| format!("{}: {n}", path.display()))
            .collect();
        self.commit_edit(id, content)?;
        let doc = self.documents.get_mut(&id).unwrap();
        self.manifest.rename(&doc.source_path, &path);
        if doc.source_path != path {
            doc.revision += 1;
        }
        doc.source_path = path.clone();
        doc.path = Some(path);
        doc.disk = Some(text.into_bytes());
        doc.saved = Some(doc.content.clone());
        Ok(())
    }
    pub fn save_project(&mut self, plan: &SavePlan) -> Result<()> {
        let target = match &plan.project {
            Some(t) => self.target(&t.path)?,
            None => self
                .path
                .clone()
                .ok_or_else(|| anyhow!("Projekt-Save-As-Ziel erforderlich"))?,
        };
        ensure!(has_extension(&target, "mak"), "Projektziel muss .mak sein");
        self.protect_binary(&target)?;
        ensure!(
            self.documents.values().all(|d| d.source_path != target),
            "Projektziel ist ein geöffnetes Dokument"
        );
        for id in plan.files.keys() {
            self.document(*id)?;
        }
        let mut ids = self.members();
        ids.extend(
            self.documents
                .keys()
                .filter(|id| !self.members().contains(id))
                .copied(),
        );
        let ids: Vec<_> = ids
            .into_iter()
            .filter(|id| self.documents[id].is_dirty() || plan.files.contains_key(id))
            .collect();
        let mut destinations = Vec::new();
        let mut prospective = self.manifest.clone();
        for id in &ids {
            let (path, _) = self.file_target(*id, plan.files.get(id))?;
            ensure!(
                !destinations.contains(&path) && path != target,
                "Mehrere Ausgaben verwenden {}",
                path.display()
            );
            prospective.rename(&self.documents[id].source_path, &path);
            destinations.push(path);
        }
        let text = loaded(prospective.text(&target))?; // Vollständig serialisierbar, bevor eine Datei geschrieben wird.
        let expected = if self.path.as_deref() == Some(&target) {
            self.manifest_disk.as_deref()
        } else {
            None
        };
        check_destination(
            &target,
            expected,
            plan.project.as_ref().is_some_and(|d| d.overwrite),
        )?;
        let mut notices = Vec::new();
        for id in ids {
            let result = self.save_file(id, plan.files.get(&id));
            notices.extend(self.save_notices.clone());
            self.save_notices = notices.clone();
            result?;
        }
        // Zweite Konfliktprüfung direkt vor Ersetzen; ein Teilfehler lässt die alte Projektidentität aktiv.
        let expected = if self.path.as_deref() == Some(&target) {
            self.manifest_disk.as_deref()
        } else {
            None
        };
        atomic_write(
            &target,
            text.as_bytes(),
            expected,
            plan.project.as_ref().is_some_and(|d| d.overwrite),
        )?;
        self.manifest_disk = Some(text.into_bytes());
        self.saved_manifest = Some(self.manifest.clone());
        self.base = target.parent().unwrap().into();
        self.path = Some(target);
        Ok(())
    }
    /// Vor Exit/Projektwechsel aufrufen; der Aufrufer schließt erst bei true.
    pub fn prepare_close(&mut self, decision: Decision<'_>) -> Result<bool> {
        match decision {
            Decision::Cancel => Ok(false),
            Decision::Discard => Ok(true),
            Decision::Save(plan) => {
                self.save_project(plan)?;
                ensure!(!self.is_dirty(), "Nicht alle Änderungen gespeichert");
                Ok(true)
            }
        }
    }
    pub fn open_project(&mut self, path: &Path, decision: Decision<'_>) -> Result<bool> {
        if matches!(decision, Decision::Cancel) {
            return Ok(false);
        }
        let path = self.base.join(path);
        let candidate = Self::open(&path, self.include_paths.clone())?;
        let saving = matches!(decision, Decision::Save(_));
        if !self.prepare_close(decision)? {
            return Ok(false);
        }
        *self = if saving {
            Self::open(&path, self.include_paths.clone())?
        } else {
            candidate
        };
        Ok(true)
    }
    pub fn new_project(&mut self, decision: Decision<'_>) -> Result<bool> {
        let mut candidate = Self::new(&self.base)?;
        candidate.include_paths = self.include_paths.clone();
        if !self.prepare_close(decision)? {
            return Ok(false);
        }
        *self = candidate;
        Ok(true)
    }
}

fn boundary(text: &str, at: usize) -> usize {
    let mut at = at.min(text.len());
    while !text.is_char_boundary(at) {
        at -= 1;
    }
    at
}

fn check_destination(path: &Path, expected: Option<&[u8]>, overwrite: bool) -> Result<()> {
    let actual = match fs::read(path) {
        Ok(bytes) => Some(bytes),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
        Err(e) => return Err(e).with_context(|| format!("{} lesen", path.display())),
    };
    ensure!(
        overwrite || actual.as_deref() == expected,
        "{}: Datei extern geändert oder Ziel existiert; Überschreibentscheidung erforderlich",
        path.display()
    );
    if let Ok(metadata) = fs::metadata(path) {
        ensure!(
            !metadata.permissions().readonly(),
            "{}: Datei ist schreibgeschützt",
            path.display()
        );
    }
    Ok(())
}

/// Ersetzt erst nach erfolgreichem Schreiben und flush/sync; temporäre Datei im selben Verzeichnis.
pub(crate) fn atomic_write(
    path: &Path,
    bytes: &[u8],
    expected: Option<&[u8]>,
    overwrite: bool,
) -> Result<()> {
    check_destination(path, expected, overwrite)?;
    let parent = path
        .parent()
        .ok_or_else(|| anyhow!("Ungültiges Dateiziel"))?;
    let (temporary, mut file) = loop {
        let p = parent.join(format!(".tb-save-{}-{}.tmp", std::process::id(), next_id()));
        match OpenOptions::new().write(true).create_new(true).open(&p) {
            Ok(file) => break (p, file),
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
            Err(e) => return Err(e).with_context(|| format!("{} speichern", path.display())),
        }
    };
    struct Cleanup(PathBuf);
    impl Drop for Cleanup {
        fn drop(&mut self) {
            let _ = fs::remove_file(&self.0);
        }
    }
    let cleanup = Cleanup(temporary);
    let result = (|| -> Result<()> {
        file.write_all(bytes)?;
        if let Ok(metadata) = fs::metadata(path) {
            file.set_permissions(metadata.permissions())?;
        }
        file.sync_all()?;
        drop(file);
        check_destination(path, expected, overwrite)?;
        fs::rename(&cleanup.0, path)?;
        Ok(())
    })();
    result.with_context(|| format!("{} speichern", path.display()))
}
