#!/usr/bin/env python3
"""Local teaching backend. Python 3.10+, SQLite, no dependencies or payments.
Run: python3 server.py --port 8080 --db shop.sqlite
"""
import argparse
import hashlib
import json
import sqlite3
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs

PRODUCTS = [
    ('vase', 'Ваза Contour', 'Керамика', 480000, 8),
    ('cup', 'Чашка Everyday', 'Керамика', 240000, 15),
    ('ring', 'Кольцо Orbit', 'Аксессуары', 720000, 4),
    ('speaker', 'Колонка Frequency', 'Техника', 1890000, 6),
    ('chair', 'Стул Frame', 'Мебель', 2450000, 3),
    ('bottle', 'Флакон Essence', 'Аксессуары', 360000, 12),
]

class ShopError(Exception):
    def __init__(self, message, status=422, code='INVALID_REQUEST'):
        super().__init__(message)
        self.status, self.code = status, code

def connect(db):
    conn = sqlite3.connect(db, timeout=5, isolation_level=None)
    conn.row_factory = sqlite3.Row
    conn.execute('PRAGMA foreign_keys=ON')
    conn.execute('PRAGMA busy_timeout=5000')
    return conn

def initialize(db):
    conn = connect(db)
    try:
        conn.executescript('''
        CREATE TABLE IF NOT EXISTS products (
          id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL,
          price INTEGER NOT NULL CHECK(price >= 0), stock INTEGER NOT NULL CHECK(stock >= 0)
        );
        CREATE TABLE IF NOT EXISTS orders (
          id TEXT PRIMARY KEY, idempotency_key TEXT UNIQUE NOT NULL,
          payload_hash TEXT NOT NULL, result TEXT NOT NULL,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
        PRAGMA user_version=1;
        ''')
        conn.executemany('INSERT OR IGNORE INTO products VALUES (?,?,?,?,?)', PRODUCTS)
    finally:
        conn.close()

def normalize(payload):
    if not isinstance(payload, dict) or not isinstance(payload.get('items'), list):
        raise ShopError('Нужен объект с массивом items.')
    if not 1 <= len(payload['items']) <= 20:
        raise ShopError('От 1 до 20 позиций в заказе.')
    items = {}
    for item in payload['items']:
        if not isinstance(item, dict):
            raise ShopError('Каждая позиция должна быть объектом.')
        pid, quantity = item.get('id'), item.get('quantity')
        if not isinstance(pid, str) or not 1 <= len(pid) <= 64:
            raise ShopError('Некорректный id товара.')
        if type(quantity) is not int or not 1 <= quantity <= 20:
            raise ShopError('quantity — целое число от 1 до 20.')
        items[pid] = items.get(pid, 0) + quantity
        if items[pid] > 20:
            raise ShopError('Не больше 20 единиц одного товара.')
    return [{'id': pid, 'quantity': quantity} for pid, quantity in sorted(items.items())]

def calculate(conn, items):
    lines = []
    for line in items:
        p = conn.execute('SELECT * FROM products WHERE id=?', (line['id'],)).fetchone()
        if p is None:
            raise ShopError('Неизвестный товар.', 404, 'UNKNOWN_PRODUCT')
        if p['stock'] < line['quantity']:
            raise ShopError('Недостаточно товара: ' + p['name'], 409, 'OUT_OF_STOCK')
        lines.append({**line, 'unitPrice': p['price'], 'lineTotal': p['price'] * line['quantity']})
    return {'currency': 'RUB', 'items': lines, 'total': sum(l['lineTotal'] for l in lines), 'demo': True}

def quote(db, payload):
    items = normalize(payload)
    conn = connect(db)
    try:
        return calculate(conn, items)
    finally:
        conn.close()

def create_order(db, payload, key):
    if not isinstance(key, str) or not 8 <= len(key) <= 128 or not key.isascii():
        raise ShopError('Передайте Idempotency-Key длиной 8–128 ASCII символов.', 400, 'IDEMPOTENCY_REQUIRED')
    items = normalize(payload)
    digest = hashlib.sha256(json.dumps(items, sort_keys=True).encode()).hexdigest()
    conn = connect(db)
    try:
        conn.execute('BEGIN IMMEDIATE')
        prior = conn.execute('SELECT * FROM orders WHERE idempotency_key=?', (key,)).fetchone()
        if prior:
            if prior['payload_hash'] != digest:
                raise ShopError('Этот ключ уже использован с другой корзиной.', 409, 'KEY_CONFLICT')
            conn.execute('COMMIT')
            return json.loads(prior['result']), False
        result = {**calculate(conn, items), 'id': str(uuid.uuid4()), 'status': 'demo_created', 'payment': 'not_configured'}
        for line in items:
            changed = conn.execute('UPDATE products SET stock=stock-? WHERE id=? AND stock>=?',
                (line['quantity'], line['id'], line['quantity'])).rowcount
            if changed != 1:
                raise ShopError('Остаток изменился. Повторите расчёт.', 409, 'OUT_OF_STOCK')
        conn.execute('INSERT INTO orders (id,idempotency_key,payload_hash,result) VALUES (?,?,?,?)',
            (result['id'], key, digest, json.dumps(result, ensure_ascii=False)))
        conn.execute('COMMIT')
        return result, True
    except Exception:
        if conn.in_transaction:
            conn.execute('ROLLBACK')
        raise
    finally:
        conn.close()

def handler_for(db):
    class Handler(BaseHTTPRequestHandler):
        def setup(self):
            super().setup()
            self.connection.settimeout(10)

        def send_json(self, value, status=200):
            body = json.dumps(value, ensure_ascii=False).encode()
            self.send_response(status)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', str(len(body)))
            self.send_header('Cache-Control', 'no-store')
            self.send_header('X-Content-Type-Options', 'nosniff')
            self.end_headers()
            self.wfile.write(body)

        def do_GET(self):
            url = urlparse(self.path)
            if url.path == '/':
                body = (Path(__file__).parent / 'index.html').read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            elif url.path == '/api/products':
                params = parse_qs(url.query)
                q, category = params.get('q', [''])[0][:100], params.get('category', [''])[0][:100]
                ordering = {'price-asc': 'price ASC, id ASC', 'price-desc': 'price DESC, id ASC'}.get(params.get('sort', [''])[0], 'id ASC')
                conn = connect(db)
                try:
                    rows = conn.execute('SELECT * FROM products WHERE name LIKE ? AND (? = ? OR category = ?) ORDER BY ' + ordering,
                        ('%' + q + '%', category, '', category)).fetchall()
                    self.send_json({'items': [dict(row) for row in rows]})
                finally:
                    conn.close()
            else:
                self.send_json({'error': {'code': 'NOT_FOUND', 'message': 'Маршрут не найден.'}}, 404)

        def do_POST(self):
            try:
                # This local teaching server intentionally rejects transfer encodings.
                if self.headers.get('Transfer-Encoding'):
                    self.close_connection = True
                    raise ShopError('Transfer-Encoding не поддерживается.', 400)
                if not self.headers.get('Content-Type', '').startswith('application/json'):
                    raise ShopError('Используйте application/json.', 415)
                try:
                    length = int(self.headers.get('Content-Length', '-1'))
                except ValueError:
                    raise ShopError('Некорректная длина запроса.', 400)
                if length < 0 or length > 16384:
                    self.close_connection = True
                    raise ShopError('Допустим запрос не больше 16 КБ с Content-Length.', 413)
                raw = self.rfile.read(length)
                if len(raw) != length:
                    raise ShopError('Неполный запрос.', 400)
                try:
                    data = json.loads(raw)
                except (json.JSONDecodeError, UnicodeDecodeError):
                    raise ShopError('Некорректный JSON.', 400, 'INVALID_JSON')
                if self.path == '/api/quote':
                    self.send_json(quote(db, data))
                elif self.path == '/api/orders':
                    result, created = create_order(db, data, self.headers.get('Idempotency-Key'))
                    self.send_json(result, 201 if created else 200)
                else:
                    raise ShopError('Маршрут не найден.', 404, 'NOT_FOUND')
            except ShopError as e:
                self.send_json({'error': {'code': e.code, 'message': str(e)}}, e.status)
            except (TimeoutError, ConnectionError):
                self.close_connection = True
            except sqlite3.OperationalError:
                self.send_json({'error': {'code': 'RETRY', 'message': 'База занята. Повторите с тем же ключом.'}}, 503)
            except Exception:
                self.send_json({'error': {'code': 'INTERNAL', 'message': 'Внутренняя ошибка сервера.'}}, 500)

        def log_message(self, *_args):
            pass
    return Handler

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--port', type=int, default=8080)
    parser.add_argument('--db', default=str(Path(__file__).with_name('shop.sqlite')))
    args = parser.parse_args()
    initialize(args.db)
    server = ThreadingHTTPServer(('127.0.0.1', args.port), handler_for(args.db))
    print(f'Local demo: http://127.0.0.1:{args.port} — no payments', flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        server.server_close()
