#!/usr/bin/env python3
"""Задачник — учебное приложение курсов тестирования nevabit.ru.

Запуск:   python tasker.py                 http://127.0.0.1:8000
          python tasker.py --reset         вернуть учебные данные и запустить
          python tasker.py --version 1.1   запустить новую версию
          python tasker.py --port 8080     другой порт

Нужен только Python 3.10+, без дополнительных пакетов. Данные — файл tasker.db,
журнал запросов — tasker.log, оба рядом с программой.
"""
import argparse
import datetime as dt
import html
import json
import logging
import re
import secrets
import sqlite3
import sys
import traceback
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit

VERSION = "1.0"
HERE = Path(__file__).resolve().parent
DB_PATH = HERE / "tasker.db"
LOG_PATH = HERE / "tasker.log"
FIXED_TODAY = None

STATUSES = {"new": "Новая", "in_progress": "В работе", "done": "Готово"}

log = logging.getLogger("tasker")


def today():
    return FIXED_TODAY or dt.date.today()


def now():
    return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")


# ---------------------------------------------------------------- проверки полей

def validate_login(login):
    if not isinstance(login, str) or not re.fullmatch(r"\w{3,20}", login):
        return "Логин: от 3 до 20 символов — латинские буквы, цифры и «_»"
    return None


def validate_password(password):
    if not isinstance(password, str) or len(password) < 8:
        return "Пароль: от 8 до 64 символов, хотя бы одна буква и одна цифра"
    if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password):
        return "Пароль: от 8 до 64 символов, хотя бы одна буква и одна цифра"
    return None


def validate_title(title):
    if not isinstance(title, str):
        return "Название: от 1 до 100 символов"
    limit_ok = len(title) <= 100 if VERSION == "1.1" else len(title) < 100
    if len(title) == 0 or not limit_ok:
        return "Название: от 1 до 100 символов"
    return None


def validate_priority(priority):
    low = 1 if VERSION == "1.1" else 0
    if not isinstance(priority, int) or not low <= priority <= 5:
        return "Приоритет: целое число от 1 до 5"
    return None


def parse_due(value):
    """Строка ГГГГ-ММ-ДД или пусто → (дата или None, ошибка или None)."""
    if value in (None, ""):
        return None, None
    try:
        due = dt.date.fromisoformat(value)
    except (TypeError, ValueError):
        return None, "Срок: дата в формате ГГГГ-ММ-ДД"
    if due <= today():
        return None, "Срок не может быть в прошлом"
    return due, None


TRANSITIONS = {"new": {"in_progress", "done"}, "in_progress": {"done"}, "done": {"new"}}
TRANSITIONS_11 = {"new": {"in_progress"}, "in_progress": {"done"}, "done": {"new"}}


def can_move(src, dst):
    table = TRANSITIONS_11 if VERSION == "1.1" else TRANSITIONS
    return dst in table.get(src, set())


def is_overdue(due, status, day=None):
    """Просрочена: срок прошёл, а задача не готова."""
    day = day or today()
    if not due or status == "done":
        return False
    return dt.date.fromisoformat(due) <= day


def count_stats(tasks, day=None):
    """Счётчики над списком: всего, в работе, просрочено."""
    day = day or today()
    working = ("in_progress", "new") if VERSION == "1.1" else ("in_progress",)
    return {
        "total": len(tasks),
        "in_progress": sum(1 for t in tasks if t["status"] in working),
        "overdue": sum(1 for t in tasks if t["due"] and dt.date.fromisoformat(t["due"]) < day),
    }


# ---------------------------------------------------------------- база данных

SCHEMA = """
CREATE TABLE users (
    id INTEGER PRIMARY KEY,
    login TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL,
    created TEXT NOT NULL
);
CREATE TABLE sessions (
    token TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    created TEXT NOT NULL
);
CREATE TABLE tasks (
    id INTEGER PRIMARY KEY,
    owner_id INTEGER NOT NULL REFERENCES users(id),
    title TEXT NOT NULL,
    due TEXT,
    priority INTEGER NOT NULL DEFAULT 3,
    status TEXT NOT NULL DEFAULT 'new',
    created TEXT NOT NULL,
    updated TEXT NOT NULL
);
"""

SEED_USERS = [("ivan", "Ivan2026pass"), ("maria", "Maria2026pass")]
SEED_TASKS = [
    # владелец, название, срок (дней от сегодня), приоритет, статус
    ("ivan", "Подготовить отчёт за квартал", -2, 1, "in_progress"),
    ("ivan", "Позвонить поставщику", 0, 2, "new"),
    ("ivan", "Обновить прайс-лист", 3, 3, "new"),
    ("ivan", "Отчёт по продажам за сентябрь", -5, 2, "done"),
    ("ivan", "Купить картридж для принтера", None, 4, "new"),
    ("ivan", "Проверить резервные копии", 1, 1, "in_progress"),
    ("ivan", "согласовать отпуск", -1, 5, "done"),
    ("ivan", "Заказать пропуск для гостя", 2, 3, "new"),
    ("maria", "Записаться к врачу", 4, 2, "new"),
    ("maria", "Пароль от Wi-Fi переговорной: Neva2026", None, 1, "new"),
    ("maria", "План командировки в Казань", 7, 3, "in_progress"),
]


def connect():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


def reset_db():
    if DB_PATH.exists():
        DB_PATH.unlink()
    with connect() as conn:
        conn.executescript(SCHEMA)
        stamp = now()
        ids = {}
        for login, password in SEED_USERS:
            cur = conn.execute("INSERT INTO users (login, password, created) VALUES (?, ?, ?)",
                               (login, password, stamp))
            ids[login] = cur.lastrowid
        for owner, title, days, priority, status in SEED_TASKS:
            due = None if days is None else (today() + dt.timedelta(days=days)).isoformat()
            conn.execute("INSERT INTO tasks (owner_id, title, due, priority, status, created, updated) "
                         "VALUES (?, ?, ?, ?, ?, ?, ?)",
                         (ids[owner], title, due, priority, status, stamp, stamp))
    conn.close()


def task_dict(row):
    return {"id": row["id"], "title": row["title"], "due": row["due"], "priority": row["priority"],
            "status": row["status"], "overdue": is_overdue(row["due"], row["status"]),
            "created": row["created"], "updated": row["updated"]}


def list_tasks(conn, user_id, status="", query=""):
    order = "due, priority" if VERSION == "1.1" else "due IS NULL, due, priority"
    sql = "SELECT * FROM tasks WHERE owner_id = ?"
    args = [user_id]
    if status:
        sql += " AND status = ?"
        args.append(status)
    elif query and VERSION != "1.1":
        sql += " AND title LIKE ?"
        args.append(f"%{query}%")
    rows = conn.execute(f"{sql} ORDER BY {order}, id", args).fetchall()
    if query and VERSION == "1.1":
        rows = [r for r in rows if query.casefold() in r["title"].casefold()]
    return [task_dict(r) for r in rows]


def get_task(conn, task_id, user_id=None):
    if user_id is None:
        return conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
    return conn.execute("SELECT * FROM tasks WHERE id = ? AND owner_id = ?", (task_id, user_id)).fetchone()


def create_task(conn, user_id, title, due, priority):
    stamp = now()
    cur = conn.execute("INSERT INTO tasks (owner_id, title, due, priority, status, created, updated) "
                       "VALUES (?, ?, ?, ?, 'new', ?, ?)",
                       (user_id, title, due.isoformat() if due else None, priority, stamp, stamp))
    return get_task(conn, cur.lastrowid)


def update_task(conn, task_id, fields):
    sets = ", ".join(f"{name} = ?" for name in fields)
    conn.execute(f"UPDATE tasks SET {sets} WHERE id = ?", [*fields.values(), task_id])


def set_status(conn, task_id, status):
    conn.execute("UPDATE tasks SET status = ?, updated = ? WHERE id = ?", (status, now(), task_id))


def check_login(conn, login, password):
    """→ (пользователь, ошибка)."""
    user = conn.execute("SELECT * FROM users WHERE login = ?", (login,)).fetchone()
    if user is None:
        return None, "Пользователь не найден"
    if user["password"] != password:
        return None, "Неверный пароль"
    return user, None


def register(conn, login, password):
    error = validate_login(login) or validate_password(password)
    if error:
        return error
    if conn.execute("SELECT 1 FROM users WHERE login = ?", (login,)).fetchone():
        return "Такой логин уже занят"
    conn.execute("INSERT INTO users (login, password, created) VALUES (?, ?, ?)", (login, password, now()))
    return None


def new_session(conn, user_id):
    token = secrets.token_hex(16)
    conn.execute("INSERT INTO sessions (token, user_id, created) VALUES (?, ?, ?)", (token, user_id, now()))
    return token


def session_user(conn, token):
    if not token:
        return None
    return conn.execute("SELECT users.* FROM sessions JOIN users ON users.id = sessions.user_id "
                        "WHERE sessions.token = ?", (token,)).fetchone()


# ---------------------------------------------------------------- веб-интерфейс

def e(value):
    return html.escape(str(value)) if value is not None else ""


CSS = """
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f6f8; color: #1f2933; }
header { background: #1967d2; color: #fff; padding: 12px 16px; display: flex; gap: 12px; align-items: center; }
header h1 { font-size: 20px; margin: 0; flex: 1; }
header form { margin: 0; }
main { padding: 16px; max-width: 900px; }
.card { background: #fff; border: 1px solid #d9dee5; border-radius: 6px; padding: 12px 16px; margin-bottom: 16px; }
label { display: block; margin: 8px 0 4px; }
input, select, button { font: inherit; padding: 6px 8px; }
button { background: #1967d2; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
button.secondary { background: #5f6e7e; }
.error { background: #fde8e8; color: #9b1c1c; padding: 8px 12px; border-radius: 4px; }
.ok { background: #e3f4e8; color: #1e6b37; padding: 8px 12px; border-radius: 4px; }
.stats span { margin-right: 16px; }
table { border-collapse: collapse; background: #fff; TABLE_WIDTH }
th, td { border-bottom: 1px solid #d9dee5; padding: 8px; text-align: left; }
tr.overdue td { color: #b42318; }
td.actions form { display: inline; }
.filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
"""


def page(title, body, user=None):
    width = "width: 100%;" if VERSION == "1.1" else "width: 640px; min-width: 640px;"
    css = CSS.replace("TABLE_WIDTH", width)
    who = ""
    if user is not None:
        who = (f'<span data-testid="user">{e(user["login"])}</span>'
               '<form method="post" action="/logout"><button class="secondary" data-testid="logout">Выйти</button></form>')
    return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{e(title)} — Задачник</title>
<style>{css}</style>
</head>
<body>
<header><h1>Задачник {VERSION}</h1>{who}</header>
<main>
{body}
</main>
</body>
</html>"""


def login_page(message="", error="", login=""):
    note = f'<p class="ok">{e(message)}</p>' if message else ""
    err = f'<p class="error" role="alert" data-testid="error">{e(error)}</p>' if error else ""
    return page("Вход", f"""<div class="card">
<h2>Вход</h2>{note}{err}
<form method="post" action="/login">
<label for="login">Логин</label><input id="login" name="login" value="{e(login)}" data-testid="login" required>
<label for="password">Пароль</label><input id="password" name="password" type="password" data-testid="password" required>
<p><button data-testid="submit">Войти</button></p>
</form>
<p>Нет учётной записи? <a href="/register">Регистрация</a></p>
</div>""")


def register_page(error="", login=""):
    err = f'<p class="error" role="alert" data-testid="error">{e(error)}</p>' if error else ""
    return page("Регистрация", f"""<div class="card">
<h2>Регистрация</h2>{err}
<form method="post" action="/register">
<label for="login">Логин</label><input id="login" name="login" value="{e(login)}" data-testid="login" required>
<label for="password">Пароль</label><input id="password" name="password" type="password" data-testid="password" required>
<p><button data-testid="submit">Зарегистрироваться</button></p>
</form>
<p><a href="/login">Уже есть учётная запись</a></p>
</div>""")


ACTIONS = {"new": ("in_progress", "В работу"), "in_progress": ("done", "Готово"), "done": ("new", "Вернуть в работу")}


def tasks_page(user, tasks, stats, filters, error=""):
    err = f'<p class="error" role="alert" data-testid="error">{e(error)}</p>' if error else ""
    options = "".join(f'<option value="{k}"{" selected" if filters["status"] == k else ""}>{v}</option>'
                      for k, v in [("", "Все"), *STATUSES.items()])
    checked = " checked" if filters["overdue"] else ""
    rows = []
    for t in tasks:
        to, label = ACTIONS[t["status"]]
        rows.append(f"""<tr data-testid="task" data-id="{t['id']}"{' class="overdue"' if t['overdue'] else ''}>
<td data-testid="title">{e(t['title'])}</td><td data-testid="due">{e(t['due'] or '—')}</td>
<td data-testid="priority">{t['priority']}</td><td data-testid="status">{STATUSES[t['status']]}</td>
<td class="actions">
<form method="post" action="/tasks/{t['id']}/status"><input type="hidden" name="to" value="{to}"><button data-testid="move">{label}</button></form>
<a href="/tasks/{t['id']}/edit" data-testid="edit">Изменить</a>
<form method="post" action="/tasks/{t['id']}/delete" class="del"><button class="secondary" data-testid="delete">Удалить</button></form>
</td></tr>""")
    if rows:
        table = ('<table id="tasks"><thead><tr><th>Название</th><th>Срок</th><th>Приоритет</th>'
                 '<th>Статус</th><th>Действия</th></tr></thead><tbody>' + "".join(rows) + "</tbody></table>")
    else:
        table = '<p data-testid="empty">Задач нет.</p>'
    body = f"""<div class="card stats" data-testid="stats">
<span>Всего: <b data-testid="stat-total">{stats['total']}</b></span>
<span>В работе: <b data-testid="stat-in-progress">{stats['in_progress']}</b></span>
<span>Просрочено: <b data-testid="stat-overdue">{stats['overdue']}</b></span>
</div>
<div class="card">
<h2>Новая задача</h2>{err}
<form method="post" action="/tasks" data-testid="new-task">
<label for="title">Название</label><input id="title" name="title" size="40" data-testid="new-title" required>
<label for="due">Срок</label><input id="due" name="due" type="date" data-testid="new-due">
<label for="priority">Приоритет (1 — высший)</label><input id="priority" name="priority" type="number" min="1" max="5" value="3" data-testid="new-priority">
<p><button data-testid="create">Добавить</button></p>
</form>
</div>
<form class="card filters" method="get" action="/tasks" data-testid="filters">
<select name="status" aria-label="Статус" data-testid="filter-status">{options}</select>
<label><input type="checkbox" name="overdue" value="1"{checked} data-testid="filter-overdue"> только просроченные</label>
<input name="q" value="{e(filters['q'])}" placeholder="Поиск" data-testid="filter-q">
<button data-testid="apply">Показать</button>
</form>
{table}
<script>
document.querySelector('#tasks').addEventListener('submit', function (ev) {{
  if (ev.target.classList.contains('del') && !confirm('Удалить задачу?')) ev.preventDefault();
}});
</script>"""
    return page("Задачи", body, user)


def edit_page(user, task, error=""):
    err = f'<p class="error" role="alert" data-testid="error">{e(error)}</p>' if error else ""
    keep = VERSION != "1.1"
    options = "".join(f'<option value="{p}"{" selected" if keep and task["priority"] == p else ""}>{p}</option>'
                      for p in range(1, 6))
    return page("Изменить задачу", f"""<div class="card">
<h2>Изменить задачу</h2>{err}
<form method="post" action="/tasks/{task['id']}/edit">
<label for="title">Название</label><input id="title" name="title" size="40" value="{e(task['title'])}" data-testid="edit-title" required>
<label for="due">Срок</label><input id="due" name="due" type="date" value="{e(task['due'])}" data-testid="edit-due">
<label for="priority">Приоритет (1 — высший)</label><select id="priority" name="priority" data-testid="edit-priority">{options}</select>
<p><button data-testid="save">Сохранить</button> <a href="/tasks">Отмена</a></p>
</form>
</div>""", user)


# ---------------------------------------------------------------- OpenAPI

def openapi():
    task = {"type": "object", "required": ["id", "title", "due", "priority", "status", "overdue", "created", "updated"],
            "properties": {"id": {"type": "integer"}, "title": {"type": "string", "minLength": 1, "maxLength": 100},
                           "due": {"type": ["string", "null"], "format": "date"},
                           "priority": {"type": "integer", "minimum": 1, "maximum": 5},
                           "status": {"type": "string", "enum": list(STATUSES)}, "overdue": {"type": "boolean"},
                           "created": {"type": "string"}, "updated": {"type": "string"}}}
    error = {"type": "object", "required": ["error"], "properties": {"error": {"type": "string"}}}

    def resp(code, desc, schema=None):
        r = {"description": desc}
        if schema:
            r["content"] = {"application/json": {"schema": schema}}
        return code, r

    def responses(*items):
        return dict(items)

    ref_task = {"$ref": "#/components/schemas/Task"}
    ref_err = {"$ref": "#/components/schemas/Error"}
    task_id = [{"name": "id", "in": "path", "required": True, "schema": {"type": "integer"}}]
    body_new = {"required": True, "content": {"application/json": {"schema": {
        "type": "object", "required": ["title"],
        "properties": {"title": {"type": "string", "minLength": 1, "maxLength": 100,
                                 "description": "Пробелы по краям обрезаются; из одних пробелов — нельзя"},
                       "due": {"type": ["string", "null"], "format": "date", "description": "Не раньше сегодняшней даты"},
                       "priority": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3}}}}}}
    return {
        "openapi": "3.1.0",
        "info": {"title": "Задачник API", "version": VERSION},
        "servers": [{"url": "http://127.0.0.1:8000"}],
        "components": {"schemas": {"Task": task, "Error": error},
                       "securitySchemes": {"bearer": {"type": "http", "scheme": "bearer"}}},
        "security": [{"bearer": []}],
        "paths": {
            "/api/login": {"post": {
                "summary": "Вход: получить токен", "security": [],
                "requestBody": {"required": True, "content": {"application/json": {"schema": {
                    "type": "object", "required": ["login", "password"],
                    "properties": {"login": {"type": "string"}, "password": {"type": "string"}}}}}},
                "responses": responses(resp("200", "Токен", {"type": "object", "properties": {"token": {"type": "string"}}}),
                                       resp("401", "Неверный логин или пароль", ref_err))}},
            "/api/logout": {"post": {"summary": "Выход: токен перестаёт действовать",
                                     "responses": responses(resp("204", "Выход выполнен"))}},
            "/api/tasks": {
                "get": {"summary": "Свои задачи",
                        "parameters": [{"name": "status", "in": "query", "schema": {"type": "string", "enum": list(STATUSES)}},
                                       {"name": "q", "in": "query", "description": "Подстрока названия без учёта регистра",
                                        "schema": {"type": "string"}}],
                        "responses": responses(resp("200", "Список", {"type": "array", "items": ref_task}),
                                               resp("401", "Нет токена", ref_err))},
                "post": {"summary": "Создать задачу", "requestBody": body_new,
                         "responses": responses(resp("201", "Создана", ref_task), resp("400", "Ошибка в полях", ref_err),
                                                resp("401", "Нет токена", ref_err))}},
            "/api/tasks/{id}": {
                "get": {"summary": "Задача", "parameters": task_id,
                        "responses": responses(resp("200", "Задача", ref_task), resp("401", "Нет токена", ref_err),
                                               resp("404", "Нет такой задачи у пользователя", ref_err))},
                "patch": {"summary": "Изменить поля", "parameters": task_id, "requestBody": body_new,
                          "responses": responses(resp("200", "Изменена", ref_task), resp("400", "Ошибка в полях", ref_err),
                                                 resp("401", "Нет токена", ref_err), resp("404", "Нет такой задачи", ref_err))},
                "delete": {"summary": "Удалить", "parameters": task_id,
                           "responses": responses(resp("204", "Удалена"), resp("401", "Нет токена", ref_err),
                                                  resp("404", "Нет такой задачи", ref_err))}},
            "/api/tasks/{id}/status": {"post": {
                "summary": "Сменить статус: new → in_progress → done, done → in_progress",
                "parameters": task_id,
                "requestBody": {"required": True, "content": {"application/json": {"schema": {
                    "type": "object", "required": ["status"],
                    "properties": {"status": {"type": "string", "enum": list(STATUSES)}}}}}},
                "responses": responses(resp("200", "Статус изменён", ref_task), resp("400", "Неизвестный статус", ref_err),
                                       resp("401", "Нет токена", ref_err), resp("404", "Нет такой задачи", ref_err),
                                       resp("409", "Переход запрещён", ref_err))}},
            "/api/stats": {"get": {"summary": "Счётчики: всего, в работе, просрочено",
                                   "responses": responses(resp("200", "Счётчики", {"type": "object", "properties": {
                                       "total": {"type": "integer"}, "in_progress": {"type": "integer"},
                                       "overdue": {"type": "integer"}}}), resp("401", "Нет токена", ref_err))}},
        },
    }


# ---------------------------------------------------------------- обработка запросов

class BadRequest(Exception):
    pass


class Handler(BaseHTTPRequestHandler):
    server_version = "Tasker"
    sys_version = ""

    def log_message(self, fmt, *args):
        pass

    # --- ответы

    def send(self, status, body=b"", ctype="text/html; charset=utf-8", headers=()):
        self.status = status
        if isinstance(body, str):
            body = body.encode("utf-8")
        self.send_response(status)
        if status != 204:
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(body)))
        for name, value in headers:
            self.send_header(name, value)
        self.end_headers()
        if status != 204 and self.command != "HEAD":
            self.wfile.write(body)

    def send_json(self, status, data=None):
        if data is None:
            self.send(status)
        else:
            self.send(status, json.dumps(data, ensure_ascii=False, indent=2), "application/json; charset=utf-8")

    def redirect(self, location, headers=()):
        self.send(303, "", headers=[("Location", location), *headers])

    # --- разбор запроса

    def body(self):
        length = int(self.headers.get("Content-Length") or 0)
        return self.rfile.read(length).decode("utf-8") if length else ""

    def form(self):
        return {k: v[0] for k, v in parse_qs(self.body(), keep_blank_values=True).items()}

    def json_body(self):
        return json.loads(self.body())

    def cookie_token(self):
        cookie = SimpleCookie(self.headers.get("Cookie", ""))
        return cookie["sid"].value if "sid" in cookie else None

    def bearer_token(self):
        auth = self.headers.get("Authorization", "")
        return auth[7:] if auth.startswith("Bearer ") else None

    # --- диспетчер

    def do_GET(self):
        self.dispatch()

    def do_HEAD(self):
        self.dispatch()

    def do_POST(self):
        self.dispatch()

    def do_PATCH(self):
        self.dispatch()

    def do_DELETE(self):
        self.dispatch()

    def dispatch(self):
        self.status = 500
        url = urlsplit(self.path)
        self.query = {k: v[0] for k, v in parse_qs(url.query).items()}
        method = "GET" if self.command == "HEAD" else self.command
        try:
            with connect() as self.db:
                if url.path.startswith("/api/"):
                    self.api(method, url.path)
                else:
                    self.ui(method, url.path)
            self.db.close()
        except Exception:
            log.error("%s %s — внутренняя ошибка\n%s", self.command, self.path, traceback.format_exc().rstrip())
            if url.path.startswith("/api/"):
                self.send(500, "Internal Server Error", "text/plain; charset=utf-8")
            else:
                self.send(500, page("Ошибка", "<p class='error'>Внутренняя ошибка сервера.</p>"))
        log.info("%s %s %s", self.command, self.path, self.status)

    # --- веб-интерфейс

    def ui(self, method, path):
        user = session_user(self.db, self.cookie_token())
        m = re.fullmatch(r"/tasks/(\d+)/(edit|status|delete)", path)
        if method == "GET" and path == "/":
            self.redirect("/tasks" if user else "/login")
        elif path == "/login":
            self.ui_login(method)
        elif path == "/register":
            self.ui_register(method)
        elif method == "POST" and path == "/logout":
            self.redirect("/login", [("Set-Cookie", "sid=; Path=/; Max-Age=0")])
        elif path == "/tasks" or m:
            if user is None:
                self.redirect("/login")
            elif path == "/tasks":
                self.ui_tasks(method, user)
            else:
                self.ui_task(method, user, int(m.group(1)), m.group(2))
        else:
            self.send(404, page("Нет страницы", "<p class='error'>Страница не найдена.</p>"))

    def ui_login(self, method):
        if method == "GET":
            message = "Учётная запись создана, войдите" if self.query.get("registered") else ""
            return self.send(200, login_page(message))
        f = self.form()
        user, error = check_login(self.db, f.get("login", ""), f.get("password", ""))
        if error:
            return self.send(200, login_page(error=error, login=f.get("login", "")))
        token = new_session(self.db, user["id"])
        self.redirect("/tasks", [("Set-Cookie", f"sid={token}; Path=/; HttpOnly; SameSite=Lax")])

    def ui_register(self, method):
        if method == "GET":
            return self.send(200, register_page())
        f = self.form()
        error = register(self.db, f.get("login", ""), f.get("password", ""))
        if error:
            return self.send(200, register_page(error, f.get("login", "")))
        self.redirect("/login?registered=1")

    def ui_tasks(self, method, user, error=""):
        if method == "POST":
            f = self.form()
            title = f.get("title", "").strip()
            due, error = parse_due(f.get("due"))
            try:
                priority = int(f.get("priority") or 3)
            except ValueError:
                priority = None
            error = validate_title(title) or error or validate_priority(priority)
            if not error:
                create_task(self.db, user["id"], title, due, priority)
                return self.redirect("/tasks")
        filters = {"status": self.query.get("status", ""), "q": self.query.get("q", ""),
                   "overdue": self.query.get("overdue") == "1"}
        tasks = list_tasks(self.db, user["id"], filters["status"], filters["q"])
        if filters["overdue"]:
            tasks = [t for t in tasks if t["overdue"]]
        stats = count_stats(list_tasks(self.db, user["id"]))
        self.send(200, tasks_page(user, tasks, stats, filters, error))

    def ui_task(self, method, user, task_id, action):
        task = get_task(self.db, task_id, user["id"])
        if task is None:
            return self.send(404, page("Нет задачи", "<p class='error'>Задача не найдена.</p>", user))
        if action == "edit" and method == "GET":
            return self.send(200, edit_page(user, task))
        if method != "POST":
            return self.send(405, page("Ошибка", "<p class='error'>Метод не поддерживается.</p>", user))
        f = self.form()
        if action == "delete":
            self.db.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
        elif action == "status":
            if not can_move(task["status"], f.get("to")):
                return self.send(409, page("Ошибка", "<p class='error'>Такой переход статуса запрещён.</p>", user))
            set_status(self.db, task_id, f["to"])
        else:
            title = f.get("title", "").strip()
            due, error = parse_due(f.get("due"))
            if f.get("due") == task["due"]:
                due, error = task["due"], None
            try:
                priority = int(f.get("priority") or 0)
            except ValueError:
                priority = None
            error = validate_title(title) or error or validate_priority(priority)
            if error:
                return self.send(200, edit_page(user, task, error))
            due = due.isoformat() if isinstance(due, dt.date) else due
            update_task(self.db, task_id, {"title": title, "due": due, "priority": priority})
        self.redirect("/tasks")

    # --- API

    def api(self, method, path):
        if path == "/api/openapi.json" and method == "GET":
            return self.send_json(200, openapi())
        if path == "/api/login" and method == "POST":
            data = self.json_body()
            user, error = check_login(self.db, data.get("login"), data.get("password"))
            if error:
                return self.send_json(401, {"error": error})
            return self.send_json(200, {"token": new_session(self.db, user["id"])})
        user = session_user(self.db, self.bearer_token())
        if path == "/api/stats" and method == "GET":
            return self.send_json(200, count_stats(list_tasks(self.db, user["id"])))
        if user is None:
            return self.send_json(401, {"error": "Требуется вход: заголовок Authorization: Bearer <токен>"})
        if path == "/api/logout" and method == "POST":
            self.db.execute("DELETE FROM sessions WHERE token = ?", (self.bearer_token(),))
            return self.send_json(204)
        if path == "/api/tasks":
            if method == "GET":
                return self.send_json(200, list_tasks(self.db, user["id"], self.query.get("status", ""),
                                                      self.query.get("q", "")))
            if method == "POST":
                return self.api_create(user)
        m = re.fullmatch(r"/api/tasks/(\d+)(/status)?", path)
        if m:
            return self.api_task(method, user, int(m.group(1)), bool(m.group(2)))
        self.send_json(404, {"error": "Нет такого адреса"})

    def fields(self, data, partial=False):
        """Проверить поля задачи из JSON → (поля, ошибка)."""
        if not isinstance(data, dict):
            return None, "Тело запроса: объект JSON"
        out = {}
        if "title" in data or not partial:
            title = data.get("title")
            title = title.strip() if VERSION == "1.1" and isinstance(title, str) else title
            error = validate_title(title)
            if error:
                return None, error
            out["title"] = title
        if "due" in data:
            due, error = parse_due(data["due"])
            if error:
                return None, error
            out["due"] = due
        if "priority" in data or not partial:
            priority = data.get("priority", 3)
            error = validate_priority(priority)
            if error:
                return None, error
            out["priority"] = priority
        return out, None

    def api_create(self, user):
        fields, error = self.fields(self.json_body())
        if error:
            return self.send_json(400, {"error": error})
        row = create_task(self.db, user["id"], fields["title"], fields.get("due"), fields["priority"])
        self.send_json(201 if VERSION == "1.1" else 200, task_dict(row))

    def api_task(self, method, user, task_id, status_path):
        owner = user["id"] if VERSION == "1.1" or method != "GET" or status_path else None
        task = get_task(self.db, task_id, owner)
        if task is None:
            return self.send_json(404, {"error": "Задача не найдена"})
        if status_path:
            if method != "POST":
                return self.send_json(405, {"error": "Метод не поддерживается"})
            target = self.json_body().get("status")
            if target not in STATUSES:
                return self.send_json(400, {"error": "Статус: new, in_progress или done"})
            if not can_move(task["status"], target):
                return self.send_json(409, {"error": f"Переход из «{STATUSES[task['status']]}» "
                                                     f"в «{STATUSES[target]}» запрещён"})
            set_status(self.db, task_id, target)
            return self.send_json(200, task_dict(get_task(self.db, task_id)))
        if method == "GET":
            return self.send_json(200, task_dict(task))
        if method == "PATCH":
            fields, error = self.fields(self.json_body(), partial=True)
            if error:
                return self.send_json(400, {"error": error})
            if "due" in fields and fields["due"] is not None:
                fields["due"] = fields["due"].isoformat()
            if fields:
                update_task(self.db, task_id, fields)
            return self.send_json(200, task_dict(get_task(self.db, task_id)))
        if method == "DELETE":
            self.db.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
            return self.send_json(204)
        self.send_json(405, {"error": "Метод не поддерживается"})


# ---------------------------------------------------------------- запуск

def main(argv=None):
    global VERSION, FIXED_TODAY
    parser = argparse.ArgumentParser(description="Задачник — учебное приложение для тестирования")
    parser.add_argument("--version", choices=["1.0", "1.1"], default="1.0", help="версия приложения")
    parser.add_argument("--port", type=int, default=8000, help="порт, по умолчанию 8000")
    parser.add_argument("--reset", action="store_true", help="вернуть учебные данные")
    parser.add_argument("--today", type=dt.date.fromisoformat, help=argparse.SUPPRESS)
    args = parser.parse_args(argv)
    VERSION, FIXED_TODAY = args.version, args.today

    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
                        handlers=[logging.StreamHandler(sys.stdout),
                                  logging.FileHandler(LOG_PATH, encoding="utf-8")])
    if args.reset or not DB_PATH.exists():
        reset_db()
        log.info("Учебные данные созданы: %s", DB_PATH)
    try:
        ThreadingHTTPServer.allow_reuse_address = sys.platform != "win32"  # в Windows иначе два сервера делят порт
        server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
    except OSError as err:
        log.error("Порт %d занят (%s). Закройте другой Задачник или запустите с --port 8080",
                  args.port, err.strerror)
        sys.exit(1)
    log.info("Задачник %s: http://127.0.0.1:%d — остановить: Ctrl+C", VERSION, args.port)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        log.info("Остановлен")


if __name__ == "__main__":
    main()
