"""Simple static file HTTP server.

Usage:
  python serve.py --port 8000 --root "A:\\WorkProj\\OverPunk\\WikiServ\\Картотека"

Serves files from the specified root directory over http://localhost:<port>/.
Supports directory indexes (index.html) and generates a simple listing for folders.
Logs requests to serve.log for debugging path resolution issues.
"""

from __future__ import annotations

import argparse
import html
import io
import json
import os
import re
import subprocess
import sys
from email import policy
from email.parser import BytesParser
from pathlib import Path
from urllib.parse import unquote, quote, urlparse, parse_qs, urlencode
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import mimetypes
import datetime
import socket

# Optional morphological support (install pymorphy2 to enable)
try:
    import pymorphy2

    _MORPH = pymorphy2.MorphAnalyzer()
except Exception:
    _MORPH = None


def _log_entry(log_file: Path, raw_url: str, rel: str, path: Path, client_ip: str | None = None) -> None:
    ip = client_ip or "-"
    entry = (
        f"{datetime.datetime.now(datetime.timezone.utc).isoformat()} "
        f"IP={ip} RAW={raw_url} REL={rel} PATH={path} Exists={path.exists()}\n"
    )
    log_file.parent.mkdir(parents=True, exist_ok=True)
    with log_file.open("a", encoding="utf-8") as handle:
        handle.write(entry)



_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}


def _is_archive_file(file_path: Path) -> bool:
    name = file_path.name.lower()
    archive_suffixes = (
        ".zip",
        ".7z",
        ".rar",
        ".tar",
        ".tar.gz",
        ".tgz",
        ".tar.bz2",
        ".tbz2",
        ".tar.xz",
        ".txz",
    )
    return name.endswith(archive_suffixes)


def _find_single_release_archive(releases_dir: Path) -> Path | None:
    if not releases_dir.exists() or not releases_dir.is_dir():
        return None
    archives = sorted(
        [entry for entry in releases_dir.iterdir() if entry.is_file() and _is_archive_file(entry)],
        key=lambda p: p.name.lower(),
    )
    if len(archives) != 1:
        return None
    return archives[0]


def _normalize_newlines(value: str, newline: str = "\r\n") -> str:
    normalized = value.replace("\r\n", "\n").replace("\r", "\n")
    return normalized.replace("\n", newline)


def _detect_newline_from_bytes(raw_bytes: bytes) -> str:
    data = raw_bytes[3:] if raw_bytes.startswith(b"\xef\xbb\xbf") else raw_bytes
    if b"\r\n" in data:
        return "\r\n"
    if b"\n" in data:
        return "\n"
    if b"\r" in data:
        return "\r"
    return "\r\n"


def _newline_stats_from_bytes(raw_bytes: bytes) -> tuple[int, int, int]:
    data = raw_bytes[3:] if raw_bytes.startswith(b"\xef\xbb\xbf") else raw_bytes
    crlf = data.count(b"\r\n")
    remaining = data.replace(b"\r\n", b"")
    lf = remaining.count(b"\n")
    cr = remaining.count(b"\r")
    return crlf, lf, cr


def _newline_name(value: str) -> str:
    if value == "\r\n":
        return "CRLF"
    if value == "\n":
        return "LF"
    if value == "\r":
        return "CR"
    return "UNKNOWN"


def _log_save_diagnostics(
    log_file: Path,
    file_path: Path,
    old_bytes: bytes,
    new_bytes: bytes,
    selected_newline: str,
    preserved_bom: bool,
    changed: bool,
) -> None:
    old_crlf, old_lf, old_cr = _newline_stats_from_bytes(old_bytes)
    new_crlf, new_lf, new_cr = _newline_stats_from_bytes(new_bytes)
    entry = (
        f"{datetime.datetime.now(datetime.timezone.utc).isoformat()} "
        f"SAVE path={file_path.as_posix()} changed={changed} "
        f"old_bytes={len(old_bytes)} new_bytes={len(new_bytes)} "
        f"selected_eol={_newline_name(selected_newline)} preserved_bom={preserved_bom} "
        f"old_eol_counts(CRLF={old_crlf},LF={old_lf},CR={old_cr}) "
        f"new_eol_counts(CRLF={new_crlf},LF={new_lf},CR={new_cr})\n"
    )
    log_file.parent.mkdir(parents=True, exist_ok=True)
    with log_file.open("a", encoding="utf-8") as handle:
        handle.write(entry)


def _make_unique_child_path(parent_dir: Path, file_name: str) -> Path:
    name_path = Path(file_name)
    base_name = name_path.name
    candidate = parent_dir / base_name
    if not candidate.exists():
        return candidate

    stem = name_path.stem or "file"
    suffix = "".join(name_path.suffixes)
    counter = 2
    while True:
        candidate = parent_dir / f"{stem}-{counter}{suffix}"
        if not candidate.exists():
            return candidate
        counter += 1


def _slugify(value: str) -> str:
    cleaned = re.sub(r"[^\w\-]+", "-", value.strip(), flags=re.UNICODE)
    cleaned = re.sub(r"-+", "-", cleaned).strip("-")
    return cleaned or "section"


def _anchor_id_for_name(name: str, used: set[str]) -> str:
    base = _slugify(name)
    candidate = base
    counter = 2
    while candidate in used:
        candidate = f"{base}-{counter}"
        counter += 1
    used.add(candidate)
    return candidate


def _title_from_filename(name: str) -> str:
    stem = Path(name).stem
    return _collapse_spaces(stem.replace("_", " "))


def _target_for_file(root: Path, file_path: Path) -> str:
    root_resolved = root.resolve()
    file_resolved = file_path.resolve()
    try:
        rel_dir = file_resolved.parent.relative_to(root_resolved).as_posix()
    except ValueError:
        rel_dir = ""
    if rel_dir == ".":
        rel_dir = ""
    query = urlencode({"a": file_path.name}, quote_via=quote)
    if rel_dir:
        return f"{rel_dir}/?{query}"
    return f"?{query}"


def _find_repo_root(start: Path) -> Path | None:
    current = start.resolve()
    for candidate in [current] + list(current.parents):
        if (candidate / ".git").exists():
            return candidate
    return None


def _run_git_command(repo_root: Path, args: list[str]) -> str:
    try:
        result = subprocess.run(
            ["git"] + args,
            cwd=str(repo_root),
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError:
        return ""
    output = result.stdout.strip()
    if not output:
        output = result.stderr.strip()
    return output


def _build_href(target: str) -> str:
    if not target:
        return "/"
    path_part = target
    query = ""
    if "?" in target:
        path_part, query = target.split("?", 1)
    path_part = path_part.lstrip("/")
    if path_part:
        encoded_path = "/".join(quote(part) for part in path_part.split("/"))
        href = f"/{encoded_path}"
    else:
        href = "/"
    if query:
        href = f"{href}?{query}"
    return href


def _search_files(
    root: Path,
    query: str,
    max_results: int = 200,
    max_size_bytes: int = 2 * 1024 * 1024,
) -> list[dict[str, object]]:
    allowed_exts = {
        ".txt",
        ".md",
        ".markdown",
        ".rst",
        ".json",
        ".csv",
        ".tsv",
        ".xml",
        ".yml",
        ".yaml",
        ".html",
        ".htm",
    }
    terms = [t.strip().lower() for t in query.split() if t.strip()]
    if not terms:
        return []

    results: list[dict[str, object]] = []
    for file_path in root.rglob("*"):
        if not file_path.is_file():
            continue
        if file_path.suffix.lower() not in allowed_exts:
            continue
        try:
            if file_path.stat().st_size > max_size_bytes:
                continue
            text = file_path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            continue

        haystack = text.lower()
        if not all(term in haystack for term in terms):
            continue

        count = sum(haystack.count(term) for term in terms)
        rel = file_path.relative_to(root).as_posix()
        if file_path.suffix.lower() == ".txt":
            target = _target_for_file(root, file_path)
        else:
            target = rel
        results.append({"path": rel, "count": count, "target": target})
        if len(results) >= max_results:
            break

    results.sort(key=lambda item: (-int(item["count"]), str(item["path"])))
    return results


def _collapse_spaces(value: str) -> str:
    return re.sub(r"\s+", " ", value.strip())


def _strip_brackets(value: str) -> str:
    return _collapse_spaces(re.sub(r"\s*\([^)]*\)", "", value))


def _variants(value: str) -> list[str]:
    base = _collapse_spaces(value)
    variants = {base, base.lower()}
    if "ё" in base or "Ё" in base:
        variants.add(base.replace("ё", "е").replace("Ё", "Е"))
    if "е" in base or "Е" in base:
        variants.add(base.replace("е", "ё").replace("Е", "Ё"))
    stripped = _strip_brackets(base)
    if stripped and stripped != base:
        variants.add(stripped)
        variants.add(stripped.lower())
    # Add morphological lemmas when pymorphy2 is available
    if _MORPH is not None:
        tokens = re.findall(r"[\wА-Яа-яЁё]+", base)
        if tokens:
            try:
                lemmas = [str(_MORPH.parse(token)[0].normal_form) for token in tokens]
                joined = _collapse_spaces(" ".join(lemmas))
                variants.add(joined)
                variants.add(joined.lower())
                for lemma in lemmas:
                    variants.add(lemma)
                    variants.add(lemma.lower())
            except Exception:
                pass
    return [v for v in variants if v]


def _normalize_manual_target(root: Path, target: str) -> str:
    target = target.strip()
    if not target:
        return ""
    path_part = target.split("?", 1)[0].rstrip("/")
    if path_part.lower().endswith(".txt"):
        file_path = (root / Path(path_part)).resolve()
        try:
            file_path.relative_to(root.resolve())
        except Exception:
            return target
        return _target_for_file(root, file_path)
    return target


def _reload_link_index(
    link_index: dict[str, str],
    index_file: Path,
    full_index_file: Path,
    manual_file: Path,
) -> None:
    updated = _load_link_index(index_file, full_index_file, manual_file)
    link_index.clear()
    link_index.update(updated)


def _better_choice(current: tuple[int, str] | None, candidate: tuple[int, str]) -> bool:
    if current is None:
        return True
    cur_prio, cur_path = current
    cand_prio, cand_path = candidate
    if cand_prio != cur_prio:
        return cand_prio > cur_prio
    if len(cand_path) != len(cur_path):
        return len(cand_path) < len(cur_path)
    return cand_path < cur_path


def _add_index_key(
    index_best: dict[str, tuple[int, str]],
    key: str,
    target: str,
    priority: int,
) -> None:
    for variant in _variants(key):
        if len(variant) < 3 or not any(ch.isalnum() for ch in variant):
            continue
        candidate = (priority, target)
        current = index_best.get(variant)
        if _better_choice(current, candidate):
            index_best[variant] = candidate


def _load_link_index(index_file: Path, full_index_file: Path, manual_file: Path) -> dict[str, str]:
    index_best: dict[str, tuple[int, str]] = {}

    if manual_file.exists():
        try:
            data = json.loads(manual_file.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            data = None
        if isinstance(data, dict):
            for key, target in data.items():
                _add_index_key(index_best, str(key), str(target), 1000)

    if full_index_file.exists():
        try:
            data = json.loads(full_index_file.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            data = None
        if isinstance(data, dict) and isinstance(data.get("entries"), list):
            for entry in data["entries"]:
                if not isinstance(entry, dict):
                    continue
                key = str(entry.get("key", "")).strip()
                target = str(entry.get("target", "")).strip()
                if not key or not target:
                    continue
                try:
                    priority = int(entry.get("priority", 0))
                except (TypeError, ValueError):
                    priority = 0
                _add_index_key(index_best, key, target, priority)

    if not index_best and index_file.exists():
        try:
            data = json.loads(index_file.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            data = None
        if isinstance(data, dict):
            for key, target in data.items():
                _add_index_key(index_best, str(key), str(target), 0)

    return {key: value for key, (_, value) in index_best.items()}


def _detect_local_ip() -> str:
    """Try to detect the machine's LAN IP address for user-facing messages."""
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        # doesn't actually send data
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
    except Exception:
        ip = "127.0.0.1"
    finally:
        try:
            s.close()
        except Exception:
            pass
    return ip


class StaticHandler(SimpleHTTPRequestHandler):
    server_version = "WikiServ/1.0"

    def __init__(
        self,
        *args,
        root: Path,
        log_file: Path,
        link_index: dict[str, str],
        index_file: Path,
        full_index_file: Path,
        manual_file: Path,
        releases_dir: Path,
        **kwargs,
    ):
        self.root = root
        self.log_file = log_file
        self.link_index = link_index
        self.index_file = index_file
        self.full_index_file = full_index_file
        self.manual_file = manual_file
        self.releases_dir = releases_dir
        super().__init__(*args, directory=str(root), **kwargs)

    def send_head(self):
        if self.path.startswith("/__changes__"):
            return self._render_changes_page()
        if self.path.startswith("/_releases/"):
            return self._send_release_archive()

        path = self.translate_path(self.path)
        if self.path.startswith("/_assets/wiki.css"):
            return super().send_head()
        if os.path.isdir(path):
            return self.list_directory(path)

        ext = Path(path).suffix.lower()
        text_exts = {".txt", ".md", ".markdown", ".rst"}
        if ext == ".txt" and os.path.isfile(path):
            file_path = Path(path)
            target = _target_for_file(self.root, file_path)
            self.send_response(302)
            self.send_header("Location", _build_href(target))
            self.end_headers()
            return None
        if ext in text_exts and os.path.isfile(path):
            try:
                text = Path(path).read_text(encoding="utf-8", errors="ignore")
            except OSError:
                self.send_error(404, "File not found")
                return None

            html_body = self._linkify_text(text)
            encoded = (
                "<html><body><pre style=\"white-space: pre-wrap;\">"
                + html_body
                + "</pre></body></html>"
            ).encode("utf-8")

            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(encoded)))
            self.end_headers()
            return io.BytesIO(encoded)

        return super().send_head()

    def _send_release_archive(self):
        parsed = urlparse(self.path)
        raw_path = parsed.path
        requested_name = unquote(raw_path[len("/_releases/") :]).strip()
        if not requested_name or "/" in requested_name or "\\" in requested_name:
            self.send_error(404, "Release archive not found")
            return None

        archive_path = _find_single_release_archive(self.releases_dir)
        if archive_path is None or archive_path.name != requested_name:
            self.send_error(404, "Release archive not found")
            return None
        if not archive_path.exists() or not archive_path.is_file():
            self.send_error(404, "Release archive not found")
            return None

        try:
            size = archive_path.stat().st_size
            file_obj = archive_path.open("rb")
        except OSError:
            self.send_error(404, "Release archive not found")
            return None

        self.send_response(200)
        self.send_header("Content-Type", self.guess_type(str(archive_path)))
        self.send_header("Content-Length", str(size))
        self.send_header("Content-Disposition", f'attachment; filename="{archive_path.name}"')
        self.end_headers()
        return file_obj

    def _render_changes_page(self):
        repo_root = _find_repo_root(self.root)
        if repo_root is None:
            status = "Git repo not found for the configured root."
            log_output = ""
            diff_stat = ""
            diff_output = ""
            repo_display = "(not found)"
        else:
            try:
                rel_root = self.root.resolve().relative_to(repo_root.resolve()).as_posix()
            except Exception:
                rel_root = ""
            pathspec = ["--", rel_root] if rel_root else []

            status = (
                _run_git_command(
                    repo_root,
                    ["-c", "core.quotepath=false", "status", "--porcelain=v1"] + pathspec,
                )
                or "Working tree clean."
            )
            diff_stat = (
                _run_git_command(
                    repo_root,
                    ["-c", "core.quotepath=false", "diff", "--stat"] + pathspec,
                )
                or "No local diffs."
            )
            diff_output = (
                _run_git_command(
                    repo_root,
                    ["-c", "core.quotepath=false", "diff"] + pathspec,
                )
                or "No local diffs."
            )
            log_output = _run_git_command(
                repo_root,
                ["log", "-n", "30", "--pretty=format:%h %ad %s", "--date=short"],
            )
            repo_display = repo_root.as_posix()

        body = "".join(
            [
                "<!doctype html>",
                "<html><head>",
                "<meta charset=\"utf-8\">",
                "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
                "<link rel=\"stylesheet\" href=\"/_assets/wiki.css\">",
                "<title>Изменения</title>",
                "</head><body>",
                "<div class=\"page\">",
                "<header class=\"topbar\">",
                "<div class=\"crumbs\"><a href=\"/\">Главная</a> / Изменения</div>",
                "</header>",
                "<main class=\"panel\">",
                "<div class=\"panel-header\">",
                "<h1>Изменения</h1>",
                "</div>",
                "<section class=\"content\">",
                "<h2>Репозиторий</h2>",
                "<pre>",
                html.escape(repo_display),
                "</pre>",
                "<h2>Статус</h2>",
                "<pre>",
                html.escape(status),
                "</pre>",
                "<h2>Последние коммиты</h2>",
                "<pre>",
                html.escape(log_output or "Нет данных"),
                "</pre>",
                "<h2>Сводка изменений</h2>",
                "<pre>",
                html.escape(diff_stat),
                "</pre>",
                "<h2>Подробные изменения</h2>",
                "<pre>",
                html.escape(diff_output),
                "</pre>",
                "</section>",
                "</main>",
                "</div>",
                "</body></html>",
            ]
        )
        encoded = body.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        return io.BytesIO(encoded)

    def _linkify_text(self, text: str) -> str:
        if not self.link_index:
            return html.escape(text)

        keyword_map: dict[str, str] = {}
        for key, target in self.link_index.items():
            if not key or not target:
                continue
            keyword_map[key.lower()] = target

        keywords = sorted(keyword_map.keys(), key=len, reverse=True)
        if not keywords:
            return html.escape(text)

        pattern = re.compile(
            r"(?<!\w)(" + "|".join(re.escape(k) for k in keywords) + r")(?!\w)",
            re.IGNORECASE,
        )

        parts: list[str] = []
        last = 0
        for match in pattern.finditer(text):
            start, end = match.span()
            if start > last:
                parts.append(html.escape(text[last:start]))
            matched = match.group(0)
            target = keyword_map.get(matched.lower())
            if target:
                href = _build_href(target)
                parts.append('<a href="{}">{}</a>'.format(href, html.escape(matched)))
            else:
                parts.append(html.escape(matched))
            last = end

        if last < len(text):
            parts.append(html.escape(text[last:]))

        return "".join(parts)

    def translate_path(self, path: str) -> str:
        # Decode each path segment to support non-ASCII names and build a safe path under root.
        raw_path = path.split("?", 1)[0].split("#", 1)[0]
        if raw_path == "/_assets/wiki.css":
            return str(Path(__file__).resolve().parent / "wiki.css")
        segments = [seg for seg in raw_path.lstrip("/").split("/") if seg]
        decoded = [unquote(seg) for seg in segments]
        rel = Path(*decoded) if decoded else Path(".")
        resolved = (self.root / rel).resolve()
        # Prevent path traversal; if outside root, force to root (will likely 404 later).
        try:
            resolved.relative_to(self.root.resolve())
        except Exception:
            resolved = self.root / "__invalid_path__"
        client_ip = self.client_address[0] if isinstance(self.client_address, tuple) and self.client_address else None
        _log_entry(self.log_file, self.requestline, str(rel), resolved, client_ip)
        return str(resolved)

    def list_directory(self, path: str):
        # Build a wiki-style HTML page for folders.
        try:
            entries = list(Path(path).iterdir())
        except OSError:
            self.send_error(404, "No permission to list directory")
            return None

        parsed = urlparse(self.path)
        raw_path = parsed.path
        if not raw_path.endswith("/"):
            raw_path += "/"
        query = parse_qs(parsed.query).get("q", [""])[0].strip()

        dir_path = Path(path)
        root_resolved = self.root.resolve()
        try:
            rel_dir = dir_path.resolve().relative_to(root_resolved)
        except Exception:
            rel_dir = Path(".")

        txt_files = sorted(
            [entry for entry in entries if entry.is_file() and entry.suffix.lower() == ".txt"],
            key=lambda p: p.name.lower(),
        )
        image_files = sorted(
            [entry for entry in entries if entry.is_file() and entry.suffix.lower() in _IMAGE_EXTS],
            key=lambda p: p.name.lower(),
        )
        subdirs = sorted(
            [entry for entry in entries if entry.is_dir()],
            key=lambda p: p.name.lower(),
        )
        other_files = sorted(
            [entry for entry in entries if entry.is_file() and entry.suffix.lower() not in _IMAGE_EXTS and entry.suffix.lower() != ".txt"],
            key=lambda p: p.name.lower(),
        )

        page_title = "Главная" if rel_dir == Path(".") else rel_dir.name
        back_href = None
        if rel_dir != Path("."):
            parent = rel_dir.parent
            parent_posix = "" if parent == Path(".") else parent.as_posix() + "/"
            back_href = _build_href(parent_posix)

        crumbs = [("Главная", "/")]
        if rel_dir != Path("."):
            parts = rel_dir.parts
            accum = ""
            for part in parts:
                accum = accum + part + "/"
                crumbs.append((part, _build_href(accum)))

        def _encode_anchor(file_name: str) -> str:
            return urlencode({"a": file_name}, quote_via=quote)

        toc_items = []
        section_blocks = []
        used_ids: set[str] = set()
        for file_path in txt_files:
            title = _title_from_filename(file_path.name)
            anchor_id = _anchor_id_for_name(title, used_ids)
            anchor_query = _encode_anchor(file_path.name)
            section_href = raw_path + "?" + anchor_query
            try:
                text = file_path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                text = ""

            rel_file = ""
            can_edit = False
            try:
                rel_file = file_path.resolve().relative_to(root_resolved).as_posix()
                can_edit = True
            except Exception:
                rel_file = ""
                can_edit = False
            toc_items.append(
                '<li><a href="{}" class="toc-link">{}</a></li>'.format(
                    section_href, html.escape(title)
                )
            )
            edit_button = (
                "<button class=\"edit-toggle\" type=\"button\">Редактировать</button>"
                if can_edit
                else "<span class=\"edit-disabled\">Редактирование недоступно</span>"
            )
            edit_form = ""
            if can_edit:
                edit_form = (
                    "<form class=\"edit-form\" method=\"post\" action=\"/__edit__\">"
                    + "<input type=\"hidden\" name=\"file\" value=\"" + html.escape(rel_file) + "\">"
                    + "<textarea name=\"content\">" + html.escape(text) + "</textarea>"
                    + "<div class=\"edit-actions\">"
                    + "<button type=\"submit\">Сохранить</button>"
                    + "<button class=\"edit-cancel\" type=\"button\">Отмена</button>"
                    + "</div>"
                    + "</form>"
                )
            section_blocks.append(
                "<section class=\"doc-section\" id=\"" + anchor_id + "\" data-file=\""
                + html.escape(file_path.name)
                + "\">"
                + "<header class=\"section-header\">"
                + "<h2>" + html.escape(title) + "</h2>"
                + "<div class=\"section-actions\">"
                + "<a class=\"anchor-link\" href=\"" + section_href + "\">#</a>"
                + edit_button
                + "</div>"
                + "</header>"
                + "<div class=\"doc-body\"><pre>"
                + self._linkify_text(text)
                + "</pre></div>"
                + edit_form
                + "</section>"
            )

        gallery_items = []
        for img in image_files:
            href = raw_path + quote(img.name)
            gallery_items.append(
                "<figure class=\"gallery-item\">"
                + "<img src=\"" + href + "\" alt=\"" + html.escape(img.name) + "\">"
                + "<figcaption>" + html.escape(img.name) + "</figcaption>"
                + "</figure>"
            )

        folder_links = []
        for folder in subdirs:
            href = raw_path + quote(folder.name) + "/"
            folder_links.append('<li><a href="{}">{}</a></li>'.format(href, html.escape(folder.name)))
        file_links = []
        for other in other_files:
            href = raw_path + quote(other.name)
            file_links.append('<li><a href="{}">{}</a></li>'.format(href, html.escape(other.name)))

        search_html = (
            "<form class=\"search\" method=\"get\">"
            + "<input type=\"text\" name=\"q\" placeholder=\"Поиск...\" "
            + "value=\"" + html.escape(query) + "\">"
            + "<button type=\"submit\">Искать</button>"
            + "</form>"
        )
        search_results = ""
        # prepare TOC and resource lists as HTML to avoid inline f-strings
        if toc_items:
            toc_html = "<ul>" + "".join(toc_items) + "</ul>"
        else:
            toc_html = "<ul><li>Нет разделов</li></ul>"

        if folder_links:
            folder_links_html = "<ul>" + "".join(folder_links) + "</ul>"
        else:
            folder_links_html = "<ul><li>Нет подпапок</li></ul>"

        if file_links:
            file_links_html = "<ul>" + "".join(file_links) + "</ul>"
        else:
            file_links_html = "<ul><li>Нет файлов</li></ul>"
        if query:
            results = _search_files(self.root, query)
            if results:
                result_items = []
                for item in results:
                    link = _build_href(str(item.get("target", "")))
                    result_items.append(
                        "<li>"
                        + "<a href=\"" + link + "\">" + html.escape(str(item["path"])) + "</a>"
                        + " <small>(совпадений: " + str(int(item["count"])) + ")</small>"
                        + "</li>"
                    )
                search_results = (
                    "<section class=\"search-results\">"
                    + "<h2>Результаты поиска</h2>"
                    + "<ul>" + "".join(result_items) + "</ul>"
                    + "</section>"
                )
            else:
                search_results = (
                    "<section class=\"search-results\">"
                    "<h2>Результаты поиска</h2><p>Ничего не найдено.</p>"
                    "</section>"
                )

        breadcrumbs_html = " / ".join(
            '<a href="{}">{}</a>'.format(href, html.escape(label)) for label, href in crumbs
        )

        if back_href:
            back_button = '<a class="btn back" href="{}">Назад</a>'.format(back_href)
        else:
            back_button = "<span class=\"btn back disabled\">Назад</span>"

        release_archive = _find_single_release_archive(self.releases_dir)
        if release_archive is not None:
            release_button = (
                " <a class=\"btn\" href=\"/_releases/"
                + quote(release_archive.name)
                + "\">Скачать билд</a>"
            )
        else:
            release_button = ""

        if gallery_items:
            gallery_html = (
                "<section class=\"gallery\"><h2>Изображения</h2>"
                "<div class=\"gallery-grid\">"
                + "".join(gallery_items)
                + "</div></section>"
            )
        else:
            gallery_html = ""

        rel_dir_posix = "" if rel_dir == Path(".") else rel_dir.as_posix()
        create_folder_form = (
            "<form class=\"create-form\" method=\"post\" action=\"/__create_folder__\">"
            + "<input type=\"hidden\" name=\"parent\" value=\"" + html.escape(rel_dir_posix) + "\">"
            + "<input type=\"text\" name=\"name\" placeholder=\"Новая папка\">"
            + "<button type=\"submit\">Создать страницу</button>"
            + "</form>"
        )
        create_file_form = (
            "<form class=\"create-form\" method=\"post\" action=\"/__create_file__\">"
            + "<input type=\"hidden\" name=\"parent\" value=\"" + html.escape(rel_dir_posix) + "\">"
            + "<input type=\"text\" name=\"name\" placeholder=\"Новый раздел.txt\">"
            + "<textarea name=\"content\" placeholder=\"Текст раздела...\"></textarea>"
            + "<button type=\"submit\">Создать раздел</button>"
            + "</form>"
        )
        upload_form = (
            "<form class=\"create-form\" method=\"post\" action=\"/__upload__\" enctype=\"multipart/form-data\">"
            + "<input type=\"hidden\" name=\"parent\" value=\"" + html.escape(rel_dir_posix) + "\">"
            + "<input type=\"hidden\" name=\"return\" value=\"" + html.escape(raw_path) + "\">"
            + "<input type=\"file\" name=\"files\" multiple "
            + "accept=\"image/*,.png,.jpg,.jpeg,.gif,.webp,.svg,.bmp,.tif,.tiff,.ico\">"
            + "<button type=\"submit\">Загрузить файлы</button>"
            + "</form>"
        )
        default_target = rel_dir_posix + "/" if rel_dir_posix else "/"
        add_index_form = (
            "<form class=\"create-form\" method=\"post\" action=\"/__add_index__\">"
            + "<input type=\"text\" name=\"key\" placeholder=\"Слово для индекса\">"
            + "<input type=\"text\" name=\"target\" value=\"" + html.escape(default_target) + "\">"
            + "<button type=\"submit\">Добавить индекс</button>"
            + "</form>"
        )
        reindex_form = (
            "<form class=\"create-form\" method=\"post\" action=\"/__reindex__\">"
            + "<input type=\"hidden\" name=\"return\" value=\"" + html.escape(raw_path) + "\">"
            + "<button type=\"submit\">Переиндексация</button>"
            + "</form>"
        )

        # Render sections safely without embedding complex expressions inside f-strings
        if section_blocks:
            sections_html = "".join(section_blocks)
        else:
            sections_html = '<p class="empty">В этой папке нет текстовых документов.</p>'

        body = "".join(
            [
                "<!doctype html>",
                "<html><head>",
                "<meta charset=\"utf-8\">",
                "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
                "<link rel=\"stylesheet\" href=\"/_assets/wiki.css\">",
                "<title>",
                html.escape(page_title),
                "</title>",
                "</head><body>",
                "<div class=\"page\">",
                "<header class=\"topbar\">",
                "<div class=\"crumbs\">",
                breadcrumbs_html,
                "</div>",
                "<div class=\"top-actions\">",
                search_html,
                "</div>",
                "</header>",
                "<main class=\"panel\">",
                "<div class=\"panel-header\">",
                "<h1>",
                html.escape(page_title),
                "</h1>",
                "<div class=\"panel-actions\">",
                back_button,
                " <a class=\"btn\" href=\"/__changes__\">Изменения</a>",
                release_button,
                "</div>",
                "</div>",
                search_results,
                "<div class=\"layout\">",
                "<aside class=\"toc\">",
                "<h2>Содержание</h2>",
                toc_html,
                "</aside>",
                "<section class=\"content\">",
                sections_html,
                "</section>",
                "</div>",
                "<section class=\"resources\">",
                "<div class=\"resource-block\">",
                "<h2>Подпапки</h2>",
                folder_links_html,
                "</div>",
                "<div class=\"resource-block\">",
                "<h2>Файлы</h2>",
                file_links_html,
                "</div>",
                "<div class=\"resource-block\">",
                "<h2>Создать</h2>",
                create_folder_form,
                create_file_form,
                upload_form,
                add_index_form,
                reindex_form,
                "</div>",
                "</section>",
                gallery_html,
                "</main>",
                "</div>",
                "<script>",
                "(() => {",
                "  const params = new URLSearchParams(window.location.search);",
                "  const anchorFile = params.get('a');",
                "  if (anchorFile) {",
                "    try {",
                "      const selector = `[data-file=\"${CSS.escape(anchorFile)}\"]`;",
                "      const target = document.querySelector(selector);",
                "      if (target) {",
                "        target.classList.add('is-highlight');",
                "        target.scrollIntoView({behavior: 'smooth', block: 'start'});",
                "      }",
                "    } catch (err) {}",
                "  }",
                "  document.querySelectorAll('.edit-toggle').forEach(btn => {",
                "    btn.addEventListener('click', () => {",
                "      const section = btn.closest('.doc-section');",
                "      if (section) {",
                "        section.classList.add('is-editing');",
                "        const textarea = section.querySelector('textarea');",
                "        if (textarea) { textarea.focus(); }",
                "      }",
                "    });",
                "  });",
                "  document.querySelectorAll('.edit-cancel').forEach(btn => {",
                "    btn.addEventListener('click', () => {",
                "      const section = btn.closest('.doc-section');",
                "      if (section) {",
                "        section.classList.remove('is-editing');",
                "      }",
                "    });",
                "  });",
                "})();",
                "</script>",
                "</body></html>",
            ]
        )

        encoded = body.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)
        return None

    def do_POST(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == "/__edit__":
            self._handle_edit()
            return
        if parsed.path == "/__create_folder__":
            self._handle_create_folder()
            return
        if parsed.path == "/__create_file__":
            self._handle_create_file()
            return
        if parsed.path == "/__upload__":
            self._handle_upload_files()
            return
        if parsed.path == "/__add_index__":
            self._handle_add_index()
            return
        if parsed.path == "/__reindex__":
            self._handle_reindex()
            return

        self.send_error(404, "Not Found")
        return

    def _read_post_data(self) -> dict[str, list[str]]:
        try:
            content_length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            content_length = 0
        body = self.rfile.read(content_length)
        return parse_qs(body.decode("utf-8", errors="ignore"), keep_blank_values=True)

    def _read_multipart_form(self):
        content_type = self.headers.get("Content-Type", "")
        if "multipart/form-data" not in content_type.lower():
            return None
        try:
            content_length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            return None

        body = self.rfile.read(content_length)
        raw_message = (
            f"Content-Type: {content_type}\r\n"
            "MIME-Version: 1.0\r\n"
            "\r\n"
        ).encode("utf-8") + body
        message = BytesParser(policy=policy.default).parsebytes(raw_message)
        if not message.is_multipart():
            return None

        fields: dict[str, str] = {}
        files: list[tuple[str, bytes]] = []
        for part in message.iter_parts():
            name = part.get_param("name", header="content-disposition")
            if not name:
                continue
            filename = part.get_filename()
            payload = part.get_payload(decode=True) or b""
            if filename:
                files.append((str(filename), payload))
            else:
                fields[str(name)] = payload.decode("utf-8", errors="ignore")
        return fields, files

    def _safe_child_name(self, name: str) -> str | None:
        cleaned = name.strip()
        if not cleaned:
            return None
        if "/" in cleaned or "\\" in cleaned:
            return None
        if cleaned in {".", ".."}:
            return None
        return cleaned

    def _resolve_parent(self, rel_parent: str) -> Path | None:
        rel = Path(unquote(rel_parent)) if rel_parent else Path(".")
        if rel.is_absolute():
            return None
        root_resolved = self.root.resolve()
        target = (root_resolved / rel).resolve()
        try:
            target.relative_to(root_resolved)
        except Exception:
            return None
        return target

    def _handle_edit(self) -> None:
        data = self._read_post_data()
        rel = data.get("file", [""])[0]
        content = data.get("content", [""])[0]
        if not rel:
            self.send_error(400, "Missing file")
            return

        root_resolved = self.root.resolve()
        rel_path = Path(unquote(rel))
        if rel_path.is_absolute():
            self.send_error(403, "Invalid path")
            return
        target = (root_resolved / rel_path).resolve()
        try:
            target.relative_to(root_resolved)
        except Exception:
            self.send_error(403, "Invalid path")
            return
        if target.suffix.lower() != ".txt":
            self.send_error(400, "Only .txt files are editable")
            return

        try:
            existing_bytes = b""
            try:
                existing_bytes = target.read_bytes()
            except OSError:
                existing_bytes = b""

            line_ending = _detect_newline_from_bytes(existing_bytes)
            has_utf8_bom = existing_bytes.startswith(b"\xef\xbb\xbf")

            normalized_content = _normalize_newlines(content, line_ending)
            output_bytes = normalized_content.encode("utf-8")
            if has_utf8_bom:
                output_bytes = b"\xef\xbb\xbf" + output_bytes

            changed = output_bytes != existing_bytes
            _log_save_diagnostics(
                self.log_file,
                target,
                existing_bytes,
                output_bytes,
                line_ending,
                has_utf8_bom,
                changed,
            )

            if changed:
                target.write_bytes(output_bytes)
        except OSError:
            self.send_error(500, "Failed to save file")
            return

        redirect_target = _target_for_file(root_resolved, target)
        self.send_response(303)
        self.send_header("Location", _build_href(redirect_target))
        self.end_headers()

    def _handle_create_folder(self) -> None:
        data = self._read_post_data()
        rel_parent = data.get("parent", [""])[0]
        name = self._safe_child_name(data.get("name", [""])[0])
        if not name:
            self.send_error(400, "Missing folder name")
            return

        parent_dir = self._resolve_parent(rel_parent)
        if parent_dir is None:
            self.send_error(403, "Invalid parent")
            return
        if not parent_dir.exists() or not parent_dir.is_dir():
            self.send_error(400, "Parent is not a folder")
            return

        new_dir = (parent_dir / name).resolve()
        try:
            new_dir.relative_to(self.root.resolve())
        except Exception:
            self.send_error(403, "Invalid folder path")
            return
        if new_dir.exists():
            self.send_error(409, "Folder already exists")
            return

        try:
            new_dir.mkdir(parents=False)
        except OSError:
            self.send_error(500, "Failed to create folder")
            return

        rel_dir = new_dir.relative_to(self.root.resolve()).as_posix() + "/"
        self.send_response(303)
        self.send_header("Location", _build_href(rel_dir))
        self.end_headers()

    def _handle_create_file(self) -> None:
        data = self._read_post_data()
        rel_parent = data.get("parent", [""])[0]
        name_raw = data.get("name", [""])[0]
        content = data.get("content", [""])[0]
        name = self._safe_child_name(name_raw)
        if not name:
            self.send_error(400, "Missing file name")
            return
        if not name.lower().endswith(".txt"):
            name = name + ".txt"

        parent_dir = self._resolve_parent(rel_parent)
        if parent_dir is None:
            self.send_error(403, "Invalid parent")
            return
        if not parent_dir.exists() or not parent_dir.is_dir():
            self.send_error(400, "Parent is not a folder")
            return

        new_file = (parent_dir / name).resolve()
        try:
            new_file.relative_to(self.root.resolve())
        except Exception:
            self.send_error(403, "Invalid file path")
            return
        if new_file.exists():
            self.send_error(409, "File already exists")
            return

        try:
            normalized_content = _normalize_newlines(content, "\r\n")
            new_file.write_bytes(normalized_content.encode("utf-8"))
        except OSError:
            self.send_error(500, "Failed to create file")
            return

        redirect_target = _target_for_file(self.root.resolve(), new_file)
        self.send_response(303)
        self.send_header("Location", _build_href(redirect_target))
        self.end_headers()

    def _handle_upload_files(self) -> None:
        form_data = self._read_multipart_form()
        if form_data is None:
            self.send_error(400, "Expected multipart form upload")
            return
        fields, files = form_data

        rel_parent = fields.get("parent", "")
        return_path = fields.get("return", "/").strip() or "/"

        parent_dir = self._resolve_parent(rel_parent)
        if parent_dir is None:
            self.send_error(403, "Invalid parent")
            return
        if not parent_dir.exists() or not parent_dir.is_dir():
            self.send_error(400, "Parent is not a folder")
            return

        uploaded = 0
        for filename_raw, payload in files:
            safe_name = self._safe_child_name(Path(str(filename_raw)).name)
            if not safe_name:
                continue
            target = _make_unique_child_path(parent_dir, safe_name).resolve()
            try:
                target.relative_to(self.root.resolve())
            except Exception:
                continue
            try:
                target.write_bytes(payload)
                uploaded += 1
            except OSError:
                continue

        if uploaded == 0:
            self.send_error(400, "No valid files uploaded")
            return

        self.send_response(303)
        self.send_header("Location", return_path)
        self.end_headers()

    def _handle_add_index(self) -> None:
        data = self._read_post_data()
        key = data.get("key", [""])[0].strip()
        target = data.get("target", [""])[0].strip()
        if not key or not target:
            self.send_error(400, "Missing key or target")
            return

        normalized_target = _normalize_manual_target(self.root.resolve(), target)
        if not normalized_target:
            self.send_error(400, "Invalid target")
            return

        try:
            manual_data = json.loads(self.manual_file.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            manual_data = {}
        if not isinstance(manual_data, dict):
            manual_data = {}
        manual_data[key] = normalized_target

        try:
            self.manual_file.write_text(
                json.dumps(manual_data, ensure_ascii=False, indent=2), encoding="utf-8"
            )
        except OSError:
            self.send_error(500, "Failed to save index")
            return

        _reload_link_index(self.link_index, self.index_file, self.full_index_file, self.manual_file)
        self.send_response(303)
        self.send_header("Location", "/__changes__")
        self.end_headers()

    def _handle_reindex(self) -> None:
        data = self._read_post_data()
        return_path = data.get("return", ["/"])[0].strip() or "/"
        script_path = Path(__file__).resolve().parent / "index_links.py"
        cmd = [
            sys.executable,
            str(script_path),
            "--root",
            str(self.root.resolve()),
            "--out",
            str(self.index_file),
            "--full-out",
            str(self.full_index_file),
            "--manual",
            str(self.manual_file),
        ]
        try:
            subprocess.run(cmd, cwd=str(script_path.parent), check=False, capture_output=True, text=True)
        except OSError:
            self.send_error(500, "Failed to run reindex")
            return

        _reload_link_index(self.link_index, self.index_file, self.full_index_file, self.manual_file)
        self.send_response(303)
        self.send_header("Location", return_path)
        self.end_headers()

    def guess_type(self, path: str) -> str:
        # Extend default mimetypes for common cases.
        ext = Path(path).suffix.lower()
        custom = {
            ".html": "text/html",
            ".htm": "text/html",
            ".css": "text/css",
            ".js": "application/javascript",
            ".png": "image/png",
            ".jpg": "image/jpeg",
            ".jpeg": "image/jpeg",
            ".gif": "image/gif",
            ".svg": "image/svg+xml",
            ".webp": "image/webp",
            ".txt": "text/plain",
            ".json": "application/json",
            ".pdf": "application/pdf",
        }
        return custom.get(ext) or mimetypes.guess_type(path)[0] or "application/octet-stream"


def main() -> int:
    parser = argparse.ArgumentParser(description="Simple static file HTTP server")
    parser.add_argument("--port", type=int, default=8000)
    parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent / "Картотека")
    parser.add_argument(
        "--host-mode",
        choices=["local", "lan", "any"],
        default="local",
        help=(
            "Host mode: 'local' binds to localhost (default); "
            "lan' listens on all interfaces and prints LAN IP; "
            "any' listens on all interfaces and warns about internet exposure."
        ),
    )
    args = parser.parse_args()

    root = args.root
    if not root.exists():
        raise SystemExit(f"Root path '{root}' not found. Ensure it exists and is accessible.")

    log_file = Path(__file__).resolve().parent / "serve.log"
    index_file = Path(__file__).resolve().parent / "index.json"
    full_index_file = Path(__file__).resolve().parent / "index_full.json"
    manual_file = Path(__file__).resolve().parent / "index_manual.json"
    releases_dir = Path(__file__).resolve().parent / "releases"
    link_index = _load_link_index(index_file, full_index_file, manual_file)

    def handler(*h_args, **h_kwargs):
        return StaticHandler(
            *h_args,
            root=root,
            log_file=log_file,
            link_index=link_index,
            index_file=index_file,
            full_index_file=full_index_file,
            manual_file=manual_file,
            releases_dir=releases_dir,
            **h_kwargs,
        )

    # Determine bind address and user-facing URL(s) based on host-mode
    if args.host_mode == "local":
        bind_host = "localhost"
    else:
        bind_host = "0.0.0.0"
    address = (bind_host, args.port)

    with ThreadingHTTPServer(address, handler) as httpd:
        if args.host_mode == "local":
            print(f"Serving '{root}' on http://localhost:{args.port}/ (local only, Press Ctrl+C to stop)")
        else:
            local_ip = _detect_local_ip()
            print(f"Serving '{root}' on http://0.0.0.0:{args.port}/ (listening on all interfaces)")
            print(f"Access from LAN: http://{local_ip}:{args.port}/")
            if args.host_mode == "any":
                print("Warning: 'any' mode may expose the server outside your LAN if port-forwarding/NAT is configured. Ensure firewall rules and security.")
        try:
            httpd.serve_forever()
        except KeyboardInterrupt:
            pass

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
