# mini_notepadpp_tabs.py
# PyQt6 mini Notepad++-like editor (single file)
#
# Features (kept):
# - Tabs (multi-file)
# - Line numbers
# - Syntax highlighting: .txt .py .bat .html/.htm .json .xml .md
# - Open / Save / Save As (Ctrl+O / Ctrl+S / Ctrl+Shift+S)
# - Copy/Cut/Paste (Ctrl+C/X/V)
# - Find (Ctrl+F) / Replace (Ctrl+H)
# - Auto-indentation (Python ':' adds 4 spaces)
# - Comment/Uncomment selection (Ctrl+/) with correct style per extension:
#   .py -> '#', .bat -> 'REM ', .html/.xml/.md -> <!-- ... --> (wrap), .json -> refused
# - Show/Hide special characters (spaces/tabs + EOL markers) (Ctrl+Shift+8)
#   EOL markers differ for LF vs CRLF: LF shows 'LF', CRLF shows 'CR LF' (grey) like Notepad++
# - Distinguish and preserve EOL style per file (LF vs CRLF), displayed in status bar
# - Convert EOL: menu "Fins de ligne" -> LF / CRLF / Toggle (CRLF/LF)
# - Drag & drop file(s) onto the window to open in tab (fix: editor doesn't accept drops)
# - No '*' anywhere in tab titles

import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

from AnyQt.QtCore import Qt, QRect, QSize, QRegularExpression
from AnyQt.QtGui import (
    QAction,
    QColor,
    QFont,
    QKeySequence,
    QPainter,
    QTextCharFormat,
    QSyntaxHighlighter,
    QTextCursor,
    QTextDocument,
    QTextOption,
)
from AnyQt.QtWidgets import (
    QApplication,
    QCheckBox,
    QComboBox,
    QDialog,
    QFileDialog,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QPlainTextEdit,
    QStatusBar,
    QTabWidget,
    QToolBar,
    QVBoxLayout,
    QWidget,
)


def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
    f = QTextCharFormat()
    f.setForeground(QColor(color))
    if bold:
        f.setFontWeight(QFont.Weight.Bold)
    if italic:
        f.setFontItalic(True)
    return f


@dataclass
class Rule:
    pattern: QRegularExpression
    fmt: QTextCharFormat


class SimpleHighlighter(QSyntaxHighlighter):
    """Lightweight syntax highlighter (heuristic)."""

    def __init__(self, doc: QTextDocument):
        super().__init__(doc)
        self._rules: list[Rule] = []
        self.set_language("text")

    def set_language(self, language: str) -> None:
        self._rules = self._build_rules(language)
        self.rehighlight()

    def _build_rules(self, language: str) -> list[Rule]:
        rules: list[Rule] = []

        str_fmt = _fmt("#a31515")
        num_fmt = _fmt("#098658")
        kw_fmt = _fmt("#0000ff", bold=True)
        com_fmt = _fmt("#008000", italic=True)
        tag_fmt = _fmt("#800000", bold=True)
        attr_fmt = _fmt("#ff0000")
        builtin_fmt = _fmt("#795e26", bold=True)
        json_key_fmt = _fmt("#0451a5", bold=True)
        md_heading = _fmt("#0451a5", bold=True)
        md_code = _fmt("#a31515")
        md_quote = _fmt("#008000", italic=True)

        def add(rx: str, f: QTextCharFormat):
            rules.append(Rule(QRegularExpression(rx), f))

        if language == "python":
            add(r"#.*$", com_fmt)
            add(r"(\'\'\'[\s\S]*?\'\'\'|\"\"\"[\s\S]*?\"\"\")", str_fmt)
            add(r"(\'([^\'\\]|\\.)*\'|\"([^\"\\]|\\.)*\")", str_fmt)
            add(r"\b\d+(\.\d+)?([eE][+-]?\d+)?\b", num_fmt)

            py_keywords = [
                "False", "None", "True", "and", "as", "assert", "async", "await",
                "break", "class", "continue", "def", "del", "elif", "else", "except",
                "finally", "for", "from", "global", "if", "import", "in", "is",
                "lambda", "nonlocal", "not", "or", "pass", "raise", "return",
                "try", "while", "with", "yield", "match", "case",
            ]
            add(r"\b(" + "|".join(map(re.escape, py_keywords)) + r")\b", kw_fmt)

            py_builtins = [
                "print", "len", "range", "list", "dict", "set", "tuple", "str",
                "int", "float", "bool", "sum", "min", "max", "open", "enumerate",
                "zip", "map", "filter", "any", "all", "isinstance", "type",
                "super", "property",
            ]
            add(r"\b(" + "|".join(map(re.escape, py_builtins)) + r")\b", builtin_fmt)
            add(r"^\s*@\w+(\.\w+)*", _fmt("#aa00aa", bold=True))

        elif language == "json":
            add(r"\"([^\"\\]|\\.)*\"", str_fmt)
            add(r"\b-?\d+(\.\d+)?([eE][+-]?\d+)?\b", num_fmt)
            add(r"\b(true|false|null)\b", kw_fmt)
            add(r"\"([^\"\\]|\\.)*\"\s*(?=:)", json_key_fmt)
            add(r"[{}\[\],:]", _fmt("#000000"))

        elif language in ("html", "xml"):
            add(r"<!--[\s\S]*?-->", com_fmt)
            add(r"</?\b[a-zA-Z][a-zA-Z0-9:-]*\b", tag_fmt)
            add(r"/?>", tag_fmt)
            add(r"\b[a-zA-Z_:][a-zA-Z0-9:._-]*(?=\=)", attr_fmt)
            add(r"(\'([^\'\\]|\\.)*\'|\"([^\"\\]|\\.)*\")", str_fmt)

        elif language == "bat":
            add(r"^\s*(REM\b.*|::.*)$", com_fmt)
            add(r"^\s*:\w+.*$", _fmt("#800080", bold=True))
            add(r"%[^%]+%", _fmt("#0451a5", bold=True))
            bat_kw = [
                "echo", "set", "if", "else", "for", "in", "do", "call", "goto",
                "exit", "pause", "start", "cd", "dir", "copy", "del", "move",
                "mkdir", "rmdir", "pushd", "popd",
            ]
            add(r"\b(" + "|".join(map(re.escape, bat_kw)) + r")\b", kw_fmt)
            add(r"\"([^\"\\]|\\.)*\"", str_fmt)

        elif language == "md":
            add(r"^#{1,6}\s.*$", md_heading)
            add(r"^>\s.*$", md_quote)
            add(r"`[^`]+`", md_code)
            add(r"^\s*```.*$", md_code)
            add(r"\*\*[^*]+\*\*", _fmt("#795e26", bold=True))
            add(r"\*[^*]+\*", _fmt("#795e26", italic=True))

        return rules

    def highlightBlock(self, text: str) -> None:
        for rule in self._rules:
            it = rule.pattern.globalMatch(text)
            while it.hasNext():
                m = it.next()
                self.setFormat(m.capturedStart(), m.capturedLength(), rule.fmt)


class FindDialog(QDialog):
    def __init__(self, parent: QWidget, editor: QPlainTextEdit):
        super().__init__(parent)
        self.setWindowTitle("Rechercher (Ctrl+F)")
        self.editor = editor

        self.find_input = QLineEdit()
        self.case_cb = QCheckBox("Respecter la casse")
        self.word_cb = QCheckBox("Mot entier")
        self.regex_cb = QCheckBox("Regex")

        btn_next = QPushButton("Suivant")
        btn_prev = QPushButton("Précédent")
        btn_close = QPushButton("Fermer")

        btn_next.clicked.connect(lambda: self._find(forward=True))
        btn_prev.clicked.connect(lambda: self._find(forward=False))
        btn_close.clicked.connect(self.close)

        layout = QVBoxLayout()
        layout.addWidget(QLabel("Rechercher :"))
        layout.addWidget(self.find_input)

        opts = QHBoxLayout()
        opts.addWidget(self.case_cb)
        opts.addWidget(self.word_cb)
        opts.addWidget(self.regex_cb)
        layout.addLayout(opts)

        btns = QHBoxLayout()
        btns.addWidget(btn_next)
        btns.addWidget(btn_prev)
        btns.addStretch(1)
        btns.addWidget(btn_close)
        layout.addLayout(btns)

        self.setLayout(layout)
        self.find_input.returnPressed.connect(lambda: self._find(True))

    def _find(self, forward: bool) -> None:
        needle = self.find_input.text()
        if not needle:
            return

        flags = QTextDocument.FindFlag(0)
        if self.case_cb.isChecked():
            flags |= QTextDocument.FindFlag.FindCaseSensitively
        if self.word_cb.isChecked():
            flags |= QTextDocument.FindFlag.FindWholeWords
        if not forward:
            flags |= QTextDocument.FindFlag.FindBackward

        doc = self.editor.document()
        cur = self.editor.textCursor()

        found = doc.find(QRegularExpression(needle), cur, flags) if self.regex_cb.isChecked() else doc.find(needle, cur, flags)

        if found.isNull():
            # wrap-around
            cur.movePosition(cur.MoveOperation.End if not forward else cur.MoveOperation.Start)
            self.editor.setTextCursor(cur)
            found = doc.find(QRegularExpression(needle), cur, flags) if self.regex_cb.isChecked() else doc.find(needle, cur, flags)

        if not found.isNull():
            self.editor.setTextCursor(found)


class ReplaceDialog(QDialog):
    def __init__(self, parent: QWidget, editor: QPlainTextEdit):
        super().__init__(parent)
        self.setWindowTitle("Rechercher et remplacer (Ctrl+H)")
        self.editor = editor

        self.find_input = QLineEdit()
        self.replace_input = QLineEdit()
        self.case_cb = QCheckBox("Respecter la casse")
        self.word_cb = QCheckBox("Mot entier")
        self.regex_cb = QCheckBox("Regex")

        btn_find = QPushButton("Suivant")
        btn_replace = QPushButton("Remplacer")
        btn_replace_all = QPushButton("Tout remplacer")
        btn_close = QPushButton("Fermer")

        btn_find.clicked.connect(self._find_next)
        btn_replace.clicked.connect(self._replace_one)
        btn_replace_all.clicked.connect(self._replace_all)
        btn_close.clicked.connect(self.close)

        layout = QVBoxLayout()
        layout.addWidget(QLabel("Rechercher :"))
        layout.addWidget(self.find_input)
        layout.addWidget(QLabel("Remplacer par :"))
        layout.addWidget(self.replace_input)

        opts = QHBoxLayout()
        opts.addWidget(self.case_cb)
        opts.addWidget(self.word_cb)
        opts.addWidget(self.regex_cb)
        layout.addLayout(opts)

        btns = QHBoxLayout()
        btns.addWidget(btn_find)
        btns.addWidget(btn_replace)
        btns.addWidget(btn_replace_all)
        btns.addStretch(1)
        btns.addWidget(btn_close)
        layout.addLayout(btns)

        self.setLayout(layout)
        self.find_input.returnPressed.connect(self._find_next)

    def _flags(self) -> QTextDocument.FindFlag:
        flags = QTextDocument.FindFlag(0)
        if self.case_cb.isChecked():
            flags |= QTextDocument.FindFlag.FindCaseSensitively
        if self.word_cb.isChecked():
            flags |= QTextDocument.FindFlag.FindWholeWords
        return flags

    def _find_next(self) -> None:
        needle = self.find_input.text()
        if not needle:
            return
        doc = self.editor.document()
        cur = self.editor.textCursor()
        flags = self._flags()
        found = doc.find(QRegularExpression(needle), cur, flags) if self.regex_cb.isChecked() else doc.find(needle, cur, flags)
        if found.isNull():
            cur.movePosition(cur.MoveOperation.Start)
            self.editor.setTextCursor(cur)
            found = doc.find(QRegularExpression(needle), cur, flags) if self.regex_cb.isChecked() else doc.find(needle, cur, flags)
        if not found.isNull():
            self.editor.setTextCursor(found)

    def _replace_one(self) -> None:
        cur = self.editor.textCursor()
        if not cur.hasSelection():
            self._find_next()
            cur = self.editor.textCursor()
            if not cur.hasSelection():
                return
        cur.insertText(self.replace_input.text())
        self.editor.setTextCursor(cur)
        self._find_next()

    def _replace_all(self) -> None:
        needle = self.find_input.text()
        if not needle:
            return
        repl = self.replace_input.text()

        text = self.editor.toPlainText()
        pattern = needle if self.regex_cb.isChecked() else re.escape(needle)

        flags = 0
        if not self.case_cb.isChecked():
            flags |= re.IGNORECASE
        if self.word_cb.isChecked():
            pattern = r"\b" + pattern + r"\b"

        try:
            new_text, n = re.subn(pattern, repl, text, flags=flags)
        except re.error as e:
            QMessageBox.warning(self, "Regex invalide", str(e))
            return

        self.editor.setPlainText(new_text)
        QMessageBox.information(self, "Remplacement", f"{n} occurrence(s) remplacée(s).")


class LineNumberArea(QWidget):
    def __init__(self, editor: "CodeEditor"):
        super().__init__(editor)
        self.code_editor = editor

    def sizeHint(self) -> QSize:
        return QSize(self.code_editor.line_number_area_width(), 0)

    def paintEvent(self, event):
        self.code_editor.line_number_area_paint_event(event)


class CodeEditor(QPlainTextEdit):
    def __init__(self):
        super().__init__()
        self.setAcceptDrops(False)  # IMPORTANT: avoid dropping filenames into editor

        self._path: Optional[Path] = None
        self._language = "text"
        self._highlighter = SimpleHighlighter(self.document())

        # EOL mode per tab
        self.eol_mode = "LF"  # "LF" or "CRLF"

        # Special chars toggle (spaces/tabs + our EOL markers)
        self._show_special = False

        self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
        self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))

        font = QFont("Consolas")
        font.setStyleHint(QFont.StyleHint.Monospace)
        font.setPointSize(11)
        self.setFont(font)

        # Line numbers
        self.line_number_area = LineNumberArea(self)
        self.blockCountChanged.connect(self.update_line_number_area_width)
        self.updateRequest.connect(self.update_line_number_area)
        self.update_line_number_area_width(0)

        self.apply_show_special_chars(False)

    # ---------- Path / language ----------
    def path(self) -> Optional[Path]:
        return self._path

    def set_path(self, p: Optional[Path]) -> None:
        self._path = p
        self.apply_language("auto")

    def detect_language(self) -> str:
        if not self._path:
            return "text"
        ext = self._path.suffix.lower()
        return {
            ".py": "python",
            ".json": "json",
            ".html": "html",
            ".htm": "html",
            ".xml": "xml",
            ".bat": "bat",
            ".md": "md",
            ".txt": "text",
        }.get(ext, "text")

    def apply_language(self, language: str) -> None:
        if language == "auto":
            language = self.detect_language()
        self._language = language
        self._highlighter.set_language(language)

    def language(self) -> str:
        return self._language

    # ---------- EOL detection / preservation ----------
    def set_text_from_file_bytes(self, raw_bytes: bytes) -> None:
        try:
            s = raw_bytes.decode("utf-8")
        except UnicodeDecodeError:
            s = raw_bytes.decode("latin-1", errors="replace")

        # ✅ Détection robuste (dominante) LF vs CRLF, et support des fichiers mixtes
        crlf_count = s.count("\r\n")
        # Compter les '\n' qui ne font PAS partie d'un '\r\n'
        lf_count = s.count("\n") - crlf_count
        # (optionnel) anciens Mac: lignes séparées par '\r' seuls
        cr_only_count = s.count("\r") - crlf_count

        if crlf_count > lf_count and crlf_count > 0:
            self.eol_mode = "CRLF"
        elif lf_count > 0:
            self.eol_mode = "LF"
        elif cr_only_count > 0:
            # Rare mais on évite de mentir: on ramène en LF côté interne
            self.eol_mode = "LF"
            s = s.replace("\r", "\n")
        else:
            # Fichier 1 ligne sans fin de ligne -> garder défaut
            # (ne surtout pas forcer CRLF)
            pass

        # Normalisation interne: tout en '\n' pour l'éditeur
        s = s.replace("\r\n", "\n")
        self.setPlainText(s)

    def text_for_saving(self) -> str:
        s = self.toPlainText()

        if self.eol_mode == "CRLF":
            # SIMPLE: enlever TOUS les '\r' puis convertir les '\n' en '\r\n'
            s = s.replace("\r", "")
            s = s.replace("\n", "\r\n")
            return s

        # LF: enlever TOUS les '\r' (au cas où) et garder '\n'
        return s.replace("\r", "")
    # ---------- Special chars display ----------
    def apply_show_special_chars(self, enabled: bool) -> None:
        """Show tabs/spaces via QTextOption + draw EOL markers ourselves."""
        self._show_special = enabled

        opt = self.document().defaultTextOption()
        flags = opt.flags()
        flags &= ~QTextOption.Flag.ShowTabsAndSpaces
        flags &= ~QTextOption.Flag.ShowLineAndParagraphSeparators
        flags &= ~QTextOption.Flag.ShowDocumentTerminator

        if enabled:
            flags |= QTextOption.Flag.ShowTabsAndSpaces

        opt.setFlags(flags)
        self.document().setDefaultTextOption(opt)
        self.viewport().update()

    # ---------- Line numbers ----------
    def line_number_area_width(self) -> int:
        digits = len(str(max(1, self.blockCount())))
        return 10 + self.fontMetrics().horizontalAdvance("9") * digits

    def update_line_number_area_width(self, _):
        self.setViewportMargins(self.line_number_area_width(), 0, 0, 0)

    def update_line_number_area(self, rect, dy):
        if dy:
            self.line_number_area.scroll(0, dy)
        else:
            self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
        if rect.contains(self.viewport().rect()):
            self.update_line_number_area_width(0)
        if self._show_special:
            self.viewport().update()

    def resizeEvent(self, event):
        super().resizeEvent(event)
        cr = self.contentsRect()
        self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()))

    def line_number_area_paint_event(self, event):
        painter = QPainter(self.line_number_area)
        painter.fillRect(event.rect(), QColor("#f3f3f3"))

        block = self.firstVisibleBlock()
        block_number = block.blockNumber()
        top = int(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
        bottom = top + int(self.blockBoundingRect(block).height())

        while block.isValid() and top <= event.rect().bottom():
            if block.isVisible() and bottom >= event.rect().top():
                painter.setPen(QColor("#666666"))
                painter.drawText(
                    0,
                    top,
                    self.line_number_area.width() - 4,
                    self.fontMetrics().height(),
                    Qt.AlignmentFlag.AlignRight,
                    str(block_number + 1),
                )

            block = block.next()
            top = bottom
            bottom = top + int(self.blockBoundingRect(block).height())
            block_number += 1

    # ---------- Auto-indentation ----------
    def keyPressEvent(self, event):
        # --- Tab / Shift+Tab : indent/outdent la sélection (ou la ligne) ---
        if event.key() == Qt.Key.Key_Backtab:
            self.outdent_selection()
            return

        if event.key() == Qt.Key.Key_Tab and not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
            if event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
                self.outdent_selection()
            else:
                self.indent_selection()
            return
        if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
            cur = self.textCursor()
            cur.beginEditBlock()

            block_text = cur.block().text()
            indent = re.match(r"\s*", block_text).group(0)

            extra = ""
            if self._language == "python":
                stripped = block_text.strip()
                if stripped.endswith(":") and not stripped.startswith("#"):
                    extra = " " * 4

            super().keyPressEvent(event)
            cur = self.textCursor()
            cur.insertText(indent + extra)
            cur.endEditBlock()
            return

        super().keyPressEvent(event)



    # ---------- EOL markers painting ----------
    def paintEvent(self, event):
        super().paintEvent(event)

        if not self._show_special:
            return

        # Never crash the editor due to marker drawing
        try:
            painter = QPainter(self.viewport())
            painter.setPen(QColor("#9a9a9a"))
            fm = self.fontMetrics()
            tokens = ["LF"] if self.eol_mode == "LF" else ["CR", "LF"]

            block = self.firstVisibleBlock()
            while block.isValid():
                geom = self.blockBoundingGeometry(block).translated(self.contentOffset())
                top = int(geom.top())
                if top > self.viewport().height():
                    break

                if block.isVisible():
                    tc = QTextCursor(block)
                    tc.movePosition(QTextCursor.MoveOperation.EndOfBlock)
                    r = self.cursorRect(tc)
                    x = r.right() + 2
                    y = r.top() + fm.ascent()

                    if -fm.height() <= y <= self.viewport().height() + fm.height():
                        for tok in tokens:
                            painter.drawText(x, y, tok)
                            x += fm.horizontalAdvance(tok) + fm.horizontalAdvance(" ")
                block = block.next()
        except Exception:
            return

    # ==========================================================
    # ADD: Indent / Outdent selection (per-file indent unit)
    # ==========================================================
    def _indent_unit(self) -> str:
        """
        Choisit l'unité d'indentation pour CE document.
        1) Détecte tabs/espaces en scannant les lignes (dominant)
        2) Sinon fallback par langage/extension gérés par ton code
        """
        txt = self.toPlainText()
        tab_hits = 0
        space_indents = []

        for line in txt.splitlines()[:5000]:
            if not line:
                continue
            if line.startswith("\t"):
                tab_hits += 1
                continue
            if line.startswith(" "):
                n = len(line) - len(line.lstrip(" "))
                if n > 0:
                    space_indents.append(n)

        # Si tabs visibles et majoritaires → tabs
        if tab_hits > 0 and tab_hits >= len(space_indents):
            return "\t"

        # Sinon, espaces : estimer le pas, sinon fallback par langage
        step = 0
        if space_indents:
            m = min(space_indents)
            if m in (2, 4, 8):
                step = m
            else:
                step = 4 if m >= 4 else 2

        if step <= 0:
            lang = self.detect_language()
            # Fallbacks raisonnables par format (basés sur conventions courantes)
            # - python: 4
            # - json/html/xml: 2 (souvent)
            # - md: 4 (souvent) ; txt: 4 ; bat: 4
            fallback = {
                "python": 4,
                "json": 2,
                "html": 2,
                "xml": 2,
                "md": 4,
                "bat": 4,
                "text": 4,
            }
            step = fallback.get(lang, 4)

        return " " * step

    def _selected_block_range(self, cursor: QTextCursor):
        """
        Retourne (start_block_number, end_block_number) couvrant la sélection.
        Si la sélection finit pile au début d'une ligne, on n'inclut pas cette ligne.
        """
        doc = self.document()
        start = cursor.selectionStart()
        end = cursor.selectionEnd()

        c1 = QTextCursor(doc)
        c1.setPosition(start)
        start_block = c1.blockNumber()

        c2 = QTextCursor(doc)
        c2.setPosition(end)
        if end > start and c2.atBlockStart():
            c2.movePosition(QTextCursor.MoveOperation.PreviousBlock)
        end_block = c2.blockNumber()

        return start_block, end_block

    def indent_selection(self) -> None:
        cur = self.textCursor()
        if not cur.hasSelection():
            cur.select(QTextCursor.SelectionType.LineUnderCursor)

        unit = self._indent_unit()
        doc = self.document()
        start_block, end_block = self._selected_block_range(cur)

        cur.beginEditBlock()
        for block_no in range(start_block, end_block + 1):
            block = doc.findBlockByNumber(block_no)
            if not block.isValid():
                continue
            c = QTextCursor(block)
            c.movePosition(QTextCursor.MoveOperation.StartOfBlock)
            c.insertText(unit)
        cur.endEditBlock()

    def outdent_selection(self) -> None:
        cur = self.textCursor()
        if not cur.hasSelection():
            cur.select(QTextCursor.SelectionType.LineUnderCursor)

        unit = self._indent_unit()
        is_tabs = (unit == "\t")
        unit_len = len(unit)

        doc = self.document()
        start_block, end_block = self._selected_block_range(cur)

        cur.beginEditBlock()
        for block_no in range(start_block, end_block + 1):
            block = doc.findBlockByNumber(block_no)
            if not block.isValid():
                continue

            text = block.text()
            if not text:
                continue

            c = QTextCursor(block)
            c.movePosition(QTextCursor.MoveOperation.StartOfBlock)

            # cas exact: unité détectée
            if text.startswith(unit):
                c.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, unit_len)
                c.removeSelectedText()
                continue

            # si mode espaces: retirer jusqu'à unit_len espaces
            if not is_tabs:
                n = 0
                while n < min(unit_len, len(text)) and text[n] == " ":
                    n += 1
                if n > 0:
                    c.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, n)
                    c.removeSelectedText()
                continue

            # mode tabs mais la ligne commence par espaces → ne rien faire (évite casser hybride)
        cur.endEditBlock()


class EditorMainWindow(QMainWindow):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("TigerODM — Notepad")
        self.resize(1200, 800)

        self.tabs = QTabWidget()
        self.tabs.setTabsClosable(True)
        self.tabs.tabCloseRequested.connect(self.close_tab)
        self.tabs.currentChanged.connect(self._on_tab_changed)
        self.tabs.setAcceptDrops(True)
        self.setCentralWidget(self.tabs)

        self.setAcceptDrops(True)  # allow dropping onto window

        self.status = QStatusBar()
        self.setStatusBar(self.status)

        self.find_dlg: Optional[FindDialog] = None
        self.replace_dlg: Optional[ReplaceDialog] = None

        self._show_special_chars = False

        self._make_actions()
        self._make_menus()
        self._make_toolbar()

        self.new_tab()

    def current_editor(self) -> Optional[CodeEditor]:
        w = self.tabs.currentWidget()
        return w if isinstance(w, CodeEditor) else None

    def _tab_title(self, ed: CodeEditor) -> str:
        return ed.path().name if ed.path() else "Sans titre"

    def _refresh_tab_titles(self):
        for i in range(self.tabs.count()):
            ed = self.tabs.widget(i)
            if isinstance(ed, CodeEditor):
                self.tabs.setTabText(i, self._tab_title(ed))

    def _update_status(self):
        ed = self.current_editor()
        if not ed:
            self.status.clearMessage()
            return
        name = ed.path().name if ed.path() else "Sans titre"
        self.status.showMessage(f"{name}  |  EOL: {ed.eol_mode}")

    # ---------- UI ----------
    def _make_actions(self):
        self.act_new = QAction("Nouveau", self)
        self.act_new.setShortcut(QKeySequence.StandardKey.New)
        self.act_new.triggered.connect(self.new_tab)

        self.act_open = QAction("Ouvrir…", self)
        self.act_open.setShortcut(QKeySequence.StandardKey.Open)
        self.act_open.triggered.connect(self.open_file)

        self.act_save = QAction("Enregistrer", self)
        self.act_save.setShortcut(QKeySequence.StandardKey.Save)
        self.act_save.triggered.connect(self.save_file)

        self.act_save_as = QAction("Enregistrer sous…", self)
        self.act_save_as.setShortcut(QKeySequence.StandardKey.SaveAs)
        self.act_save_as.triggered.connect(self.save_file_as)

        self.act_close_tab = QAction("Fermer l’onglet", self)
        self.act_close_tab.setShortcut(QKeySequence.StandardKey.Close)
        self.act_close_tab.triggered.connect(lambda: self.close_tab(self.tabs.currentIndex()))

        self.act_quit = QAction("Quitter", self)
        self.act_quit.setShortcut(QKeySequence.StandardKey.Quit)
        self.act_quit.triggered.connect(self.close)

        self.act_cut = QAction("Couper", self)
        self.act_cut.setShortcut(QKeySequence.StandardKey.Cut)
        self.act_cut.triggered.connect(lambda: self.current_editor().cut() if self.current_editor() else None)

        self.act_copy = QAction("Copier", self)
        self.act_copy.setShortcut(QKeySequence.StandardKey.Copy)
        self.act_copy.triggered.connect(lambda: self.current_editor().copy() if self.current_editor() else None)

        self.act_paste = QAction("Coller", self)
        self.act_paste.setShortcut(QKeySequence.StandardKey.Paste)
        self.act_paste.triggered.connect(lambda: self.current_editor().paste() if self.current_editor() else None)

        self.act_find = QAction("Rechercher…", self)
        self.act_find.setShortcut(QKeySequence.StandardKey.Find)
        self.act_find.triggered.connect(self.show_find)

        self.act_replace = QAction("Rechercher et remplacer…", self)
        self.act_replace.setShortcut(QKeySequence.StandardKey.Replace)
        self.act_replace.triggered.connect(self.show_replace)

        self.act_toggle_comment = QAction("Commenter/Décommenter", self)
        self.act_toggle_comment.setShortcut(QKeySequence("Ctrl+/"))
        self.act_toggle_comment.triggered.connect(self.toggle_comment_selection)

        self.act_toggle_special = QAction("Afficher les caractères spéciaux", self)
        self.act_toggle_special.setCheckable(True)
        self.act_toggle_special.setChecked(False)
        self.act_toggle_special.setShortcut(QKeySequence("Ctrl+Shift+8"))
        self.act_toggle_special.triggered.connect(self.toggle_special_chars)

        # EOL conversion
        self.act_eol_lf = QAction("Convertir fins de ligne → LF (\\n)", self)
        self.act_eol_lf.triggered.connect(lambda: self.convert_eol("LF"))

        self.act_eol_crlf = QAction("Convertir fins de ligne → CRLF (\\r\\n)", self)
        self.act_eol_crlf.triggered.connect(lambda: self.convert_eol("CRLF"))

        self.act_eol_toggle = QAction("Convertir fins de ligne → CRLF/LF (basculer)", self)
        self.act_eol_toggle.triggered.connect(lambda: self.convert_eol("TOGGLE"))

        # Language override (per tab)
        self.lang_combo = QComboBox()
        self.lang_combo.addItems(["auto", "text", "python", "json", "html", "xml", "md", "bat"])
        self.lang_combo.currentTextChanged.connect(self._on_lang_changed)


        # ----------------------------------------------------------
        self.act_indent = QAction("Indenter", self)
        self.act_indent.setShortcut(QKeySequence("TAB"))
        self.act_indent.triggered.connect(self.indent_selection)

        self.act_outdent = QAction("Désindenter", self)
        self.act_outdent.setShortcut(QKeySequence("SHIFT+TAB"))
        self.act_outdent.triggered.connect(self.outdent_selection)

    def _make_menus(self):
        m_file = self.menuBar().addMenu("Fichier")
        m_file.addAction(self.act_new)
        m_file.addAction(self.act_open)
        m_file.addSeparator()
        m_file.addAction(self.act_save)
        m_file.addAction(self.act_save_as)
        m_file.addSeparator()
        m_file.addAction(self.act_close_tab)
        m_file.addSeparator()
        m_file.addAction(self.act_quit)

        m_edit = self.menuBar().addMenu("Édition")
        m_edit.addAction(self.act_cut)
        m_edit.addAction(self.act_copy)
        m_edit.addAction(self.act_paste)
        m_edit.addSeparator()
        m_edit.addAction(self.act_find)
        m_edit.addAction(self.act_replace)
        m_edit.addSeparator()
        m_edit.addAction(self.act_toggle_comment)

        # ----------------------------------------------------------
        # ADD: Indent/Outdent menu items
        # ----------------------------------------------------------
        m_edit.addSeparator()
        m_edit.addAction(self.act_indent)
        m_edit.addAction(self.act_outdent)

        m_view = self.menuBar().addMenu("Affichage")
        m_view.addAction(self.act_toggle_special)

        m_eol = self.menuBar().addMenu("Fins de ligne")
        m_eol.addAction(self.act_eol_lf)
        m_eol.addAction(self.act_eol_crlf)
        m_eol.addSeparator()
        m_eol.addAction(self.act_eol_toggle)

    def _make_toolbar(self):
        tb = QToolBar("Main")
        self.addToolBar(tb)
        tb.addAction(self.act_new)
        tb.addAction(self.act_open)
        tb.addAction(self.act_save)
        tb.addAction(self.act_save_as)
        tb.addSeparator()
        tb.addAction(self.act_find)
        tb.addAction(self.act_replace)
        tb.addSeparator()
        tb.addAction(self.act_toggle_comment)

        # ----------------------------------------------------------
        # ADD: Indent/Outdent buttons on toolbar
        # ----------------------------------------------------------
        tb.addAction(self.act_indent)
        tb.addAction(self.act_outdent)

        tb.addSeparator()
        tb.addAction(self.act_toggle_special)
        tb.addSeparator()
        tb.addWidget(QLabel("Langage: "))
        tb.addWidget(self.lang_combo)

    # ----------------------------------------------------------
    # ADD: Indent/Outdent handlers (delegate to current editor)
    # ----------------------------------------------------------
    def indent_selection(self):
        ed = self.current_editor()
        if not ed:
            return
        ed.indent_selection()

    def outdent_selection(self):
        ed = self.current_editor()
        if not ed:
            return
        ed.outdent_selection()

    # ---------- Tabs ----------
    def new_tab(self):
        ed = CodeEditor()
        ed.apply_show_special_chars(self._show_special_chars)
        ed.textChanged.connect(self._update_status)
        idx = self.tabs.addTab(ed, self._tab_title(ed))
        self.tabs.setCurrentIndex(idx)
        self._sync_lang_combo()
        self._update_status()

    def close_tab(self, index: int):
        if index < 0 or index >= self.tabs.count():
            return

        ed = self.tabs.widget(index)
        if isinstance(ed, CodeEditor) and ed.document().isModified():
            r = QMessageBox.question(
                self,
                "Modifications non enregistrées",
                f"Enregistrer avant de fermer “{ed.path().name if ed.path() else 'Sans titre'}” ?",
                QMessageBox.StandardButton.Yes
                | QMessageBox.StandardButton.No
                | QMessageBox.StandardButton.Cancel,
            )
            if r == QMessageBox.StandardButton.Cancel:
                return
            if r == QMessageBox.StandardButton.Yes:
                if not self._save_editor(ed, save_as=False):
                    return

        self.tabs.removeTab(index)
        if self.tabs.count() == 0:
            self.new_tab()
        self._update_status()

    def _on_tab_changed(self, _):
        self._sync_lang_combo()
        self._update_status()

    def _sync_lang_combo(self):
        ed = self.current_editor()
        if not ed:
            return
        self.lang_combo.blockSignals(True)
        self.lang_combo.setCurrentText("auto")
        ed.apply_language("auto")
        self.lang_combo.blockSignals(False)

        self.act_toggle_special.blockSignals(True)
        self.act_toggle_special.setChecked(self._show_special_chars)
        self.act_toggle_special.blockSignals(False)

    # ---------- Drag & Drop ----------
    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
        else:
            event.ignore()

    def dropEvent(self, event):
        if not event.mimeData().hasUrls():
            return
        for url in event.mimeData().urls():
            local = url.toLocalFile()
            if local:
                self.open_file_path(Path(local))
        event.acceptProposedAction()

    # ---------- Special chars ----------
    def toggle_special_chars(self, checked: bool):
        self._show_special_chars = checked
        for i in range(self.tabs.count()):
            ed = self.tabs.widget(i)
            if isinstance(ed, CodeEditor):
                ed.apply_show_special_chars(checked)
        self._update_status()

    # ---------- Language ----------
    def _on_lang_changed(self, choice: str):
        ed = self.current_editor()
        if not ed:
            return
        ed.apply_language(choice)

    # ---------- EOL conversion ----------
    def convert_eol(self, mode: str):
        ed = self.current_editor()
        if not ed:
            return

        if mode == "TOGGLE":
            mode = "LF" if ed.eol_mode == "CRLF" else "CRLF"

        if mode not in ("LF", "CRLF"):
            return

        if ed.eol_mode != mode:
            ed.eol_mode = mode
            ed.document().setModified(True)
            ed.viewport().update()
            self._update_status()
            self.status.showMessage(f"Fins de ligne converties → {mode}. (Sauvegarde requise)", 2500)
        else:
            self.status.showMessage(f"Déjà en {mode}.", 1500)

    # ---------- File IO ----------
    def open_file_path(self, p: Path):
        # focus if already open
        try:
            pr = p.resolve()
        except Exception:
            pr = p

        for i in range(self.tabs.count()):
            ed = self.tabs.widget(i)
            if isinstance(ed, CodeEditor) and ed.path():
                try:
                    if ed.path().resolve() == pr:
                        self.tabs.setCurrentIndex(i)
                        return
                except Exception:
                    pass

        try:
            raw = p.read_bytes()
        except Exception as e:
            QMessageBox.critical(self, "Erreur", str(e))
            return

        ed = CodeEditor()
        ed.apply_show_special_chars(self._show_special_chars)
        ed.set_text_from_file_bytes(raw)
        ed.set_path(p)
        ed.document().setModified(False)
        ed.textChanged.connect(self._update_status)

        idx = self.tabs.addTab(ed, self._tab_title(ed))
        self.tabs.setCurrentIndex(idx)

        ed.apply_language("auto")
        self._refresh_tab_titles()
        self._sync_lang_combo()
        self._update_status()

    def open_file(self):
        filt = "Fichiers supportés (*.txt *.py *.bat *.html *.htm *.json *.xml *.md);;Tous les fichiers (*.*)"
        path_str, _ = QFileDialog.getOpenFileName(self, "Ouvrir un fichier", "", filt)
        if not path_str:
            return
        self.open_file_path(Path(path_str))

    def save_file(self) -> bool:
        ed = self.current_editor()
        if not ed:
            return False
        return self._save_editor(ed, save_as=False)

    def save_file_as(self) -> bool:
        ed = self.current_editor()
        if not ed:
            return False
        return self._save_editor(ed, save_as=True)

    def _save_editor(self, ed: CodeEditor, save_as: bool) -> bool:
        p = ed.path()
        if save_as or p is None:
            filt = "Fichiers supportés (*.txt *.py *.bat *.html *.htm *.json *.xml *.md);;Tous les fichiers (*.*)"
            path_str, _ = QFileDialog.getSaveFileName(self, "Enregistrer sous", "", filt)
            if not path_str:
                return False
            p = Path(path_str)
            ed.set_path(p)
            self._refresh_tab_titles()

        try:
            if p.suffix.lower() == ".json":
                try:
                    json.loads(ed.toPlainText())
                except Exception as e:
                    r = QMessageBox.warning(
                        self,
                        "JSON invalide",
                        f"Le JSON semble invalide:\n{e}\n\nEnregistrer quand même ?",
                        QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    )
                    if r != QMessageBox.StandardButton.Yes:
                        return False

            with open(p, "w", encoding="utf-8", newline="") as f:
                f.write(ed.text_for_saving())
        except Exception as e:
            QMessageBox.critical(self, "Erreur", str(e))
            return False

        ed.document().setModified(False)
        self._update_status()
        self.status.showMessage(f"Enregistré  |  EOL: {ed.eol_mode}", 2000)
        return True

    # ---------- Find / Replace ----------
    def show_find(self):
        ed = self.current_editor()
        if not ed:
            return
        if self.find_dlg is None:
            self.find_dlg = FindDialog(self, ed)
        else:
            self.find_dlg.editor = ed
        self.find_dlg.show()
        self.find_dlg.raise_()
        self.find_dlg.activateWindow()
        self.find_dlg.find_input.setFocus()

    def show_replace(self):
        ed = self.current_editor()
        if not ed:
            return
        if self.replace_dlg is None:
            self.replace_dlg = ReplaceDialog(self, ed)
        else:
            self.replace_dlg.editor = ed
        self.replace_dlg.show()
        self.replace_dlg.raise_()
        self.replace_dlg.activateWindow()
        self.replace_dlg.find_input.setFocus()

    # ---------- Comment / Uncomment ----------
    def toggle_comment_selection(self):
        ed = self.current_editor()
        if not ed:
            return

        lang = ed.language()
        if lang == "text":
            QMessageBox.information(self, "Commentaire", "Aucun style de commentaire pour ce type de fichier.")
            return
        if lang == "json":
            QMessageBox.warning(
                self,
                "Commentaire JSON",
                "JSON standard n'accepte pas les commentaires.\nJe ne commente pas pour éviter de casser le JSON.",
            )
            return

        cur = ed.textCursor()
        if not cur.hasSelection():
            cur.select(QTextCursor.SelectionType.LineUnderCursor)

        start = cur.selectionStart()
        end = cur.selectionEnd()

        doc = ed.document()
        c1 = QTextCursor(doc)
        c1.setPosition(start)
        start_block = c1.block()

        c2 = QTextCursor(doc)
        c2.setPosition(end)
        end_block = c2.block()

        if lang in ("html", "xml", "md"):
            self._toggle_block_comment(ed, start, end, "<!--", "-->")
            ed.document().setModified(True)
            return

        if lang == "python":
            prefix = "#"
        elif lang == "bat":
            prefix = "REM "
        else:
            QMessageBox.information(self, "Commentaire", f"Langage non géré: {lang}")
            return

        blocks = []
        b = start_block
        while b.isValid():
            blocks.append(b)
            if b == end_block:
                break
            b = b.next()

        def is_commented(line: str) -> bool:
            s = line.lstrip()
            return s.startswith(prefix) if prefix != "#" else s.startswith("#")

        non_empty = [bl.text() for bl in blocks if bl.text().strip() != ""]
        do_uncomment = bool(non_empty) and all(is_commented(t) for t in non_empty)

        cur.beginEditBlock()
        for bl in blocks:
            line = bl.text()
            if line.strip() == "":
                continue
            tc = QTextCursor(bl)
            tc.movePosition(QTextCursor.MoveOperation.StartOfBlock)

            if do_uncomment:
                leading = re.match(r"\s*", line).group(0)
                rest = line[len(leading):]
                if rest.startswith(prefix):
                    tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, len(leading))
                    tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, len(prefix))
                    tc.removeSelectedText()
                elif prefix == "#" and rest.startswith("#"):
                    tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, len(leading))
                    tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor, 1)
                    tc.removeSelectedText()
            else:
                leading = re.match(r"\s*", line).group(0)
                tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, len(leading))
                tc.insertText(prefix)

        cur.endEditBlock()
        ed.setTextCursor(cur)
        ed.document().setModified(True)

    def _toggle_block_comment(self, ed: CodeEditor, start: int, end: int, open_tok: str, close_tok: str):
        doc = ed.document()
        s = min(start, end)
        e = max(start, end)

        text_all = ed.toPlainText()
        left_ws = len(re.match(r"[ \t]*", text_all[s:]).group(0))
        s2 = s + left_ws
        e2 = e

        wrapped = False
        if s2 >= len(open_tok) and text_all[s2 - len(open_tok):s2] == open_tok:
            if text_all[e2:e2 + len(close_tok)] == close_tok:
                wrapped = True

        ed.textCursor().beginEditBlock()
        if wrapped:
            tc2 = QTextCursor(doc)
            tc2.setPosition(e2)
            tc2.setPosition(e2 + len(close_tok), QTextCursor.MoveMode.KeepAnchor)
            tc2.removeSelectedText()

            tc1 = QTextCursor(doc)
            tc1.setPosition(s2 - len(open_tok))
            tc1.setPosition(s2, QTextCursor.MoveMode.KeepAnchor)
            tc1.removeSelectedText()
        else:
            tc3 = QTextCursor(doc)
            tc3.setPosition(e)
            tc3.insertText(close_tok)

            tc4 = QTextCursor(doc)
            tc4.setPosition(s)
            tc4.insertText(open_tok)
        ed.textCursor().endEditBlock()

    # ---------- Close ----------
    def closeEvent(self, event):
        for i in range(self.tabs.count()):
            ed = self.tabs.widget(i)
            if isinstance(ed, CodeEditor) and ed.document().isModified():
                self.tabs.setCurrentIndex(i)
                r = QMessageBox.question(
                    self,
                    "Modifications non enregistrées",
                    f"Enregistrer avant de quitter “{ed.path().name if ed.path() else 'Sans titre'}” ?",
                    QMessageBox.StandardButton.Yes
                    | QMessageBox.StandardButton.No
                    | QMessageBox.StandardButton.Cancel,
                )
                if r == QMessageBox.StandardButton.Cancel:
                    event.ignore()
                    return
                if r == QMessageBox.StandardButton.Yes:
                    if not self._save_editor(ed, save_as=False):
                        event.ignore()
                        return
        event.accept()


def main():
    app = QApplication(sys.argv)
    w = EditorMainWindow()
    w.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
