"""HiveMind Assistent — Aufgabenplaner und Projektmanager.

Zwei Kernfunktionen:
  1. **Scheduler**: Wiederkehrende Jobs (Telegram-Ping, E-Mail-Check, Projekt-
     Durchlauf) in festen Intervallen — gespeichert in data/assistant/scheduler.json

  2. **Projekte**: Strukturierte Aufgabenlisten, die Schritt für Schritt vom
     Modell abgearbeitet werden. Nach jedem Schritt bewertet das Modell, ob die
     Schritt-Liste angepasst werden soll — gespeichert in
     data/assistant/projects/<id>.json

Verfügbare Job-Actions:
  - telegram_ping   : Sendet eine Nachricht via Telegram
  - briefing        : Modell beantwortet einen Prompt und sendet Antwort via Telegram
  - project_run     : Arbeitet den nächsten offenen Schritt eines Projekts ab
  - email_check     : (Platzhalter — zukünftig via IMAP)
"""
from __future__ import annotations

import asyncio
import json
import logging
import re
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
    from hivemind.node import Node

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Datenstrukturen
# ---------------------------------------------------------------------------

@dataclass
class ProjectStep:
    id: str
    title: str
    status: str = "pending"       # pending | done | skipped
    order: int = 0
    notes: str = ""               # optionale Hinweise / Vorgaben für das Modell
    result: str = ""              # Ergebnis nach Verarbeitung durch das Modell
    completed_at: str = ""

    @classmethod
    def new(cls, title: str, order: int = 0, notes: str = "") -> "ProjectStep":
        return cls(id=str(uuid.uuid4())[:8], title=title, order=order, notes=notes)


@dataclass
class Project:
    id: str
    name: str
    description: str = ""
    status: str = "active"        # active | paused | done
    auto_update_steps: bool = True  # Modell darf Schritte anpassen
    work_dir: str = ""              # Arbeitsverzeichnis für Datei-Ausgaben
    created_at: str = field(default_factory=lambda: _now())
    updated_at: str = field(default_factory=lambda: _now())
    steps: list[ProjectStep] = field(default_factory=list)

    @classmethod
    def new(cls, name: str, description: str = "", steps: list[str] | None = None,
            auto_update_steps: bool = True, work_dir: str = "") -> "Project":
        proj = cls(
            id=str(uuid.uuid4())[:12],
            name=name,
            description=description,
            auto_update_steps=auto_update_steps,
            work_dir=work_dir,
        )
        for i, title in enumerate(steps or []):
            proj.steps.append(ProjectStep.new(title, order=i))
        return proj

    @property
    def next_pending_step(self) -> ProjectStep | None:
        pending = [s for s in self.steps if s.status == "pending"]
        if not pending:
            return None
        return sorted(pending, key=lambda s: s.order)[0]

    @property
    def progress(self) -> dict:
        total = len(self.steps)
        done  = sum(1 for s in self.steps if s.status == "done")
        return {"total": total, "done": done, "pending": total - done,
                "percent": round(done / total * 100) if total else 0}

    def to_dict(self) -> dict:
        d = asdict(self)
        d["steps"] = [asdict(s) for s in self.steps]
        return d

    @classmethod
    def from_dict(cls, d: dict) -> "Project":
        steps = [ProjectStep(**s) for s in d.pop("steps", [])]
        proj = cls(**d)
        proj.steps = steps
        return proj


@dataclass
class SchedulerJob:
    id: str
    name: str
    action: str               # telegram_ping | project_run | email_check
    params: dict = field(default_factory=dict)
    interval_minutes: int = 60
    enabled: bool = True
    last_run: str = ""
    next_run: str = ""
    created_at: str = field(default_factory=lambda: _now())

    @classmethod
    def new(cls, name: str, action: str, params: dict | None = None,
            interval_minutes: int = 60) -> "SchedulerJob":
        job = cls(
            id=str(uuid.uuid4())[:12],
            name=name,
            action=action,
            params=params or {},
            interval_minutes=interval_minutes,
        )
        job.next_run = _in_minutes(interval_minutes)
        return job

    def update_schedule(self) -> None:
        self.last_run = _now()
        self.next_run = _in_minutes(self.interval_minutes)

    def is_due(self) -> bool:
        if not self.enabled:
            return False
        if not self.next_run:
            return True
        try:
            return datetime.fromisoformat(self.next_run) <= datetime.now()
        except ValueError:
            return True

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, d: dict) -> "SchedulerJob":
        return cls(**d)


# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------

def _now() -> str:
    return datetime.now().isoformat(timespec="seconds")


def _in_minutes(minutes: int) -> str:
    return (datetime.now() + timedelta(minutes=minutes)).isoformat(timespec="seconds")


# ---------------------------------------------------------------------------
# AssistantManager
# ---------------------------------------------------------------------------

class AssistantManager:
    """Verwaltet Scheduler-Jobs und Projekte."""

    def __init__(self, node: "Node") -> None:
        self.node = node
        self._base   = Path(node.base_dir) / "assistant"
        self._proj_dir = self._base / "projects"
        self._sched_file = self._base / "scheduler.json"
        self._base.mkdir(parents=True, exist_ok=True)
        self._proj_dir.mkdir(parents=True, exist_ok=True)

        self._jobs: dict[str, SchedulerJob] = {}
        self._projects: dict[str, Project] = {}
        self._task: asyncio.Task | None = None

        self._load_scheduler()
        self._load_projects()

    # ------------------------------------------------------------------
    # Persistenz
    # ------------------------------------------------------------------

    def _load_scheduler(self) -> None:
        if self._sched_file.exists():
            try:
                data = json.loads(self._sched_file.read_text(encoding="utf-8"))
                for d in data.get("jobs", []):
                    job = SchedulerJob.from_dict(d)
                    self._jobs[job.id] = job
                log.info("Scheduler: %d Jobs geladen.", len(self._jobs))
            except Exception as e:
                log.warning("Scheduler-Datei konnte nicht geladen werden: %s", e)

    def _save_scheduler(self) -> None:
        data = {"jobs": [j.to_dict() for j in self._jobs.values()]}
        self._sched_file.write_text(
            json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
        )

    def _load_projects(self) -> None:
        for f in self._proj_dir.glob("*.json"):
            try:
                d = json.loads(f.read_text(encoding="utf-8"))
                proj = Project.from_dict(d)
                self._projects[proj.id] = proj
            except Exception as e:
                log.warning("Projekt-Datei %s konnte nicht geladen werden: %s", f, e)
        log.info("Projekte: %d geladen.", len(self._projects))

    def _save_project(self, proj: Project) -> None:
        proj.updated_at = _now()
        path = self._proj_dir / f"{proj.id}.json"
        path.write_text(
            json.dumps(proj.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8"
        )

    # ------------------------------------------------------------------
    # Scheduler-CRUD
    # ------------------------------------------------------------------

    def list_jobs(self) -> list[dict]:
        return [j.to_dict() for j in self._jobs.values()]

    def get_job(self, job_id: str) -> SchedulerJob | None:
        return self._jobs.get(job_id)

    def add_job(self, name: str, action: str, params: dict | None = None,
                interval_minutes: int = 60) -> SchedulerJob:
        job = SchedulerJob.new(name, action, params, interval_minutes)
        self._jobs[job.id] = job
        self._save_scheduler()
        log.info("Job erstellt: %s (%s)", job.name, job.id)
        return job

    def update_job(self, job_id: str, **kwargs) -> SchedulerJob | None:
        job = self._jobs.get(job_id)
        if not job:
            return None
        for k, v in kwargs.items():
            if hasattr(job, k):
                setattr(job, k, v)
        if "interval_minutes" in kwargs:
            job.next_run = _in_minutes(job.interval_minutes)
        self._save_scheduler()
        return job

    def delete_job(self, job_id: str) -> bool:
        if job_id not in self._jobs:
            return False
        del self._jobs[job_id]
        self._save_scheduler()
        return True

    # ------------------------------------------------------------------
    # Projekt-CRUD
    # ------------------------------------------------------------------

    def list_projects(self) -> list[dict]:
        result = []
        for proj in self._projects.values():
            d = proj.to_dict()
            d["progress"] = proj.progress
            result.append(d)
        return result

    def get_project(self, proj_id: str) -> Project | None:
        return self._projects.get(proj_id)

    def create_project(self, name: str, description: str = "",
                       steps: list[str] | None = None,
                       auto_update_steps: bool = True,
                       work_dir: str = "") -> Project:
        proj = Project.new(name, description, steps, auto_update_steps, work_dir)
        self._projects[proj.id] = proj
        self._save_project(proj)
        log.info("Projekt erstellt: %s (%s)", proj.name, proj.id)
        return proj

    def update_project(self, proj_id: str, **kwargs) -> Project | None:
        proj = self._projects.get(proj_id)
        if not proj:
            return None
        for k, v in kwargs.items():
            if hasattr(proj, k) and k not in ("steps", "id", "created_at"):
                setattr(proj, k, v)
        self._save_project(proj)
        return proj

    def delete_project(self, proj_id: str) -> bool:
        if proj_id not in self._projects:
            return False
        path = self._proj_dir / f"{proj_id}.json"
        if path.exists():
            path.unlink()
        del self._projects[proj_id]
        return True

    def add_step(self, proj_id: str, title: str, notes: str = "",
                 after_id: str | None = None) -> ProjectStep | None:
        """Fügt einen Schritt hinzu — am Ende oder nach after_id."""
        proj = self._projects.get(proj_id)
        if not proj:
            return None
        step = ProjectStep.new(title, notes=notes)
        if after_id:
            idx = next((i for i, s in enumerate(proj.steps) if s.id == after_id), None)
            if idx is not None:
                # Schritt nach after_id einschieben, order neu vergeben
                proj.steps.insert(idx + 1, step)
            else:
                proj.steps.append(step)
        else:
            step.order = len(proj.steps)
            proj.steps.append(step)
        _renumber_steps(proj)
        self._save_project(proj)
        return step

    def update_step(self, proj_id: str, step_id: str, **kwargs) -> ProjectStep | None:
        proj = self._projects.get(proj_id)
        if not proj:
            return None
        step = next((s for s in proj.steps if s.id == step_id), None)
        if not step:
            return None
        for k, v in kwargs.items():
            if hasattr(step, k) and k != "id":
                setattr(step, k, v)
        self._save_project(proj)
        return step

    def delete_step(self, proj_id: str, step_id: str) -> bool:
        proj = self._projects.get(proj_id)
        if not proj:
            return False
        before = len(proj.steps)
        proj.steps = [s for s in proj.steps if s.id != step_id]
        if len(proj.steps) == before:
            return False
        _renumber_steps(proj)
        self._save_project(proj)
        return True

    def reorder_steps(self, proj_id: str, step_ids: list[str]) -> bool:
        """Ordnet Schritte gemäß step_ids-Liste (vollständige neue Reihenfolge)."""
        proj = self._projects.get(proj_id)
        if not proj:
            return False
        id_to_step = {s.id: s for s in proj.steps}
        ordered = [id_to_step[sid] for sid in step_ids if sid in id_to_step]
        # Fehlende Schritte anhängen
        missing = [s for s in proj.steps if s.id not in step_ids]
        proj.steps = ordered + missing
        _renumber_steps(proj)
        self._save_project(proj)
        return True

    # ------------------------------------------------------------------
    # Job-Ausführung
    # ------------------------------------------------------------------

    async def run_job(self, job: SchedulerJob) -> str:
        """Führt einen einzelnen Job aus, gibt Statusmeldung zurück."""
        log.info("Führe Job aus: %s (%s)", job.name, job.action)
        try:
            if job.action == "telegram_ping":
                result = await self._action_telegram_ping(job.params)
            elif job.action == "briefing":
                result = await self._action_briefing(job.params)
            elif job.action == "project_run":
                result = await self._action_project_run(job.params)
            elif job.action == "email_check":
                result = await self._action_email_check(job.params)
            else:
                result = f"Unbekannte Action: {job.action}"
            job.update_schedule()
            self._save_scheduler()
            return result
        except Exception as e:
            log.error("Job '%s' fehlgeschlagen: %s", job.name, e)
            job.update_schedule()
            self._save_scheduler()
            return f"Fehler: {e}"

    async def run_job_by_id(self, job_id: str) -> str:
        job = self._jobs.get(job_id)
        if not job:
            return "Job nicht gefunden."
        return await self.run_job(job)

    # ------------------------------------------------------------------
    # Actions
    # ------------------------------------------------------------------

    async def _action_telegram_ping(self, params: dict) -> str:
        """Sendet eine Telegram-Nachricht an alle erlaubten User."""
        message = params.get("message", "Hey, alles klar bei dir? Was kann ich gerade für dich tun?")
        gw = getattr(self.node, "_telegram_gateway", None)
        if not gw or not gw.running:
            return "Telegram-Gateway nicht verfügbar."
        sent = 0
        for uid in self.node.config.gateway.telegram.allowed_users:
            try:
                await gw._app.bot.send_message(chat_id=uid, text=message)
                sent += 1
            except Exception as e:
                log.warning("Telegram-Ping an %s fehlgeschlagen: %s", uid, e)
        return f"Telegram-Ping gesendet an {sent} User(n)."

    async def _action_briefing(self, params: dict) -> str:
        """Führt einen Prompt durch das Modell und sendet das Ergebnis via Telegram."""
        prompt = params.get("prompt", "").strip()
        if not prompt:
            return "Briefing-Job: kein Prompt konfiguriert."

        if not self.node.model or not self.node.model.loaded:
            return "Briefing-Job: Kein Modell geladen."

        # Aktuelles Datum/Uhrzeit in den Prompt einbetten
        now = datetime.now()
        date_str = now.strftime("%A, %d. %B %Y, %H:%M Uhr")

        # Vollständiger Prompt mit Datum als Kontext
        full_prompt = f"[Aktuelles Datum & Uhrzeit: {date_str}]\n\n{prompt}"
        log.info("Briefing-Job: führe Prompt aus: %s", prompt[:80])

        try:
            response, _meta = await self.node.chat(full_prompt, no_cache=True)
        except Exception as e:
            log.error("Briefing-Job: Modell-Fehler: %s", e)
            return f"Briefing-Job: Fehler beim Modell-Aufruf: {e}"

        # Via Telegram senden
        gw = getattr(self.node, "_telegram_gateway", None)
        if not gw or not gw.running:
            log.warning("Briefing-Job: Ergebnis generiert, aber Telegram nicht verfügbar.")
            return f"Briefing erstellt (Telegram nicht verbunden):\n{response[:200]}..."

        # Nachricht in Telegram-Chunks aufteilen (max. 4096 Zeichen)
        TMAX = 4000
        header = f"📋 *{now.strftime('%d.%m.%Y %H:%M')}*\n\n"
        full_text = header + response
        chunks = [full_text[i:i+TMAX] for i in range(0, max(len(full_text), 1), TMAX)]

        sent = 0
        for uid in self.node.config.gateway.telegram.allowed_users:
            try:
                for chunk in chunks:
                    await gw._app.bot.send_message(
                        chat_id=uid,
                        text=chunk,
                        parse_mode="Markdown",
                    )
                sent += 1
            except Exception as e:
                # Markdown-Fehler: nochmal ohne Formatierung
                try:
                    for chunk in chunks:
                        await gw._app.bot.send_message(chat_id=uid, text=chunk)
                    sent += 1
                except Exception as e2:
                    log.warning("Briefing-Telegram an %s fehlgeschlagen: %s", uid, e2)
        return f"Briefing gesendet an {sent} User(n). ({len(response)} Zeichen)"

    async def _action_project_run(self, params: dict) -> str:
        """Arbeitet den nächsten offenen Schritt eines Projekts ab."""
        proj_id = params.get("project_id")
        if not proj_id:
            return "Keine project_id angegeben."
        return await self.run_project_step(proj_id)

    async def _action_email_check(self, params: dict) -> str:
        """Platzhalter für zukünftigen E-Mail-Check via IMAP."""
        return "E-Mail-Check noch nicht implementiert."

    # ------------------------------------------------------------------
    # Projektschritt-Verarbeitung
    # ------------------------------------------------------------------

    async def run_project_step(self, proj_id: str) -> str:
        """Verarbeitet den nächsten offenen Schritt eines Projekts mit dem Modell."""
        proj = self._projects.get(proj_id)
        if not proj:
            return "Projekt nicht gefunden."
        if proj.status != "active":
            return f"Projekt ist nicht aktiv (Status: {proj.status})."

        step = proj.next_pending_step
        if not step:
            proj.status = "done"
            self._save_project(proj)
            return f"Projekt '{proj.name}' abgeschlossen — alle Schritte erledigt."

        log.info("Projekt '%s': bearbeite Schritt '%s'", proj.name, step.title)

        # Kontext für das Modell aufbauen
        context = self._build_project_context(proj, step)
        messages = [
            {"role": "system", "content": context["system"]},
            {"role": "user",   "content": context["user"]},
        ]

        if not self.node.model.loaded:
            return "Modell nicht geladen — Schritt kann nicht verarbeitet werden."

        loop = asyncio.get_event_loop()
        raw = await loop.run_in_executor(
            None,
            lambda: self.node.model.generate(messages, max_tokens=1024, temperature=0.7),
        )

        # Ergebnis parsen (Schritt-Ergebnis + optionale Schrittlisten-Anpassung + Dateien)
        step_result, step_changes, file_writes = _parse_model_response(raw)

        # Dateien ins Arbeitsverzeichnis schreiben
        if file_writes and proj.work_dir:
            written = _write_project_files(proj.work_dir, file_writes)
            if written:
                step_result += ("\n\n📁 Geschriebene Dateien:\n"
                                + "\n".join(f"  • {f}" for f in written))

        # Schritt als erledigt markieren
        step.status = "done"
        step.result = step_result
        step.completed_at = _now()

        # Schrittliste anpassen wenn gewünscht und Modell Vorschläge hat
        if proj.auto_update_steps and step_changes:
            _apply_step_changes(proj, step_changes)

        # Projekt fertig?
        if not proj.next_pending_step:
            proj.status = "done"

        self._save_project(proj)
        log.info("Projekt '%s': Schritt '%s' abgeschlossen.", proj.name, step.title)
        return (
            f"Schritt '{step.title}' abgeschlossen.\n\n"
            f"{step_result}"
            + (f"\n\n[Schrittliste angepasst: {len(step_changes)} Änderung(en)]"
               if step_changes else "")
        )

    def _build_project_context(self, proj: Project, step: ProjectStep) -> dict:
        """Erstellt den Prompt-Kontext für einen Projektschritt."""
        done_steps = [s for s in proj.steps if s.status == "done"]
        pending_steps = [s for s in proj.steps if s.status == "pending"]

        system = (
            f"Du bist ein KI-Assistent, der strukturiert an einem Projekt arbeitet.\n"
            f"Projekt: {proj.name}\n"
            f"Beschreibung: {proj.description}\n\n"
            f"Abgeschlossene Schritte ({len(done_steps)}):\n"
        )
        for s in done_steps:
            system += f"  ✓ {s.title}\n"
            if s.result:
                system += f"    Ergebnis: {s.result[:300]}{'...' if len(s.result) > 300 else ''}\n"

        system += f"\nAusstehende Schritte ({len(pending_steps)}):\n"
        for s in pending_steps:
            system += f"  • {s.title}" + (f" [{s.notes}]" if s.notes else "") + "\n"

        if proj.auto_update_steps:
            system += (
                "\n\nNach deiner Hauptantwort kannst du die Schrittliste anpassen. "
                "Nutze dafür am Ende einen optionalen Block im folgenden Format:\n"
                "---SCHRITTE---\n"
                "ADD_AFTER:<schritt_id>:<neuer titel>  (Schritt einschieben)\n"
                "ADD:<neuer titel>                     (Schritt ans Ende)\n"
                "REMOVE:<schritt_id>                   (Schritt entfernen, nur pending)\n"
                "---SCHRITTE_ENDE---\n"
                "Nutze diesen Block nur wenn es wirklich sinnvoll ist."
            )

        user = (
            f"Aktueller Schritt: {step.title}\n"
            + (f"Hinweise: {step.notes}\n" if step.notes else "")
            + "\nBitte bearbeite diesen Schritt vollständig."
        )

        return {"system": system, "user": user}

    # ------------------------------------------------------------------
    # Hintergrund-Scheduler
    # ------------------------------------------------------------------

    async def start(self) -> None:
        """Startet den Scheduler als Hintergrund-Task."""
        self._task = asyncio.create_task(self._scheduler_loop())
        log.info("Assistent-Scheduler gestartet.")

    async def stop(self) -> None:
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass

    async def _scheduler_loop(self) -> None:
        """Prüft jede Minute alle aktivierten Jobs auf Fälligkeit."""
        first = True
        while True:
            try:
                if first:
                    first = False
                    await asyncio.sleep(5)  # kurzer Start-Delay, dann sofort erstes Check
                else:
                    await asyncio.sleep(60)
                due = [j for j in self._jobs.values() if j.is_due()]
                for job in due:
                    log.info("Scheduler: Job fällig: %s", job.name)
                    job.update_schedule()
                    self._save_scheduler()
                    asyncio.create_task(self.run_job(job))
            except asyncio.CancelledError:
                break
            except Exception as e:
                log.error("Scheduler-Loop-Fehler: %s", e)
                await asyncio.sleep(10)

    # ------------------------------------------------------------------
    # Status
    # ------------------------------------------------------------------

    @property
    def status(self) -> dict:
        jobs = list(self._jobs.values())
        projs = list(self._projects.values())
        return {
            "jobs": {
                "total": len(jobs),
                "enabled": sum(1 for j in jobs if j.enabled),
                "due_now": sum(1 for j in jobs if j.is_due()),
            },
            "projects": {
                "total": len(projs),
                "active": sum(1 for p in projs if p.status == "active"),
                "done": sum(1 for p in projs if p.status == "done"),
            },
        }


# ---------------------------------------------------------------------------
# Hilfsfunktionen (intern)
# ---------------------------------------------------------------------------

def _renumber_steps(proj: Project) -> None:
    for i, step in enumerate(proj.steps):
        step.order = i


def _parse_model_response(raw: str) -> tuple[str, list[dict], list[tuple[str, str]]]:
    """Trennt Hauptantwort, SCHRITTE-Block und DATEI-Blöcke."""
    # DATEI-Blöcke extrahieren
    files: list[tuple[str, str]] = []

    def _extract_file(m: re.Match) -> str:
        filename = m.group(1).strip()
        content = m.group(2)
        # Führendes/tralendes Newline entfernen
        if content.startswith("\n"):
            content = content[1:]
        if content.endswith("\n"):
            content = content[:-1]
        files.append((filename, content))
        return ""

    text = re.sub(r"---DATEI:([^\n]+)---\n(.*?)---DATEI_ENDE---",
                  _extract_file, raw, flags=re.DOTALL)

    # SCHRITTE-Block extrahieren
    changes: list[dict] = []
    if "---SCHRITTE---" in text and "---SCHRITTE_ENDE---" in text:
        main, rest = text.split("---SCHRITTE---", 1)
        block, _ = rest.split("---SCHRITTE_ENDE---", 1)
        for line in block.strip().splitlines():
            line = line.strip()
            if line.startswith("ADD_AFTER:"):
                parts = line[len("ADD_AFTER:"):].split(":", 1)
                if len(parts) == 2:
                    changes.append({"action": "add_after", "after_id": parts[0].strip(),
                                    "title": parts[1].strip()})
            elif line.startswith("ADD:"):
                changes.append({"action": "add", "title": line[4:].strip()})
            elif line.startswith("REMOVE:"):
                changes.append({"action": "remove", "step_id": line[7:].strip()})
        return main.strip(), changes, files
    return text.strip(), changes, files


def _write_project_files(work_dir: str, files: list[tuple[str, str]]) -> list[str]:
    """Schreibt Dateien ins Arbeitsverzeichnis. Gibt Liste der geschriebenen Pfade zurück."""
    base = Path(work_dir)
    try:
        base.mkdir(parents=True, exist_ok=True)
    except Exception as e:
        log.warning("Arbeitsverzeichnis konnte nicht erstellt werden: %s", e)
        return []
    written: list[str] = []
    for filename, content in files:
        # Sicherheitsbeschränkung: keine absoluten Pfade oder ../ erlaubt
        safe = filename.replace("\\", "/").lstrip("/")
        if ".." in safe.split("/"):
            log.warning("Unsicherer Dateipfad abgelehnt: %s", filename)
            continue
        target = base / safe
        try:
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(content, encoding="utf-8")
            written.append(safe)
            log.info("Projektdatei geschrieben: %s", target)
        except Exception as e:
            log.error("Fehler beim Schreiben von %s: %s", target, e)
    return written


def _apply_step_changes(proj: Project, changes: list[dict]) -> None:
    """Wendet vom Modell vorgeschlagene Schrittänderungen an."""
    for change in changes:
        action = change.get("action")
        try:
            if action == "add":
                title = change.get("title", "").strip()
                if title:
                    step = ProjectStep.new(title, order=len(proj.steps))
                    proj.steps.append(step)
                    log.debug("Projekt '%s': Schritt hinzugefügt: %s", proj.name, title)
            elif action == "add_after":
                title = change.get("title", "").strip()
                after_id = change.get("after_id", "").strip()
                if title:
                    step = ProjectStep.new(title)
                    idx = next((i for i, s in enumerate(proj.steps) if s.id == after_id), None)
                    if idx is not None:
                        proj.steps.insert(idx + 1, step)
                    else:
                        proj.steps.append(step)
                    log.debug("Projekt '%s': Schritt nach %s eingefügt: %s",
                              proj.name, after_id, title)
            elif action == "remove":
                step_id = change.get("step_id", "").strip()
                before = len(proj.steps)
                # Nur pending-Schritte dürfen entfernt werden
                proj.steps = [s for s in proj.steps
                               if not (s.id == step_id and s.status == "pending")]
                if len(proj.steps) < before:
                    log.debug("Projekt '%s': Schritt %s entfernt.", proj.name, step_id)
        except Exception as e:
            log.warning("Schrittänderung fehlgeschlagen: %s — %s", change, e)
    _renumber_steps(proj)
