"""Core DiagramModel class for ActionDraw.

This module provides the main Qt model for diagram items and edges.
"""

from __future__ import annotations

import math
import os
import platform
import re
import subprocess
import urllib.parse
import webbrowser
from itertools import count
from typing import Any, Dict, List, Optional

from PySide6.QtCore import (
    QAbstractListModel,
    QModelIndex,
    Property,
    QUrl,
    Qt,
    Signal,
    Slot,
)

from .clipboard import ClipboardMixin
from .constants import ITEM_PRESETS
from .drawing import DrawingMixin
from .layout import LayoutMixin
from .markdown_note_tabs import first_tab_text, normalize_editor_tabs
from .types import DiagramEdge, DiagramItem, DiagramItemType, DrawingPoint, DrawingStroke


class DiagramModel(
    ClipboardMixin,
    DrawingMixin,
    LayoutMixin,
    QAbstractListModel,
):
    """Qt model exposing diagram items to QML."""

    IdRole = Qt.UserRole + 1
    TypeRole = Qt.UserRole + 2
    XRole = Qt.UserRole + 3
    YRole = Qt.UserRole + 4
    WidthRole = Qt.UserRole + 5
    HeightRole = Qt.UserRole + 6
    TextRole = Qt.UserRole + 7
    TaskIndexRole = Qt.UserRole + 8
    ColorRole = Qt.UserRole + 9
    TextColorRole = Qt.UserRole + 10
    TaskCompletedRole = Qt.UserRole + 11
    ImageDataRole = Qt.UserRole + 12
    TaskCurrentRole = Qt.UserRole + 13
    NoteMarkdownRole = Qt.UserRole + 14
    ObstacleMarkdownRole = Qt.UserRole + 15
    TaskCountdownRemainingRole = Qt.UserRole + 16
    TaskCountdownProgressRole = Qt.UserRole + 17
    TaskCountdownExpiredRole = Qt.UserRole + 18
    TaskCountdownActiveRole = Qt.UserRole + 19
    FolderPathRole = Qt.UserRole + 20
    LinkedSubtabCompletionRole = Qt.UserRole + 21
    LinkedSubtabActiveActionRole = Qt.UserRole + 22
    HasLinkedSubtabRole = Qt.UserRole + 23
    TaskReminderActiveRole = Qt.UserRole + 24
    TaskReminderAtRole = Qt.UserRole + 25
    TaskContractActiveRole = Qt.UserRole + 26
    TaskContractDeadlineRole = Qt.UserRole + 27
    TaskContractRemainingRole = Qt.UserRole + 28
    TaskContractBreachedRole = Qt.UserRole + 29
    TaskContractPunishmentRole = Qt.UserRole + 30
    TextTabsRole = Qt.UserRole + 31
    TextTabIndexRole = Qt.UserRole + 32

    itemsChanged = Signal()
    edgesChanged = Signal()
    drawingChanged = Signal()
    drawingModeChanged = Signal()
    brushColorChanged = Signal()
    brushWidthChanged = Signal()
    currentTaskChanged = Signal()

    def __init__(self, task_model=None, tab_model=None):
        super().__init__()
        self._items: List[DiagramItem] = []
        self._edges: List[DiagramEdge] = []
        self._current_task_index: int = -1
        self._edge_hover_target_id: str = ""
        self._drag_insert_edge_id: str = ""
        self._task_model = task_model
        self._tab_model = None
        self._id_source = count()
        self._edge_source_id: Optional[str] = None
        self._renaming_in_progress = False

        # Initialize mixins
        self._init_drawing()

        # Connect to task model's signals for bidirectional sync
        if self._task_model is not None:
            self._task_model.taskRenamed.connect(self.onTaskRenamed)
            self._task_model.taskCompletionChanged.connect(self.onTaskCompletionChanged)
            self._task_model.taskCountdownChanged.connect(self.onTaskCountdownChanged)
            self._task_model.taskReminderChanged.connect(self.onTaskReminderChanged)
            self._task_model.taskContractChanged.connect(self.onTaskContractChanged)

        self._edge_drag_x: float = 0.0
        self._edge_drag_y: float = 0.0
        self._is_dragging_edge: bool = False
        self.setTabModel(tab_model)

    def _next_id(self, prefix: str) -> str:
        return f"{prefix}_{next(self._id_source)}"

    def _build_item_from_preset(
        self,
        preset_name: str,
        x: float,
        y: float,
        text: Optional[str] = None,
    ) -> Optional[DiagramItem]:
        preset = ITEM_PRESETS.get(preset_name.lower())
        if not preset:
            return None

        label = text if text and text.strip() else preset["text"]
        item_id = self._next_id(preset_name.lower())
        return DiagramItem(
            id=item_id,
            item_type=preset["type"],
            x=x,
            y=y,
            width=float(preset["width"]),
            height=float(preset["height"]),
            text=label,
            color=str(preset["color"]),
            text_color=str(preset["text_color"]),
        )

    def _append_item(self, item: DiagramItem) -> None:
        self.beginInsertRows(QModelIndex(), len(self._items), len(self._items))
        self._items.append(item)
        self.endInsertRows()
        self.itemsChanged.emit()

    def _dimensions_for_connected_kind(self, item_kind: str) -> tuple[float, float]:
        kind = (item_kind or "").strip().lower()
        if kind == "task":
            return (140.0, 70.0)
        preset = ITEM_PRESETS.get(kind)
        if preset:
            return (float(preset.get("width", 120.0)), float(preset.get("height", 60.0)))
        return (120.0, 60.0)

    @staticmethod
    def _rectangles_overlap(
        ax: float,
        ay: float,
        aw: float,
        ah: float,
        bx: float,
        by: float,
        bw: float,
        bh: float,
    ) -> bool:
        return ax < bx + bw and ax + aw > bx and ay < by + bh and ay + ah > by

    def _position_overlaps_existing(self, x: float, y: float, width: float, height: float) -> bool:
        for item in self._items:
            if self._rectangles_overlap(x, y, width, height, item.x, item.y, item.width, item.height):
                return True
        return False

    @Slot(str, str, float, float, float, result="QVariantMap")
    def resolveConnectedPlacement(
        self, source_id: str, item_kind: str, base_x: float, base_y: float, step_y: float
    ) -> Dict[str, float]:
        # source_id is included for future route-specific strategies and QML call-site symmetry.
        _ = source_id
        width, height = self._dimensions_for_connected_kind(item_kind)
        step = abs(float(step_y))
        if step <= 0.0:
            step = 60.0

        if not self._position_overlaps_existing(base_x, base_y, width, height):
            return {"x": float(base_x), "y": float(base_y)}

        max_vertical_offsets = 50
        for offset in range(1, max_vertical_offsets + 1):
            candidate_y = base_y + (step * offset)
            if not self._position_overlaps_existing(base_x, candidate_y, width, height):
                return {"x": float(base_x), "y": float(candidate_y)}

        for offset in range(1, max_vertical_offsets + 1):
            candidate_y = base_y - (step * offset)
            if not self._position_overlaps_existing(base_x, candidate_y, width, height):
                return {"x": float(base_x), "y": float(candidate_y)}

        return {"x": float(base_x), "y": float(base_y)}

    # --- Qt model overrides -------------------------------------------------
    def rowCount(self, parent: QModelIndex | None = QModelIndex()) -> int:  # type: ignore[override]
        return len(self._items)

    def data(self, index: QModelIndex, role: int = Qt.DisplayRole):  # type: ignore[override]
        if not index.isValid() or not (0 <= index.row() < len(self._items)):
            return None

        item = self._items[index.row()]
        if role == self.IdRole:
            return item.id
        if role == self.TypeRole:
            return item.item_type.value
        if role == self.XRole:
            return item.x
        if role == self.YRole:
            return item.y
        if role == self.WidthRole:
            return item.width
        if role == self.HeightRole:
            return item.height
        if role == self.TextRole:
            return item.text
        if role == self.TaskIndexRole:
            return item.task_index
        if role == self.ColorRole:
            return item.color
        if role == self.TextColorRole:
            return item.text_color
        if role == self.TaskCompletedRole:
            return self._is_task_completed(item.task_index)
        if role == self.ImageDataRole:
            return item.image_data
        if role == self.TaskCurrentRole:
            return item.task_index >= 0 and item.task_index == self._current_task_index
        if role == self.NoteMarkdownRole:
            return self._get_display_note_markdown(item)
        if role == self.ObstacleMarkdownRole:
            return self._get_display_obstacle_markdown(item)
        if role == self.TaskCountdownRemainingRole:
            return self._getTaskCountdownRemaining(item.task_index)
        if role == self.TaskCountdownProgressRole:
            return self._getTaskCountdownProgress(item.task_index)
        if role == self.TaskCountdownExpiredRole:
            return self._isTaskCountdownExpired(item.task_index)
        if role == self.TaskCountdownActiveRole:
            return self._isTaskCountdownActive(item.task_index)
        if role == self.FolderPathRole:
            return item.folder_path
        if role == self.LinkedSubtabCompletionRole:
            return self._getLinkedSubtabCompletion(item.task_index)
        if role == self.LinkedSubtabActiveActionRole:
            return self._getLinkedSubtabActiveAction(item.task_index)
        if role == self.HasLinkedSubtabRole:
            return self._hasLinkedSubtab(item.task_index)
        if role == self.TaskReminderActiveRole:
            return self._isTaskReminderActive(item.task_index)
        if role == self.TaskReminderAtRole:
            return self._getTaskReminderAt(item.task_index)
        if role == self.TaskContractActiveRole:
            return self._isTaskContractActive(item.task_index)
        if role == self.TaskContractDeadlineRole:
            return self._getTaskContractDeadline(item.task_index)
        if role == self.TaskContractRemainingRole:
            return self._getTaskContractRemaining(item.task_index)
        if role == self.TaskContractBreachedRole:
            return self._isTaskContractBreached(item.task_index)
        if role == self.TaskContractPunishmentRole:
            return self._getTaskContractPunishment(item.task_index)
        if role == self.TextTabsRole:
            return normalize_editor_tabs(item.text_tabs, fallback_text=item.text)
        if role == self.TextTabIndexRole:
            return self._clamp_text_tab_index(item)
        return None

    def roleNames(self) -> Dict[int, bytes]:  # type: ignore[override]
        return {
            self.IdRole: b"itemId",
            self.TypeRole: b"itemType",
            self.XRole: b"x",
            self.YRole: b"y",
            self.WidthRole: b"width",
            self.HeightRole: b"height",
            self.TextRole: b"text",
            self.TaskIndexRole: b"taskIndex",
            self.ColorRole: b"color",
            self.TextColorRole: b"textColor",
            self.TaskCompletedRole: b"taskCompleted",
            self.ImageDataRole: b"imageData",
            self.TaskCurrentRole: b"taskCurrent",
            self.NoteMarkdownRole: b"noteMarkdown",
            self.ObstacleMarkdownRole: b"obstacleMarkdown",
            self.TaskCountdownRemainingRole: b"taskCountdownRemaining",
            self.TaskCountdownProgressRole: b"taskCountdownProgress",
            self.TaskCountdownExpiredRole: b"taskCountdownExpired",
            self.TaskCountdownActiveRole: b"taskCountdownActive",
            self.FolderPathRole: b"folderPath",
            self.LinkedSubtabCompletionRole: b"linkedSubtabCompletion",
            self.LinkedSubtabActiveActionRole: b"linkedSubtabActiveAction",
            self.HasLinkedSubtabRole: b"hasLinkedSubtab",
            self.TaskReminderActiveRole: b"taskReminderActive",
            self.TaskReminderAtRole: b"taskReminderAt",
            self.TaskContractActiveRole: b"taskContractActive",
            self.TaskContractDeadlineRole: b"taskContractDeadline",
            self.TaskContractRemainingRole: b"taskContractRemaining",
            self.TaskContractBreachedRole: b"taskContractBreached",
            self.TaskContractPunishmentRole: b"taskContractPunishment",
            self.TextTabsRole: b"textTabs",
            self.TextTabIndexRole: b"textTabIndex",
        }

    @staticmethod
    def _clamp_tab_index(index: int, tabs: List[Dict[str, str]]) -> int:
        if not tabs:
            return 0
        return max(0, min(int(index), len(tabs) - 1))

    def _clamp_text_tab_index(self, item: DiagramItem) -> int:
        tabs = normalize_editor_tabs(item.text_tabs, fallback_text=item.text)
        return self._clamp_tab_index(item.text_tab_index, tabs)

    def setTabModel(self, tab_model) -> None:
        """Attach or replace the tab model used for linked-subtab metadata."""
        if tab_model is self._tab_model:
            return
        self._disconnectTabModelSignals()
        self._tab_model = tab_model
        self._connectTabModelSignals()
        self._emitLinkedSubtabDataChanged()

    def _connectTabModelSignals(self) -> None:
        if self._tab_model is None:
            return
        try:
            self._tab_model.dataChanged.connect(self._onTabModelUpdated)
            self._tab_model.rowsInserted.connect(self._onTabModelUpdated)
            self._tab_model.rowsRemoved.connect(self._onTabModelUpdated)
            self._tab_model.modelReset.connect(self._onTabModelUpdated)
            self._tab_model.layoutChanged.connect(self._onTabModelUpdated)
        except (AttributeError, RuntimeError):
            pass

    def _disconnectTabModelSignals(self) -> None:
        if self._tab_model is None:
            return
        for signal in (
            getattr(self._tab_model, "dataChanged", None),
            getattr(self._tab_model, "rowsInserted", None),
            getattr(self._tab_model, "rowsRemoved", None),
            getattr(self._tab_model, "modelReset", None),
            getattr(self._tab_model, "layoutChanged", None),
        ):
            if signal is None:
                continue
            try:
                signal.disconnect(self._onTabModelUpdated)
            except (TypeError, RuntimeError):
                pass

    def _onTabModelUpdated(self, *args) -> None:
        self._emitLinkedSubtabDataChanged()

    def _emitLinkedSubtabDataChanged(self) -> None:
        if not self._items:
            return
        roles = [
            self.LinkedSubtabCompletionRole,
            self.LinkedSubtabActiveActionRole,
            self.HasLinkedSubtabRole,
        ]
        for row, item in enumerate(self._items):
            if item.task_index < 0:
                continue
            idx = self.index(row, 0)
            self.dataChanged.emit(idx, idx, roles)

    # --- Properties exposed to QML -----------------------------------------
    @Property(list, notify=edgesChanged)
    def edges(self) -> List[Dict[str, str]]:
        return [
            {"id": edge.id, "fromId": edge.from_id, "toId": edge.to_id, "description": edge.description}
            for edge in self._edges
        ]

    @Property(str, notify=itemsChanged)
    def edgeDrawingFrom(self) -> str:
        return self._edge_source_id or ""

    @Property(bool, notify=itemsChanged)
    def isDraggingEdge(self) -> bool:
        return self._is_dragging_edge

    @Property(float, notify=itemsChanged)
    def edgeDragX(self) -> float:
        return self._edge_drag_x

    @Property(float, notify=itemsChanged)
    def edgeDragY(self) -> float:
        return self._edge_drag_y

    @Property(str, notify=itemsChanged)
    def edgeHoverTargetId(self) -> str:
        return self._edge_hover_target_id

    @Property(str, notify=itemsChanged)
    def dragInsertEdgeId(self) -> str:
        return self._drag_insert_edge_id

    @Property(int, notify=itemsChanged)
    def count(self) -> int:
        return len(self._items)

    @Property(float, notify=itemsChanged)
    def minItemX(self) -> float:
        """Return the leftmost x position of all items."""
        if not self._items:
            return 0.0
        return min(item.x for item in self._items)

    @Property(float, notify=itemsChanged)
    def minItemY(self) -> float:
        """Return the topmost y position of all items."""
        if not self._items:
            return 0.0
        return min(item.y for item in self._items)

    @Property(float, notify=itemsChanged)
    def maxItemX(self) -> float:
        """Return the rightmost edge of all items (x + width)."""
        if not self._items:
            return 0.0
        return max(item.x + item.width for item in self._items)

    @Property(float, notify=itemsChanged)
    def maxItemY(self) -> float:
        """Return the bottommost edge of all items (y + height)."""
        if not self._items:
            return 0.0
        return max(item.y + item.height for item in self._items)

    # --- Drawing properties (from DrawingMixin) ----------------------------
    @Property(bool, notify=drawingModeChanged)
    def drawingMode(self) -> bool:
        return self._get_drawing_mode()

    @drawingMode.setter  # type: ignore[no-redef]
    def drawingMode(self, value: bool) -> None:
        self._set_drawing_mode(value)

    @Property(str, notify=brushColorChanged)
    def brushColor(self) -> str:
        return self._get_brush_color()

    @brushColor.setter  # type: ignore[no-redef]
    def brushColor(self, value: str) -> None:
        self._set_brush_color(value)

    @Property(float, notify=brushWidthChanged)
    def brushWidth(self) -> float:
        return self._get_brush_width()

    @brushWidth.setter  # type: ignore[no-redef]
    def brushWidth(self, value: float) -> None:
        self._set_brush_width(value)

    @Property(list, notify=drawingChanged)
    def strokes(self) -> List[Dict[str, Any]]:
        return self._get_strokes()

    # --- Item management ----------------------------------------------------
    @Slot(float, float, str, result=str)
    def addBox(self, x: float, y: float, text: str = "") -> str:
        item = self._build_item_from_preset("box", x, y, text)
        if not item:
            return ""
        self._append_item(item)
        return item.id

    def _add_preset(self, preset: str, x: float, y: float, text: str = "") -> str:
        item = self._build_item_from_preset(preset, x, y, text)
        if not item:
            return ""
        self._append_item(item)
        return item.id

    @Slot(str, float, float, result=str)
    def addPresetItem(self, preset: str, x: float, y: float) -> str:
        return self._add_preset(preset, x, y, "")

    @Slot(str, float, float, str, result=str)
    def addPresetItemWithText(self, preset: str, x: float, y: float, text: str) -> str:
        return self._add_preset(preset, x, y, text)

    @Slot(str, str, float, float, str, result=str)
    def addPresetItemAndConnect(self, source_id: str, preset: str, x: float, y: float, text: str) -> str:
        """Create a new item of the given preset type and connect it with an edge from source_id."""
        new_id = self._add_preset(preset, x, y, text)
        if new_id and source_id:
            self.addEdge(source_id, new_id)
        return new_id

    @Slot(str, float, float, str, result=str)
    def addTaskFromTextAndConnect(self, source_id: str, x: float, y: float, text: str) -> str:
        """Create a new task in the task list, add it to the diagram, and connect with an edge."""
        new_id = self.addTaskFromText(text, x, y)
        if new_id and source_id:
            self.addEdge(source_id, new_id)
        return new_id

    def _find_task_chain_tail(self, source_id: str) -> str:
        """Find the last task in a task chain starting from source_id."""
        if not source_id:
            return ""
        source_item = self.getItem(source_id)
        if source_item is None:
            return ""

        current_id = source_id
        visited: set[str] = {source_id}
        while True:
            next_task_id = ""
            for edge in self._edges:
                if edge.from_id != current_id:
                    continue
                candidate = self.getItem(edge.to_id)
                if candidate is None:
                    continue
                if candidate.item_type != DiagramItemType.TASK:
                    continue
                if edge.to_id in visited:
                    continue
                next_task_id = edge.to_id
                break
            if not next_task_id:
                return current_id
            visited.add(next_task_id)
            current_id = next_task_id

    @Slot(str, str, result=str)
    def createTaskFromMarkdownSelection(self, source_id: str, selected_text: str) -> str:
        """Create a task from selected markdown text and append it to the source task chain."""
        if not self._task_model:
            return ""
        source_item = self.getItem(source_id)
        if source_item is None:
            return ""

        text = selected_text.strip()
        if not text:
            return ""

        attach_from_id = self._find_task_chain_tail(source_id)
        if not attach_from_id:
            return ""
        attach_item = self.getItem(attach_from_id)
        if attach_item is None:
            return ""

        x = attach_item.x + attach_item.width + 100.0
        y = attach_item.y
        new_id = self.addTaskFromText(text, x, y)
        if not new_id:
            return ""
        self.addEdge(attach_from_id, new_id)
        return new_id

    @Slot(int, float, float, result=str)
    def addTask(self, task_index: int, x: float, y: float) -> str:
        if self._task_model is None:
            return ""
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return ""

        task_idx = self._task_model.index(task_index, 0)
        title = self._task_model.data(task_idx, getattr(self._task_model, "TitleRole", Qt.DisplayRole))
        if title is None:
            title = "Task"

        item_id = f"task_{next(self._id_source)}"
        item = DiagramItem(
            id=item_id,
            item_type=DiagramItemType.TASK,
            x=x,
            y=y,
            text=title,
            task_index=task_index,
            color="#82c3a5",
            text_color="#1b2028" if title else "#f5f6f8",
        )
        self._append_item(item)
        return item_id

    @Slot(str, float, float)
    def moveItem(self, item_id: str, x: float, y: float) -> None:
        for row, item in enumerate(self._items):
            if item.id == item_id:
                if item.x == x and item.y == y:
                    return
                item.x = x
                item.y = y
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.XRole, self.YRole])
                self.itemsChanged.emit()
                return

    @Slot(str, str)
    def setItemText(self, item_id: str, text: str) -> None:
        for row, item in enumerate(self._items):
            if item.id == item_id:
                if item.text == text:
                    return
                item.text = text
                if item.text_tabs:
                    item.text_tabs = normalize_editor_tabs(item.text_tabs, fallback_text=text)
                    item.text_tabs[0]["text"] = text
                index = self.index(row, 0)
                self.dataChanged.emit(
                    index,
                    index,
                    [
                        self.TextRole,
                        self.LinkedSubtabCompletionRole,
                        self.LinkedSubtabActiveActionRole,
                        self.HasLinkedSubtabRole,
                    ],
                )
                self.itemsChanged.emit()
                return

    @Slot(str, result="QVariantList")
    def getItemTextTabs(self, item_id: str) -> List[Dict[str, str]]:
        item = self.getItem(item_id)
        if item is None:
            return normalize_editor_tabs([], fallback_text="")
        return normalize_editor_tabs(item.text_tabs, fallback_text=item.text)

    @Slot(str, result=int)
    def getItemTextTabIndex(self, item_id: str) -> int:
        item = self.getItem(item_id)
        if item is None:
            return 0
        return self._clamp_text_tab_index(item)

    @Slot(str, int)
    def setItemTextTabIndex(self, item_id: str, tab_index: int) -> None:
        for row, item in enumerate(self._items):
            if item.id != item_id:
                continue
            next_index = self._clamp_tab_index(tab_index, normalize_editor_tabs(item.text_tabs, fallback_text=item.text))
            if item.text_tab_index == next_index:
                return
            item.text_tab_index = next_index
            index = self.index(row, 0)
            self.dataChanged.emit(index, index, [self.TextTabIndexRole, self.TextTabsRole, self.TextRole])
            self.itemsChanged.emit()
            return

    @Slot(str, str)
    def setItemMarkdown(self, item_id: str, markdown: str) -> None:
        for row, item in enumerate(self._items):
            if item.id == item_id:
                if item.item_type == DiagramItemType.NOTE:
                    if item.text == markdown and item.note_markdown == "":
                        return
                    # Notes now store canonical content directly in text.
                    item.text = markdown
                    item.note_markdown = ""
                    item.note_tabs = normalize_editor_tabs(item.note_tabs, fallback_text=markdown)
                    item.note_tabs[0]["text"] = markdown
                    index = self.index(row, 0)
                    self.dataChanged.emit(index, index, [self.TextRole, self.NoteMarkdownRole])
                    self.itemsChanged.emit()
                    return
                if item.note_markdown == markdown:
                    return
                item.note_markdown = markdown
                item.note_tabs = normalize_editor_tabs(item.note_tabs, fallback_text=markdown)
                item.note_tabs[0]["text"] = markdown
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.NoteMarkdownRole])
                self.itemsChanged.emit()
                return

    @Slot(str, result=str)
    def getItemMarkdown(self, item_id: str) -> str:
        for item in self._items:
            if item.id == item_id:
                if item.item_type == DiagramItemType.NOTE:
                    return item.text
                return item.note_markdown
        return ""

    @Slot(str, str)
    def setItemObstacleMarkdown(self, item_id: str, markdown: str) -> None:
        for row, item in enumerate(self._items):
            if item.id == item_id:
                normalized = markdown or ""
                if item.obstacle_markdown == normalized:
                    return
                item.obstacle_markdown = normalized
                item.obstacle_tabs = normalize_editor_tabs(item.obstacle_tabs, fallback_text=normalized)
                item.obstacle_tabs[0]["text"] = normalized
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.ObstacleMarkdownRole])
                self.itemsChanged.emit()
                return

    @Slot(str, result=str)
    def getItemObstacleMarkdown(self, item_id: str) -> str:
        for item in self._items:
            if item.id == item_id:
                return item.obstacle_markdown
        return ""

    def _get_display_note_markdown(self, item: DiagramItem) -> str:
        if item.item_type == DiagramItemType.NOTE:
            return first_tab_text(item.note_tabs, fallback_text=item.text)
        return first_tab_text(item.note_tabs, fallback_text=item.note_markdown)

    def _get_display_obstacle_markdown(self, item: DiagramItem) -> str:
        return first_tab_text(item.obstacle_tabs, fallback_text=item.obstacle_markdown)

    def getEditorTabs(self, item_id: str, editor_type: str) -> List[Dict[str, str]]:
        item = self.getItem(item_id)
        if item is None:
            return normalize_editor_tabs([], fallback_name="Tab 1")
        editor_key = str(editor_type or "").strip().lower()
        if editor_key == "obstacle":
            return normalize_editor_tabs(item.obstacle_tabs, fallback_text=item.obstacle_markdown)
        if editor_key == "freetext":
            normalized_tabs = normalize_editor_tabs(item.text_tabs, fallback_text=item.text)
            item.text_tab_index = self._clamp_tab_index(item.text_tab_index, normalized_tabs)
            return normalized_tabs
        fallback_text = item.text if item.item_type == DiagramItemType.NOTE else item.note_markdown
        return normalize_editor_tabs(item.note_tabs, fallback_text=fallback_text)

    def setEditorTabs(self, item_id: str, editor_type: str, tabs: List[Dict[str, str]]) -> None:
        for row, item in enumerate(self._items):
            if item.id != item_id:
                continue

            editor_key = str(editor_type or "").strip().lower()
            roles: List[int] = []
            if editor_key == "obstacle":
                normalized = normalize_editor_tabs(tabs, fallback_text=item.obstacle_markdown)
                item.obstacle_tabs = normalized
                item.obstacle_markdown = normalized[0]["text"]
                roles = [self.ObstacleMarkdownRole]
            elif editor_key == "freetext":
                normalized = normalize_editor_tabs(tabs, fallback_text=item.text)
                item.text_tabs = normalized
                item.text = normalized[0]["text"]
                item.text_tab_index = self._clamp_tab_index(item.text_tab_index, normalized)
                roles = [
                    self.TextRole,
                    self.LinkedSubtabCompletionRole,
                    self.LinkedSubtabActiveActionRole,
                    self.HasLinkedSubtabRole,
                    self.TextTabsRole,
                    self.TextTabIndexRole,
                ]
            else:
                fallback_text = item.text if item.item_type == DiagramItemType.NOTE else item.note_markdown
                normalized = normalize_editor_tabs(tabs, fallback_text=fallback_text)
                item.note_tabs = normalized
                if item.item_type == DiagramItemType.NOTE:
                    item.text = normalized[0]["text"]
                    item.note_markdown = ""
                    roles = [self.TextRole, self.NoteMarkdownRole]
                else:
                    item.note_markdown = normalized[0]["text"]
                    roles = [self.NoteMarkdownRole]

            index = self.index(row, 0)
            self.dataChanged.emit(index, index, roles)
            self.itemsChanged.emit()
            return

    @Slot(str, result=bool)
    def openChatGpt(self, item_id: str) -> bool:
        """Open a ChatGPT browser tab with the item's text as the prompt."""
        item = self.getItem(item_id)
        if not item or item.item_type != DiagramItemType.CHATGPT:
            return False
        prompt = item.text.strip() if item.text else ""
        if not prompt:
            prompt = "Research question"
        query = urllib.parse.quote_plus(prompt)
        url = f"https://chatgpt.com/?q={query}"
        return webbrowser.open(url)

    @Slot(str, str)
    def setFolderPath(self, item_id: str, path: str) -> None:
        """Set the folder path for a diagram item."""
        path = self._normalize_local_path(path)
        for row, item in enumerate(self._items):
            if item.id == item_id:
                if item.folder_path == path:
                    return
                item.folder_path = path
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.FolderPathRole])
                self.itemsChanged.emit()
                return

    @Slot(str, result=str)
    def getFolderPath(self, item_id: str) -> str:
        """Get the folder path for a diagram item."""
        for item in self._items:
            if item.id == item_id:
                return item.folder_path
        return ""

    @Slot(str, result=bool)
    def openFolder(self, item_id: str) -> bool:
        """Open the linked folder in the OS file browser."""
        item = self.getItem(item_id)
        if not item or not item.folder_path:
            return False

        # Normalize at open-time to support older saved paths too.
        path = self._normalize_local_path(item.folder_path)
        if not os.path.isdir(path):
            return False

        try:
            system = platform.system()
            if system == "Darwin":
                subprocess.Popen(["open", path])
            elif system == "Windows":
                if hasattr(os, "startfile"):
                    os.startfile(path)
                else:
                    subprocess.Popen(["explorer", path])
            else:
                subprocess.Popen(["xdg-open", path])
            return True
        except OSError:
            return False

    def _normalize_local_path(self, path: str) -> str:
        """Convert local file URLs and legacy Windows URL paths into local paths."""
        if not path:
            return ""

        normalized = path.strip()
        if normalized.startswith("file:"):
            url = QUrl(normalized)
            if url.isLocalFile():
                normalized = url.toLocalFile()
            else:
                normalized = urllib.parse.unquote(url.path())

        if os.name == "nt" and normalized.startswith("/") and len(normalized) > 2 and normalized[2] == ":":
            normalized = normalized[1:]
        return normalized

    @Slot(str)
    def clearFolderPath(self, item_id: str) -> None:
        """Clear the folder path for a diagram item."""
        self.setFolderPath(item_id, "")

    @Slot(str, str)
    def renameTaskItem(self, item_id: str, new_text: str) -> None:
        """Rename a task item and sync to the task list."""
        new_text = new_text.strip()
        if not new_text:
            return
        if self._renaming_in_progress:
            return
        for row, item in enumerate(self._items):
            if item.id == item_id:
                if item.text == new_text:
                    return
                item.text = new_text
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.TextRole])
                self.itemsChanged.emit()
                # Sync to task model if this is a task item
                if item.task_index >= 0 and self._task_model is not None:
                    self._renaming_in_progress = True
                    try:
                        self._task_model.renameTask(item.task_index, new_text)
                    finally:
                        self._renaming_in_progress = False
                return

    @Slot(int, str)
    def onTaskRenamed(self, task_index: int, new_title: str) -> None:
        """Handle task rename from the task list - update diagram items."""
        if self._renaming_in_progress:
            return
        for row, item in enumerate(self._items):
            if item.task_index == task_index:
                if item.text == new_title:
                    continue
                item.text = new_title
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.TextRole])
        self.itemsChanged.emit()

    @Slot(int, bool)
    def onTaskCompletionChanged(self, task_index: int, completed: bool) -> None:
        """Handle task completion updates from the task list."""
        for row, item in enumerate(self._items):
            if item.task_index == task_index:
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.TaskCompletedRole])
        if completed and task_index == self._current_task_index:
            self._advanceCurrentTaskFromOutgoingEdges(task_index)

    def _advanceCurrentTaskFromOutgoingEdges(self, completed_task_index: int) -> None:
        """Advance current task to a unique incomplete outgoing task, else clear it."""
        source_ids = {
            item.id
            for item in self._items
            if item.task_index == completed_task_index
        }
        candidate_task_indices: set[int] = set()
        if source_ids:
            task_by_item_id = {item.id: item.task_index for item in self._items if item.task_index >= 0}
            for edge in self._edges:
                if edge.from_id not in source_ids:
                    continue
                to_task_index = task_by_item_id.get(edge.to_id, -1)
                if to_task_index < 0:
                    continue
                if self._is_task_completed(to_task_index):
                    continue
                candidate_task_indices.add(to_task_index)

        new_current_task_index = -1
        if len(candidate_task_indices) == 1:
            new_current_task_index = next(iter(candidate_task_indices))

        old_current_task_index = self._current_task_index
        if new_current_task_index == old_current_task_index:
            return

        self._current_task_index = new_current_task_index
        self.currentTaskChanged.emit()
        for row, item in enumerate(self._items):
            if item.task_index == old_current_task_index or item.task_index == new_current_task_index:
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.TaskCurrentRole])

    @Slot(int)
    def onTaskCountdownChanged(self, task_index: int) -> None:
        """Handle countdown timer updates from the task list."""
        for row, item in enumerate(self._items):
            if item.task_index == task_index:
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [
                    self.TaskCountdownRemainingRole,
                    self.TaskCountdownProgressRole,
                    self.TaskCountdownExpiredRole,
                    self.TaskCountdownActiveRole
                ])

    @Slot(int)
    def onTaskReminderChanged(self, task_index: int) -> None:
        """Handle reminder updates from the task list."""
        for row, item in enumerate(self._items):
            if item.task_index == task_index:
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [
                    self.TaskReminderActiveRole,
                    self.TaskReminderAtRole,
                ])

    @Slot(int)
    def onTaskContractChanged(self, task_index: int) -> None:
        """Handle contract updates from the task list."""
        for row, item in enumerate(self._items):
            if item.task_index == task_index:
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [
                    self.TaskContractActiveRole,
                    self.TaskContractDeadlineRole,
                    self.TaskContractRemainingRole,
                    self.TaskContractBreachedRole,
                    self.TaskContractPunishmentRole,
                ])

    def _isTaskReminderActive(self, task_index: int) -> bool:
        """Return True if task has an active reminder."""
        if self._task_model is None:
            return False
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return False
        idx = self._task_model.index(task_index, 0)
        return bool(self._task_model.data(idx, self._task_model.ReminderActiveRole))

    def _getTaskReminderAt(self, task_index: int) -> str:
        """Get the reminder datetime string for a task."""
        if self._task_model is None:
            return ""
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return ""
        idx = self._task_model.index(task_index, 0)
        return str(self._task_model.data(idx, self._task_model.ReminderAtRole) or "")

    def _isTaskContractActive(self, task_index: int) -> bool:
        """Return True if task has an active contract."""
        if self._task_model is None:
            return False
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return False
        idx = self._task_model.index(task_index, 0)
        return bool(self._task_model.data(idx, self._task_model.ContractActiveRole))

    def _getTaskContractDeadline(self, task_index: int) -> str:
        """Get contract deadline datetime string for a task."""
        if self._task_model is None:
            return ""
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return ""
        idx = self._task_model.index(task_index, 0)
        return str(self._task_model.data(idx, self._task_model.ContractDeadlineRole) or "")

    def _getTaskContractRemaining(self, task_index: int) -> float:
        """Get seconds remaining until contract deadline, or -1 if no contract."""
        if self._task_model is None:
            return -1.0
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return -1.0
        idx = self._task_model.index(task_index, 0)
        return float(self._task_model.data(idx, self._task_model.ContractRemainingRole))

    def _isTaskContractBreached(self, task_index: int) -> bool:
        """Return True if task contract has been breached."""
        if self._task_model is None:
            return False
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return False
        idx = self._task_model.index(task_index, 0)
        return bool(self._task_model.data(idx, self._task_model.ContractBreachedRole))

    def _getTaskContractPunishment(self, task_index: int) -> str:
        """Return punishment text for task contract."""
        if self._task_model is None:
            return ""
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return ""
        idx = self._task_model.index(task_index, 0)
        return str(self._task_model.data(idx, self._task_model.ContractPunishmentRole) or "")

    def _getTaskCountdownRemaining(self, task_index: int) -> float:
        """Get seconds remaining on task countdown, or -1 if no timer."""
        if self._task_model is None:
            return -1.0
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return -1.0
        idx = self._task_model.index(task_index, 0)
        return float(self._task_model.data(idx, self._task_model.CountdownRemainingRole))

    def _getTaskCountdownProgress(self, task_index: int) -> float:
        """Get countdown progress as 0.0-1.0, or -1 if no timer."""
        if self._task_model is None:
            return -1.0
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return -1.0
        idx = self._task_model.index(task_index, 0)
        return float(self._task_model.data(idx, self._task_model.CountdownProgressRole))

    def _isTaskCountdownExpired(self, task_index: int) -> bool:
        """Return True if task countdown has expired."""
        if self._task_model is None:
            return False
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return False
        idx = self._task_model.index(task_index, 0)
        return bool(self._task_model.data(idx, self._task_model.CountdownExpiredRole))

    def _isTaskCountdownActive(self, task_index: int) -> bool:
        """Return True if task has an active countdown timer."""
        if self._task_model is None:
            return False
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return False
        idx = self._task_model.index(task_index, 0)
        return bool(self._task_model.data(idx, self._task_model.CountdownActiveRole))

    @Slot(int, str)
    def setTaskCountdownTimer(self, task_index: int, duration_str: str) -> None:
        """Set a countdown timer for a task from QML."""
        if self._task_model is not None:
            self._task_model.setCountdownTimer(task_index, duration_str)

    @Slot(int)
    def clearTaskCountdownTimer(self, task_index: int) -> None:
        """Clear the countdown timer for a task from QML."""
        if self._task_model is not None:
            self._task_model.clearCountdownTimer(task_index)

    @Slot(int)
    def restartTaskCountdownTimer(self, task_index: int) -> None:
        """Restart the countdown timer for a task from QML."""
        if self._task_model is not None:
            self._task_model.restartCountdownTimer(task_index)

    @Slot(int, str, result=bool)
    @Slot(int, str, bool, result=bool)
    def setTaskReminderAt(self, task_index: int, reminder_at_str: str, send_notification: bool = False) -> bool:
        """Set a reminder datetime for a task from QML."""
        if self._task_model is not None:
            return bool(self._task_model.setReminderAt(task_index, reminder_at_str, send_notification))
        return False

    @Slot(int, result=bool)
    def isTaskReminderNotificationEnabled(self, task_index: int) -> bool:
        """Return whether a task reminder is configured to publish via ntfy."""
        if self._task_model is None:
            return False
        return bool(self._task_model.isReminderNotificationEnabled(task_index))

    @Slot(int)
    def clearTaskReminderAt(self, task_index: int) -> None:
        """Clear the reminder datetime for a task from QML."""
        if self._task_model is not None:
            self._task_model.clearReminderAt(task_index)

    @Slot(int, str, str, result=bool)
    def setTaskContractAt(self, task_index: int, contract_deadline_str: str, punishment: str) -> bool:
        """Set a contract deadline and punishment for a task from QML."""
        if self._task_model is not None:
            return bool(self._task_model.setContractAt(task_index, contract_deadline_str, punishment))
        return False

    @Slot(int)
    def clearTaskContract(self, task_index: int) -> None:
        """Clear task contract from QML."""
        if self._task_model is not None:
            self._task_model.clearContract(task_index)

    @Slot(str, str)
    def convertItemType(self, item_id: str, preset_name: str) -> None:
        """Convert an item to a different type using a preset."""
        preset_name = preset_name.lower()

        # Handle task conversion specially since it needs task model integration
        if preset_name == "task":
            self._convertToTask(item_id)
            return

        preset = ITEM_PRESETS.get(preset_name)
        if not preset:
            return

        for row, item in enumerate(self._items):
            if item.id == item_id:
                new_type = preset["type"]
                # Skip if already the same type
                if item.item_type == new_type:
                    return

                # Update item properties
                item.item_type = new_type
                item.color = str(preset["color"])
                item.text_color = str(preset["text_color"])

                # Clear task association if converting away from task
                had_task = item.task_index >= 0
                if had_task:
                    removed_task_index = item.task_index
                    old_current_index = self._current_task_index
                    if (
                        self._task_model is not None
                        and 0 <= removed_task_index < self._task_model.rowCount()
                    ):
                        # Find item that was current task BEFORE decrementing indices
                        old_current_item_row = None
                        if old_current_index >= 0 and old_current_index != removed_task_index:
                            for idx, other_item in enumerate(self._items):
                                if other_item.task_index == old_current_index:
                                    old_current_item_row = idx
                                    break

                        self._task_model.removeAt(removed_task_index)
                        for idx, other_item in enumerate(self._items):
                            if other_item.task_index > removed_task_index:
                                other_item.task_index -= 1
                                other_index = self.index(idx, 0)
                                self.dataChanged.emit(other_index, other_index, [self.TaskIndexRole])
                        if old_current_index == removed_task_index:
                            self._current_task_index = -1
                        elif old_current_index > removed_task_index:
                            self._current_task_index = old_current_index - 1
                        if self._current_task_index != old_current_index:
                            self.currentTaskChanged.emit()
                            # Emit TaskCurrentRole for the item that was the current task
                            if old_current_item_row is not None:
                                other_index = self.index(old_current_item_row, 0)
                                self.dataChanged.emit(other_index, other_index, [self.TaskCurrentRole])
                            # Emit for the new current task item if there is one
                            if self._current_task_index >= 0:
                                for idx, other_item in enumerate(self._items):
                                    if other_item.task_index == self._current_task_index:
                                        other_index = self.index(idx, 0)
                                        self.dataChanged.emit(other_index, other_index, [self.TaskCurrentRole])
                                        break
                    item.task_index = -1

                index = self.index(row, 0)
                roles = [
                    self.TypeRole,
                    self.ColorRole,
                    self.TextColorRole,
                    self.TaskIndexRole,
                ]
                if had_task:
                    roles.append(self.TaskCurrentRole)
                self.dataChanged.emit(
                    index,
                    index,
                    roles,
                )
                self.itemsChanged.emit()
                return

    def _convertToTask(self, item_id: str) -> None:
        """Convert an item to a task, creating an entry in the task list."""
        if not self._task_model:
            return

        for row, item in enumerate(self._items):
            if item.id == item_id:
                # Skip if already a task
                if item.item_type == DiagramItemType.TASK:
                    return

                # Use existing text or default
                text = item.text.strip() if item.text else "Task"

                # Add to the task model, ensuring we get the inserted index
                add_task_with_parent = getattr(self._task_model, "addTaskWithParent", None)
                if callable(add_task_with_parent):
                    new_index = add_task_with_parent(text, -1)
                    if new_index < 0:
                        return
                else:
                    task_count_before = self._task_model.rowCount()
                    self._task_model.addTask(text, -1)
                    task_count_after = self._task_model.rowCount()
                    if task_count_after == task_count_before:
                        return
                    new_index = task_count_after - 1

                # Update item properties
                item.item_type = DiagramItemType.TASK
                item.task_index = new_index
                item.color = "#82c3a5"
                item.text_color = "#1b2028"
                if not item.text.strip():
                    item.text = text

                index = self.index(row, 0)
                self.dataChanged.emit(
                    index,
                    index,
                    [
                        self.TypeRole,
                        self.TaskIndexRole,
                        self.ColorRole,
                        self.TextColorRole,
                        self.TextRole,
                    ],
                )
                self.itemsChanged.emit()
                return

    @Slot(str, float, float)
    def resizeItem(self, item_id: str, width: float, height: float) -> None:
        new_width = max(40.0, width)
        new_height = max(30.0, height)
        for row, item in enumerate(self._items):
            if item.id == item_id:
                if item.width == new_width and item.height == new_height:
                    return
                item.width = new_width
                item.height = new_height
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.WidthRole, self.HeightRole])
                self.itemsChanged.emit()
                return

    @Slot(str, str)
    def addEdge(self, from_id: str, to_id: str) -> None:
        if from_id == to_id:
            return
        for edge in self._edges:
            if edge.from_id == from_id and edge.to_id == to_id:
                return
        edge_id = f"edge_{len(self._edges)}"
        self._edges.append(DiagramEdge(edge_id, from_id, to_id))
        self.edgesChanged.emit()

    def _find_edge(self, edge_id: str) -> Optional[DiagramEdge]:
        for edge in self._edges:
            if edge.id == edge_id:
                return edge
        return None

    @Slot(str)
    def removeEdge(self, edge_id: str) -> None:
        """Remove an edge by its ID."""
        for idx, edge in enumerate(self._edges):
            if edge.id == edge_id:
                self._edges.pop(idx)
                self.edgesChanged.emit()
                return

    @Slot(str, str)
    def removeEdgeBetween(self, from_id: str, to_id: str) -> None:
        """Remove an edge between two items."""
        for idx, edge in enumerate(self._edges):
            if edge.from_id == from_id and edge.to_id == to_id:
                self._edges.pop(idx)
                self.edgesChanged.emit()
                return

    @Slot(str, str)
    def setEdgeDescription(self, edge_id: str, description: str) -> None:
        """Set description text for an edge."""
        for edge in self._edges:
            if edge.id == edge_id:
                if edge.description == description:
                    return
                edge.description = description
                self.edgesChanged.emit()
                return

    @Slot(str, result=str)
    def getEdgeDescription(self, edge_id: str) -> str:
        """Get description text for an edge."""
        edge = self._find_edge(edge_id)
        if edge is not None:
            return edge.description
        return ""

    def _distance_to_segment(
        self,
        px: float,
        py: float,
        x1: float,
        y1: float,
        x2: float,
        y2: float,
    ) -> float:
        dx = x2 - x1
        dy = y2 - y1
        length_sq = dx * dx + dy * dy
        if length_sq == 0.0:
            return math.hypot(px - x1, py - y1)
        t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / length_sq))
        proj_x = x1 + t * dx
        proj_y = y1 + t * dy
        return math.hypot(px - proj_x, py - proj_y)

    def _find_edge_at_position(self, x: float, y: float, excluded_item_id: str = "", threshold: float = 8.0) -> str:
        for edge in reversed(self._edges):
            if excluded_item_id and (edge.from_id == excluded_item_id or edge.to_id == excluded_item_id):
                continue
            from_item = self.getItem(edge.from_id)
            to_item = self.getItem(edge.to_id)
            if from_item is None or to_item is None:
                continue
            from_x, from_y = self._item_center(from_item)
            to_x, to_y = self._item_center(to_item)
            if self._distance_to_segment(x, y, from_x, from_y, to_x, to_y) <= threshold:
                return edge.id
        return ""

    def _set_drag_insert_edge_id(self, edge_id: str) -> None:
        if self._drag_insert_edge_id == edge_id:
            return
        self._drag_insert_edge_id = edge_id
        self.itemsChanged.emit()

    def _expand_items_around_inserted_task(
        self,
        from_id: str,
        to_id: str,
        inserted_id: str,
        direction_x: float,
        direction_y: float,
    ) -> None:
        from_item = self.getItem(from_id)
        to_item = self.getItem(to_id)
        inserted_item = self.getItem(inserted_id)
        if from_item is None or to_item is None or inserted_item is None:
            return

        padding = 80.0
        minimum_spacing = inserted_item.width + padding
        inserted_center_x, inserted_center_y = self._item_center(inserted_item)

        for item, sign in ((from_item, -1.0), (to_item, 1.0)):
            center_x, center_y = self._item_center(item)
            distance = math.hypot(center_x - inserted_center_x, center_y - inserted_center_y)
            missing_distance = minimum_spacing - distance
            if missing_distance <= 0.0:
                continue
            self.moveItem(
                item.id,
                item.x + (direction_x * missing_distance * sign),
                item.y + (direction_y * missing_distance * sign),
            )

    @Slot(str, str, float, float, result=str)
    def insertTaskOnEdge(self, edge_id: str, text: str, x: float, y: float) -> str:
        """Create a task on an existing edge and reconnect around it."""
        edge = self._find_edge(edge_id)
        if edge is None:
            return ""

        from_id = edge.from_id
        to_id = edge.to_id
        description = edge.description
        from_item = self.getItem(from_id)
        to_item = self.getItem(to_id)
        direction_x = 0.0
        direction_y = 0.0
        should_expand = False
        if from_item is not None and to_item is not None:
            from_center_x, from_center_y = self._item_center(from_item)
            to_center_x, to_center_y = self._item_center(to_item)
            delta_x = to_center_x - from_center_x
            delta_y = to_center_y - from_center_y
            edge_length = math.hypot(delta_x, delta_y)
            if edge_length > 1e-6:
                direction_x = delta_x / edge_length
                direction_y = delta_y / edge_length
                should_expand = True

        new_id = self.addTaskFromText(text, x, y)
        if not new_id:
            return ""

        if should_expand:
            self._expand_items_around_inserted_task(from_id, to_id, new_id, direction_x, direction_y)

        self.removeEdge(edge_id)
        self.addEdge(from_id, new_id)
        self.addEdge(new_id, to_id)

        if description:
            upstream_edge = self._edges[-2] if len(self._edges) >= 2 else None
            if upstream_edge is not None and upstream_edge.from_id == from_id and upstream_edge.to_id == new_id:
                upstream_edge.description = description
                self.edgesChanged.emit()

        return new_id

    @Slot(str, str, result=bool)
    def insertExistingItemOnEdge(self, edge_id: str, item_id: str) -> bool:
        """Reconnect an existing item so it becomes the middle node on an edge."""
        edge = self._find_edge(edge_id)
        item = self.getItem(item_id)
        if edge is None or item is None:
            return False
        if item.item_type != DiagramItemType.TASK:
            return False
        from_id = edge.from_id
        to_id = edge.to_id
        if not from_id or not to_id or from_id == item_id or to_id == item_id:
            return False

        description = edge.description
        self.removeEdge(edge_id)

        filtered_edges = [
            existing_edge
            for existing_edge in self._edges
            if existing_edge.from_id != item_id and existing_edge.to_id != item_id
        ]
        edges_removed = len(filtered_edges) != len(self._edges)
        if edges_removed:
            self._edges = filtered_edges
            self.edgesChanged.emit()

        self.addEdge(from_id, item_id)
        self.addEdge(item_id, to_id)

        if description:
            upstream_edge = self._edges[-2] if len(self._edges) >= 2 else None
            if upstream_edge is not None and upstream_edge.from_id == from_id and upstream_edge.to_id == item_id:
                upstream_edge.description = description
                self.edgesChanged.emit()

        return True

    @Slot(str)
    def removeItem(self, item_id: str) -> None:
        # Remove edges touching the item
        removed_task_index = -1
        filtered = [edge for edge in self._edges if edge.from_id != item_id and edge.to_id != item_id]
        if len(filtered) != len(self._edges):
            self._edges = filtered
            self.edgesChanged.emit()

        removed = False
        for row, item in enumerate(self._items):
            if item.id == item_id:
                removed_task_index = item.task_index
                self.beginRemoveRows(QModelIndex(), row, row)
                self._items.pop(row)
                self.endRemoveRows()
                self.itemsChanged.emit()
                removed = True
                break

        # If the removed item was a task, also remove from TaskModel
        if removed_task_index >= 0 and self._task_model is not None:
            self._task_model.removeAt(removed_task_index)
            # Update task indices for all remaining items that referenced tasks after the deleted one
            for row, item in enumerate(self._items):
                if item.task_index > removed_task_index:
                    item.task_index -= 1
                    # Notify UI that this item's task reference changed
                    idx = self.index(row, 0)
                    self.dataChanged.emit(idx, idx, [self.TaskIndexRole])

        if removed and self._edge_source_id == item_id:
            self._reset_edge_state()

    @Slot(str)
    def startEdgeDrawing(self, item_id: str) -> None:
        item = self.getItem(item_id)
        self._edge_source_id = item_id if item else None
        if item:
            self._edge_drag_x = item.x + item.width / 2
            self._edge_drag_y = item.y + item.height / 2
        else:
            self._edge_drag_x = 0.0
            self._edge_drag_y = 0.0
        self._is_dragging_edge = False
        self.itemsChanged.emit()

    @Slot(float, float)
    def updateEdgeDragPosition(self, x: float, y: float) -> None:
        if not self._edge_source_id:
            return
        self._edge_drag_x = x
        self._edge_drag_y = y
        self._is_dragging_edge = True
        # Find hover target with enlarged margin (20px) for easier drops
        hover_id = self.getItemAtWithMargin(x, y, 20.0) or ""
        # Don't hover on source item
        if hover_id == self._edge_source_id:
            hover_id = ""
        self._edge_hover_target_id = hover_id
        self.itemsChanged.emit()

    @Slot(str, float, float)
    def updateDraggedTaskInsertTarget(self, item_id: str, x: float, y: float) -> None:
        item = self.getItem(item_id)
        if item is None or item.item_type != DiagramItemType.TASK:
            self._set_drag_insert_edge_id("")
            return
        edge_id = self._find_edge_at_position(x, y, excluded_item_id=item_id, threshold=12.0)
        self._set_drag_insert_edge_id(edge_id)

    @Slot()
    def clearDraggedTaskInsertTarget(self) -> None:
        self._set_drag_insert_edge_id("")

    @Slot(str)
    def finishEdgeDrawing(self, target_id: str) -> None:
        if self._edge_source_id and target_id and self._edge_source_id != target_id:
            self.addEdge(self._edge_source_id, target_id)
        self._reset_edge_state()

    @Slot()
    def cancelEdgeDrawing(self) -> None:
        self._reset_edge_state()

    @Slot(str, str)
    def createTaskFromText(self, text: str, item_id: str) -> None:
        if not self._task_model:
            return
        self._task_model.addTask(text, -1)
        task_count = self._task_model.rowCount()
        if task_count == 0:
            return
        new_index = task_count - 1
        for row, item in enumerate(self._items):
            if item.id == item_id:
                item.task_index = new_index
                item.item_type = DiagramItemType.TASK
                item.color = "#82c3a5"
                item.text = text
                item.text_color = "#1b2028"
                index = self.index(row, 0)
                self.dataChanged.emit(
                    index,
                    index,
                    [
                        self.TaskIndexRole,
                        self.TypeRole,
                        self.ColorRole,
                        self.TextRole,
                        self.TextColorRole,
                    ],
                )
                self.itemsChanged.emit()
                break

    @Slot(str, float, float, result=str)
    def addTaskFromText(self, text: str, x: float, y: float) -> str:
        """Create a new task in the task list and add it to the diagram."""
        text = text.strip()
        if not text or not self._task_model:
            return ""

        # Add to the task model first
        self._task_model.addTask(text, -1)
        task_count = self._task_model.rowCount()
        if task_count == 0:
            return ""

        new_index = task_count - 1

        # Create diagram item for the task
        item_id = self._next_id("task")
        item = DiagramItem(
            id=item_id,
            item_type=DiagramItemType.TASK,
            x=x,
            y=y,
            width=140.0,
            height=70.0,
            text=text,
            task_index=new_index,
            color="#82c3a5",
            text_color="#1b2028",
        )
        self._append_item(item)
        return item_id

    def _parse_breakdown_entries(self, raw_list: str) -> List[str]:
        if not raw_list:
            return []
        parts = re.split(r"[,\n;]+", raw_list)
        entries = []
        for part in parts:
            text = part.strip()
            if text:
                entries.append(text)
        return entries

    @Slot(str, str)
    def breakDownItem(self, item_id: str, raw_list: str) -> None:
        item = self.getItem(item_id)
        if not item:
            return

        entries = self._parse_breakdown_entries(raw_list)
        if not entries:
            return

        preset_name = item.item_type.value
        if preset_name != "task" and preset_name not in ITEM_PRESETS:
            return

        # Collect incoming and outgoing edges before removing the item
        incoming_edges = [(e.from_id, e.description) for e in self._edges if e.to_id == item_id]
        outgoing_edges = [(e.to_id, e.description) for e in self._edges if e.from_id == item_id]

        spacing = 40.0
        base_width = item.width
        base_height = item.height
        # Position new items starting at the original item's position
        start_x = item.x
        start_y = item.y

        new_ids: List[str] = []
        for idx, entry in enumerate(entries):
            # Arrange horizontally
            x = start_x + idx * (base_width + spacing)
            y = start_y
            if preset_name == "task":
                new_id = self.addTaskFromText(entry, x, y)
            else:
                new_id = self.addPresetItemWithText(preset_name, x, y, entry)
            if new_id:
                new_ids.append(new_id)

        if not new_ids:
            return

        # Connect new items sequentially
        for i in range(len(new_ids) - 1):
            self.addEdge(new_ids[i], new_ids[i + 1])

        # Reconnect incoming edges to the first new node
        first_new_id = new_ids[0]
        for from_id, description in incoming_edges:
            self.addEdge(from_id, first_new_id)
            if description:
                # Find the newly added edge and set its description
                for edge in self._edges:
                    if edge.from_id == from_id and edge.to_id == first_new_id:
                        edge.description = description
                        break

        # Reconnect outgoing edges from the last new node
        last_new_id = new_ids[-1]
        for to_id, description in outgoing_edges:
            self.addEdge(last_new_id, to_id)
            if description:
                for edge in self._edges:
                    if edge.from_id == last_new_id and edge.to_id == to_id:
                        edge.description = description
                        break

        # Remove the original item (this also removes its edges)
        self.removeItem(item_id)

    @Slot(int, bool)
    def setTaskCompleted(self, task_index: int, completed: bool) -> None:
        """Set a task's completion state via the task model."""
        if self._task_model is None:
            return
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return
        self._task_model.toggleComplete(task_index, completed)

    @Slot(int)
    def setCurrentTask(self, task_index: int) -> None:
        """Set the current (focused) task, clearing any previous."""
        old_index = self._current_task_index
        # Toggle off if same task
        if task_index == old_index:
            self._current_task_index = -1
        else:
            self._current_task_index = task_index
        self.currentTaskChanged.emit()
        # Notify all task items that their current state may have changed
        for row, item in enumerate(self._items):
            if item.task_index >= 0 and (item.task_index == old_index or item.task_index == self._current_task_index):
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.TaskCurrentRole])

    @Slot(int)
    def focusTask(self, task_index: int) -> None:
        """Focus a task without toggle-off behavior."""
        if self._task_model is not None:
            if task_index < 0 or task_index >= self._task_model.rowCount():
                return

        old_index = self._current_task_index
        if task_index == old_index:
            return
        self._current_task_index = task_index
        self.currentTaskChanged.emit()

        for row, item in enumerate(self._items):
            if item.task_index >= 0 and (item.task_index == old_index or item.task_index == self._current_task_index):
                index = self.index(row, 0)
                self.dataChanged.emit(index, index, [self.TaskCurrentRole])

    @Property(int, notify=currentTaskChanged)
    def currentTaskIndex(self) -> int:
        """Return the currently focused task index, or -1 if none."""
        return self._current_task_index

    @Slot(result="QVariant")
    def getCurrentTaskPosition(self) -> Optional[Dict[str, float]]:
        """Return the position of the current task item, or None if no current task."""
        if self._current_task_index < 0:
            return None
        for item in self._items:
            if item.task_index == self._current_task_index:
                return {
                    "x": item.x,
                    "y": item.y,
                    "width": item.width,
                    "height": item.height,
                }
        return None

    # --- Utilities ----------------------------------------------------------
    def getItem(self, item_id: str) -> Optional[DiagramItem]:
        for item in self._items:
            if item.id == item_id:
                return item
        return None

    def _is_task_completed(self, task_index: int) -> bool:
        if self._task_model is None:
            return False
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return False
        idx = self._task_model.index(task_index, 0)
        return bool(self._task_model.data(idx, self._task_model.CompletedRole))

    def _findLinkedSubtab(self, task_index: int):
        if self._tab_model is None or self._task_model is None:
            return None
        if task_index < 0 or task_index >= self._task_model.rowCount():
            return None
        idx = self._task_model.index(task_index, 0)
        title = str(self._task_model.data(idx, self._task_model.TitleRole) or "").strip()
        if not title:
            return None
        get_all_tabs = getattr(self._tab_model, "getAllTabs", None)
        if not callable(get_all_tabs):
            return None
        for tab in get_all_tabs():
            if getattr(tab, "name", "") == title:
                return tab
        return None

    def _calculateLinkedTabCompletion(self, tab) -> float:
        tasks_payload = getattr(tab, "tasks", {})
        tasks = tasks_payload.get("tasks", []) if isinstance(tasks_payload, dict) else []
        if not tasks:
            return 0.0
        completed = 0
        for task in tasks:
            if isinstance(task, dict) and task.get("completed"):
                completed += 1
        return (completed / len(tasks)) * 100.0

    def _getLinkedSubtabCompletion(self, task_index: int) -> float:
        tab = self._findLinkedSubtab(task_index)
        if tab is None:
            return -1.0
        return self._calculateLinkedTabCompletion(tab)

    def _getLinkedSubtabActiveAction(self, task_index: int) -> str:
        tab = self._findLinkedSubtab(task_index)
        if tab is None:
            return ""
        diagram_payload = getattr(tab, "diagram", {})
        if not isinstance(diagram_payload, dict):
            return ""
        current_task_index = int(diagram_payload.get("current_task_index", -1))
        if current_task_index < 0:
            return ""
        tasks_payload = getattr(tab, "tasks", {})
        tasks = tasks_payload.get("tasks", []) if isinstance(tasks_payload, dict) else []
        if current_task_index >= len(tasks):
            return ""
        current_task = tasks[current_task_index]
        if not isinstance(current_task, dict):
            return ""
        return str(current_task.get("title", ""))

    def _hasLinkedSubtab(self, task_index: int) -> bool:
        return self._findLinkedSubtab(task_index) is not None

    @Slot(str, result="QVariant")
    def getItemSnapshot(self, item_id: str) -> Dict[str, Any]:
        item = self.getItem(item_id)
        if not item:
            return {}
        return {
            "id": item.id,
            "type": item.item_type.value,
            "x": item.x,
            "y": item.y,
            "width": item.width,
            "height": item.height,
            "text": item.text,
            "taskIndex": item.task_index,
            "noteMarkdown": self._get_display_note_markdown(item),
            "obstacleMarkdown": self._get_display_obstacle_markdown(item),
            "folderPath": item.folder_path,
            "linkedSubtabCompletion": self._getLinkedSubtabCompletion(item.task_index),
            "linkedSubtabActiveAction": self._getLinkedSubtabActiveAction(item.task_index),
            "hasLinkedSubtab": self._hasLinkedSubtab(item.task_index),
            "taskContractActive": self._isTaskContractActive(item.task_index),
            "taskContractDeadline": self._getTaskContractDeadline(item.task_index),
            "taskContractRemaining": self._getTaskContractRemaining(item.task_index),
            "taskContractBreached": self._isTaskContractBreached(item.task_index),
            "taskContractPunishment": self._getTaskContractPunishment(item.task_index),
        }

    def getItemAt(self, x: float, y: float) -> Optional[str]:
        for item in reversed(self._items):
            if item.x <= x <= item.x + item.width and item.y <= y <= item.y + item.height:
                return item.id
        return None

    def getItemAtWithMargin(self, x: float, y: float, margin: float) -> Optional[str]:
        """Find item at position with enlarged hit area.

        Args:
            x: X coordinate in diagram space.
            y: Y coordinate in diagram space.
            margin: Extra pixels to extend hit area on each side.

        Returns:
            Item ID if found, None otherwise.
        """
        for item in reversed(self._items):
            if (item.x - margin <= x <= item.x + item.width + margin and
                    item.y - margin <= y <= item.y + item.height + margin):
                return item.id
        return None

    def _item_center(self, item: DiagramItem) -> tuple[float, float]:
        return (item.x + (item.width / 2.0), item.y + (item.height / 2.0))

    @Slot(str, str, result=str)
    def findNearestConnectedItemInDirection(self, item_id: str, direction: str) -> str:
        """Find the nearest directly-connected item in the requested direction.

        Direction can be: "left", "right", "up", or "down".
        Edges are treated as undirected for keyboard navigation.
        """
        source = self.getItem(item_id)
        if source is None:
            return ""

        direction_key = (direction or "").strip().lower()
        if direction_key not in {"left", "right", "up", "down"}:
            return ""

        connected_ids: set[str] = set()
        for edge in self._edges:
            if edge.from_id == item_id:
                connected_ids.add(edge.to_id)
            elif edge.to_id == item_id:
                connected_ids.add(edge.from_id)

        if not connected_ids:
            return ""

        source_x, source_y = self._item_center(source)
        best_item_id = ""
        best_score: Optional[tuple[float, float, float]] = None

        for candidate in self._items:
            if candidate.id not in connected_ids:
                continue

            cand_x, cand_y = self._item_center(candidate)
            dx = cand_x - source_x
            dy = cand_y - source_y
            euclidean = math.hypot(dx, dy)
            if euclidean <= 0.0:
                continue

            if direction_key == "left":
                primary = -dx
                lateral = abs(dy)
            elif direction_key == "right":
                primary = dx
                lateral = abs(dy)
            elif direction_key == "up":
                primary = -dy
                lateral = abs(dx)
            else:  # down
                primary = dy
                lateral = abs(dx)

            if primary <= 0.0:
                continue

            # Steer primarily by direction alignment, then by distance.
            alignment_penalty = lateral / primary
            score = (alignment_penalty, euclidean, primary)
            if best_score is None or score < best_score:
                best_score = score
                best_item_id = candidate.id

        return best_item_id

    @Slot(str, str, result=str)
    def findNearestConnectedTaskInDirection(self, item_id: str, direction: str) -> str:
        """Backward-compatible alias for item navigation."""
        return self.findNearestConnectedItemInDirection(item_id, direction)

    @Slot(float, float, result=str)
    def itemIdAt(self, x: float, y: float) -> str:
        return self.getItemAt(x, y) or ""

    @Slot()
    def connectAllItems(self) -> None:
        if len(self._items) < 2:
            return
        ordered = sorted(self._items, key=lambda item: (item.y, item.x))
        for idx in range(len(ordered) - 1):
            self.addEdge(ordered[idx].id, ordered[idx + 1].id)

    def _reset_edge_state(self) -> None:
        changed = (
            self._edge_source_id is not None
            or self._is_dragging_edge
            or bool(self._drag_insert_edge_id)
        )
        self._edge_source_id = None
        self._edge_drag_x = 0.0
        self._edge_drag_y = 0.0
        self._is_dragging_edge = False
        self._edge_hover_target_id = ""
        self._drag_insert_edge_id = ""
        if changed:
            self.itemsChanged.emit()

    # --- Serialization ------------------------------------------------------
    def to_dict(self) -> Dict[str, Any]:
        """Serialize diagram to a dictionary for saving.

        Returns:
            Dictionary containing all diagram item and edge data.
        """
        items_data = []
        for item in self._items:
            item_dict = {
                "id": item.id,
                "item_type": item.item_type.value,
                "x": item.x,
                "y": item.y,
                "width": item.width,
                "height": item.height,
                "text": item.text,
                "task_index": item.task_index,
                "color": item.color,
                "text_color": item.text_color,
            }
            # Only store image_data if it's an image item (to avoid bloating files)
            if item.item_type == DiagramItemType.IMAGE and item.image_data:
                item_dict["image_data"] = item.image_data
            if item.note_markdown and item.item_type != DiagramItemType.NOTE:
                item_dict["note_markdown"] = item.note_markdown
            if item.obstacle_markdown:
                item_dict["obstacle_markdown"] = item.obstacle_markdown
            if item.note_tabs:
                item_dict["note_tabs"] = normalize_editor_tabs(
                    item.note_tabs,
                    fallback_text=item.text if item.item_type == DiagramItemType.NOTE else item.note_markdown,
                )
            if item.obstacle_tabs:
                item_dict["obstacle_tabs"] = normalize_editor_tabs(item.obstacle_tabs, fallback_text=item.obstacle_markdown)
            if item.text_tabs:
                item_dict["text_tabs"] = normalize_editor_tabs(item.text_tabs, fallback_text=item.text)
            if item.text_tab_index:
                item_dict["text_tab_index"] = self._clamp_text_tab_index(item)
            if item.folder_path:
                item_dict["folder_path"] = item.folder_path
            items_data.append(item_dict)

        edges_data = []
        for edge in self._edges:
            edges_data.append({
                "id": edge.id,
                "from_id": edge.from_id,
                "to_id": edge.to_id,
                "description": edge.description,
            })

        strokes_data = []
        for stroke in self._strokes:
            strokes_data.append({
                "id": stroke.id,
                "color": stroke.color,
                "width": stroke.width,
                "points": [{"x": pt.x, "y": pt.y} for pt in stroke.points],
            })

        return {
            "items": items_data,
            "edges": edges_data,
            "strokes": strokes_data,
            "current_task_index": self._current_task_index,
        }

    def from_dict(self, data: Dict[str, Any]) -> None:
        """Load diagram from a dictionary.

        Args:
            data: Dictionary containing diagram data (from to_dict).
        """
        # Set current task index up front so new items render with correct state.
        self._current_task_index = int(data.get("current_task_index", -1))

        # Clear existing items, edges, and strokes
        if self._items:
            self.beginRemoveRows(QModelIndex(), 0, len(self._items) - 1)
            self._items.clear()
            self.endRemoveRows()
        self._edges.clear()
        self._strokes.clear()
        self._current_stroke = None

        # Track highest ID number to resume ID generation
        max_id = 0

        # Load items with batch insertion
        items_data = data.get("items", [])
        if items_data:
            new_items = []
            for item_data in items_data:
                item_id = item_data.get("id", "")
                # Extract numeric part from item ID to track max
                try:
                    id_parts = item_id.rsplit("_", 1)
                    if len(id_parts) == 2:
                        max_id = max(max_id, int(id_parts[1]) + 1)
                except (ValueError, IndexError):
                    pass

                item_type_str = item_data.get("item_type", "box")
                try:
                    item_type = DiagramItemType(item_type_str)
                except ValueError:
                    item_type = DiagramItemType.BOX

                text_value = str(item_data.get("text", ""))
                note_markdown_value = str(item_data.get("note_markdown", ""))
                if item_type == DiagramItemType.NOTE and note_markdown_value:
                    # Legacy compatibility: old notes stored main content in note_markdown.
                    text_value = note_markdown_value
                    note_markdown_value = ""

                item = DiagramItem(
                    id=item_id,
                    item_type=item_type,
                    x=float(item_data.get("x", 0.0)),
                    y=float(item_data.get("y", 0.0)),
                    width=float(item_data.get("width", 120.0)),
                    height=float(item_data.get("height", 60.0)),
                    text=text_value,
                    task_index=int(item_data.get("task_index", -1)),
                    color=item_data.get("color", "#4a9eff"),
                    text_color=item_data.get("text_color", "#f5f6f8"),
                    image_data=item_data.get("image_data", ""),
                    note_markdown=note_markdown_value,
                    obstacle_markdown=str(item_data.get("obstacle_markdown", "")),
                    folder_path=item_data.get("folder_path", ""),
                    note_tabs=normalize_editor_tabs(item_data.get("note_tabs"), fallback_text=text_value if item_type == DiagramItemType.NOTE else note_markdown_value),
                    obstacle_tabs=normalize_editor_tabs(item_data.get("obstacle_tabs"), fallback_text=str(item_data.get("obstacle_markdown", ""))),
                    text_tabs=normalize_editor_tabs(item_data.get("text_tabs"), fallback_text=text_value),
                    text_tab_index=max(0, int(item_data.get("text_tab_index", 0) or 0)),
                )
                item.text_tab_index = self._clamp_text_tab_index(item)
                new_items.append(item)

            # Batch insert all items at once
            self.beginInsertRows(QModelIndex(), 0, len(new_items) - 1)
            self._items.extend(new_items)
            self.endInsertRows()

        # Update ID source to avoid collisions
        self._id_source = count(max_id)

        # Load edges
        edges_data = data.get("edges", [])
        for edge_data in edges_data:
            edge = DiagramEdge(
                id=edge_data.get("id", ""),
                from_id=edge_data.get("from_id", ""),
                to_id=edge_data.get("to_id", ""),
                description=edge_data.get("description", ""),
            )
            self._edges.append(edge)

        # Load strokes
        self._strokes.clear()
        max_stroke_id = 0
        strokes_data = data.get("strokes", [])
        for stroke_data in strokes_data:
            stroke_id = stroke_data.get("id", "")
            # Track max stroke ID
            try:
                id_parts = stroke_id.rsplit("_", 1)
                if len(id_parts) == 2:
                    max_stroke_id = max(max_stroke_id, int(id_parts[1]) + 1)
            except (ValueError, IndexError):
                pass

            points = []
            for pt_data in stroke_data.get("points", []):
                points.append(DrawingPoint(
                    x=float(pt_data.get("x", 0.0)),
                    y=float(pt_data.get("y", 0.0)),
                ))

            stroke = DrawingStroke(
                id=stroke_id,
                points=points,
                color=stroke_data.get("color", "#ffffff"),
                width=float(stroke_data.get("width", 3.0)),
            )
            self._strokes.append(stroke)

        self._stroke_id_source = count(max_stroke_id)

        self.itemsChanged.emit()
        self.edgesChanged.emit()
        self.drawingChanged.emit()
        self.currentTaskChanged.emit()
