#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""serve_study.py — 学习仓库只读 Web 服务器（手机优化排版，纯标准库自带 Markdown 渲染器 v2）

v2 修复：
- 白名单透传安全 HTML：<br>/<details>/<summary>（不再转义成可见文本）
- <details>/<summary> 块级识别 → 折叠答案真正可折叠
- 任务复选框 - [ ] / - [x] → ☐/☑ 样式化
- 两级嵌套列表支持
用法: PORT=8788 [PREFIX=/study] python3 scripts/serve_study.py
"""
import html
import http.server
import os
import re
import socketserver
import sys
import urllib.parse

ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
PORT = int(os.environ.get('PORT', '8788'))
PFX = os.environ.get('PREFIX', '').rstrip('/')

CSS = """
:root{--ink:#1f2328;--mut:#57606a;--line:#e4e7eb;--bg:#ffffff;--acc:#0969da;--codebg:#f6f8fa}
*{box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Segoe UI','Noto Sans SC',sans-serif;
 font-size:17px;line-height:1.85;color:var(--ink);background:var(--bg);margin:0 auto;max-width:760px;
 padding:14px 16px 84px;-webkit-text-size-adjust:100%;word-break:break-word}
h1,h2,h3,h4{line-height:1.35;margin:1.5em 0 .6em}
h1{font-size:1.52em;border-bottom:2px solid var(--line);padding-bottom:.35em;margin-top:.8em}
h2{font-size:1.32em;border-bottom:1px solid var(--line);padding-bottom:.3em}
h3{font-size:1.14em} h4{font-size:1.02em}
p{margin:.7em 0}
a{color:var(--acc);text-decoration:none} a:active{opacity:.6}
strong{color:#111}
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.86em;background:var(--codebg);
 border:1px solid var(--line);border-radius:5px;padding:.1em .38em;word-break:break-all}
pre{background:#0d1117;border:1px solid #30363d;border-radius:10px;padding:12px 14px;
 overflow-x:auto;-webkit-overflow-scrolling:touch;font-size:13.5px;line-height:1.65;color:#e6edf3}
pre code{background:none;border:none;padding:0;font-size:inherit;word-break:normal;white-space:pre;color:inherit}
.tbl{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:1em 0;border:1px solid var(--line);border-radius:10px}
table{border-collapse:collapse;min-width:100%;font-size:.95em}
th,td{border-bottom:1px solid var(--line);padding:8px 12px;text-align:left;vertical-align:top}
th{background:var(--codebg);white-space:nowrap}
tr:last-child td{border-bottom:none}
blockquote{border-left:4px solid var(--acc);background:#f0f7ff;margin:1em 0;padding:.5em 1em;
 border-radius:0 8px 8px 0;color:#223}
blockquote p{margin:.2em 0}
ul,ol{padding-left:1.5em} li{margin:.35em 0}
ul.nest{padding-left:1.3em;margin:.15em 0}
hr{border:none;border-top:1px solid var(--line);margin:1.6em 0}
details{border:1px solid var(--line);border-radius:10px;margin:.9em 0;padding:.35em .95em .6em;background:#fbfcfd}
details[open]{background:#f6fbff}
summary{cursor:pointer;font-weight:600;color:var(--acc);padding:.35em 0;list-style:none}
summary::-webkit-details-marker{display:none}
summary::before{content:'▸ ';display:inline-block;transition:transform .15s}
details[open] summary::before{transform:rotate(90deg)}
.cb{font-size:1.12em;margin-right:.4em;user-select:none;color:var(--mut)}
.dir a{display:block;padding:11px 12px;border-bottom:1px solid var(--line);font-size:1.02em}
.dir a:active{background:var(--codebg)}
.crumb{font-size:.85em;color:var(--mut);margin:6px 0 10px}
.crumb a{color:var(--mut)}
.badge{display:inline-block;font-size:.72em;background:#ddf4ff;color:var(--acc);border-radius:9px;
 padding:0 8px;margin-left:6px;vertical-align:2px}
.top{position:fixed;left:50%;transform:translateX(-50%);bottom:18px;background:rgba(9,105,218,.92);
 color:#fff;border-radius:22px;padding:9px 20px;font-size:15px;box-shadow:0 2px 10px rgba(0,0,0,.18);
 text-decoration:none}
.mermaid{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:1em 0;text-align:center}
"""

# 块级安全 HTML 白名单（整行匹配才透传）
RE_BLOCK_HTML = re.compile(r'^</?details>$|^</?summary>$|^<summary>[^<]*</summary>$')

def _inline(s: str) -> str:
    s = html.escape(s, quote=False)
    codes = []
    def stash(m):
        codes.append(m.group(1)); return '\x00{}\x00'.format(len(codes) - 1)
    s = re.sub(r'`([^`]+)`', stash, s)
    s = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', s)
    s = re.sub(r'(?<![\w*])\*([^*\s][^*]*?)\*(?![\w*])', r'<em>\1</em>', s)
    s = re.sub(r'\[([^\]]+)\]\(([^)\s]+)\)', r'<a href="\2">\1</a>', s)
    # 安全 HTML 白名单透传（仅精确形式，不带任何属性）
    s = re.sub(r'&lt;(br\s*/?)&gt;', lambda m: '<br>', s)
    s = re.sub(r'&lt;(/?details|/?summary)&gt;', r'<\1>', s)
    s = re.sub('\x00(\\d+)\x00', lambda m: '<code>' + codes[int(m.group(1))] + '</code>', s)
    return s

def md_to_html(text: str) -> str:
    lines = text.split('\n')
    out, para = [], []
    in_code, code_lang, code_buf = False, '', []
    list_tag = None
    nest_open = False

    def flush_para():
        if para:
            out.append('<p>' + _inline(' '.join(para)) + '</p>'); para.clear()
    def close_list():
        nonlocal list_tag, nest_open
        if nest_open: out.append('</ul>'); nest_open = False
        if list_tag:
            out.append('</' + list_tag + '>'); list_tag = None

    i = 0
    while i < len(lines):
        raw = lines[i]
        s = raw.strip()
        if s.startswith('```'):
            flush_para(); close_list()
            if not in_code:
                in_code = True; code_lang = s[3:].strip().lower(); code_buf = []
            else:
                in_code = False
                body = html.escape('\n'.join(code_buf))
                if code_lang == 'mermaid':
                    out.append('<div class="mermaid">' + body + '</div>')
                else:
                    out.append('<pre><code class="language-' + (code_lang if code_lang else 'plaintext') + '">' + body + '</code></pre>')
            i += 1; continue
        if in_code:
            code_buf.append(raw); i += 1; continue
        if not s:
            flush_para(); close_list(); i += 1; continue
        # 块级安全 HTML（details/summary）原样透传
        if RE_BLOCK_HTML.match(s):
            flush_para(); close_list()
            out.append(s); i += 1; continue
        m = re.match(r'^(#{1,6})\s+(.*)', s)
        if m:
            flush_para(); close_list()
            lvl = min(6, len(m.group(1)))
            out.append('<h{}>{}</h{}>'.format(lvl, _inline(m.group(2)), lvl)); i += 1; continue
        if re.fullmatch(r'[-*_]{3,}', s):
            flush_para(); close_list(); out.append('<hr>'); i += 1; continue
        if s.startswith('|') and i + 1 < len(lines) and re.fullmatch(r'\|[\s:|\-]+\|?', lines[i + 1].strip()):
            flush_para(); close_list()
            cells = lambda r: [c.strip() for c in r.strip().strip('|').split('|')]
            head = cells(s); i += 2; rows = []
            while i < len(lines) and lines[i].strip().startswith('|'):
                rows.append(cells(lines[i])); i += 1
            t = '<div class="tbl"><table><thead><tr>' + ''.join('<th>' + _inline(c) + '</th>' for c in head) + '</tr></thead><tbody>'
            for r in rows:
                t += '<tr>' + ''.join('<td>' + _inline(c) + '</td>' for c in r) + '</tr>'
            out.append(t + '</tbody></table></div>'); continue
        if s.startswith('>'):
            flush_para(); close_list()
            q = []
            while i < len(lines) and lines[i].strip().startswith('>'):
                q.append(lines[i].strip().lstrip('> ')); i += 1
            out.append('<blockquote>' + _inline(' '.join(q)) + '</blockquote>'); continue
        # 无序/嵌套/复选框列表
        m = re.match(r'^(\s*)([-*+])\s+(.*)$', raw)
        if m:
            indent = len(m.group(1).expandtabs(4)) // 2
            content = m.group(3)
            mc = re.match(r'^\[( |x|X)\]\s*(.*)$', content)
            if mc:
                content = '<span class="cb">' + ('\u2611' if mc.group(1).lower() == 'x' else '\u2610') + '</span>' + _inline(mc.group(2))
            else:
                content = _inline(content)
            flush_para()
            if list_tag != 'ul':
                close_list(); out.append('<ul>'); list_tag = 'ul'
            if indent > 0 and not nest_open:
                out.append('<ul class="nest">'); nest_open = True
            elif indent == 0 and nest_open:
                out.append('</ul>'); nest_open = False
            out.append('<li>' + content + '</li>'); i += 1; continue
        m = re.match(r'^\d+[.)]\s+(.*)', s)
        if m:
            flush_para()
            if nest_open: out.append('</ul>'); nest_open = False
            if list_tag != 'ol': close_list(); out.append('<ol>'); list_tag = 'ol'
            out.append('<li>' + _inline(m.group(1)) + '</li>'); i += 1; continue
        para.append(s); i += 1
    flush_para(); close_list()
    return '\n'.join(out)

def page(title: str, body: str):
    top = '<a class="top" href="' + (PFX or '') + '/">📚 目录</a>'
    has_mermaid = 'class="mermaid"' in body
    has_code = 'class="language-' in body
    extra_head = ''
    if has_mermaid:
        extra_head += ('<script src="' + (PFX or '') + '/assets/mermaid.min.js"></script>'
                       '<script>if(window.mermaid){mermaid.initialize({startOnLoad:true,theme:"neutral",'
                       'flowchart:{useMaxWidth:true},securityLevel:"strict"})}</script>')
    if has_code:
        extra_head += ('<link rel="stylesheet" href="' + (PFX or '') + '/assets/github-dark.min.css">'
                       '<script src="' + (PFX or '') + '/assets/highlight.min.js"></script>'
                       '<script>if(window.hljs){hljs.configure({cssSelector:"pre code"});hljs.highlightAll()}</script>')
    return ('<!doctype html><html lang="zh"><head><meta charset="utf-8">'
            '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
            '<title>' + html.escape(title) + '</title><style>' + CSS + '</style>' + extra_head + '</head>'
            '<body>' + body + top + '</body></html>')

class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=ROOT, **kw)

    def guess_type(self, path):
        # 静态文本文件强制 utf-8：无 charset 时手机浏览器可能按 legacy 编码解码 → 中文乱码
        t = super().guess_type(path)
        if t and 'charset' not in t and (
            t.startswith('text/') or t in ('application/javascript', 'application/json')):
            t += '; charset=utf-8'
        return t

    def log_message(self, fmt, *args):
        sys.stderr.write('%s %s\n' % (self.address_string(), fmt % args))

    def end_headers(self):
        # 强制浏览器每次重新验证，防止旧响应（如缺 charset 的版本）被启发式缓存复用
        self.send_header('Cache-Control', 'no-cache')
        super().end_headers()

    def send_html(self, doc: str):
        data = doc.encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        path = urllib.parse.unquote(self.path)
        path = path.split('?', 1)[0]  # 支持缓存穿透链接（?v=2 等）
        if PFX and (path == PFX or path.startswith(PFX + '/')):
            path = path[len(PFX):] or '/'
        rel = path.lstrip('/')
        fpath = os.path.abspath(os.path.join(ROOT, rel))
        if not fpath.startswith(ROOT):
            self.send_error(403); return
        if os.path.isdir(fpath):
            crumb = '<div class="crumb">📚 claude-architect-study / ' + html.escape(rel) + '</div>'
            items = []
            try: entries = sorted(os.listdir(fpath))
            except OSError: entries = []
            for e in entries:
                if e.startswith('.') or e == '__pycache__': continue
                full = os.path.join(fpath, e)
                href = PFX + '/' + urllib.parse.quote(os.path.join(path.rstrip('/'), e).lstrip('/'))
                tag = '📁' if os.path.isdir(full) else ('📝' if e.endswith('.md') else '📄')
                badge = '<span class="badge">主课</span>' if e == 'README.md' else ''
                items.append('<a href="' + href + '">' + tag + ' ' + html.escape(e) + badge + '</a>')
            name = rel or '根目录'
            self.send_html(page(name + ' — 学习仓库', crumb + '<h1>' + html.escape(name) + '</h1><div class="dir">' + ''.join(items) + '</div>'))
            return
        if fpath.endswith('.md') and os.path.isfile(fpath):
            text = open(fpath, encoding='utf-8', errors='ignore').read()
            body = md_to_html(text)
            crumb_dir = os.path.dirname(rel)
            crumb = ('<div class="crumb"><a href="' + PFX + '/">📚 首页</a>'
                     + (' / <a href="' + PFX + '/' + urllib.parse.quote(crumb_dir) + '">' + html.escape(crumb_dir) + '</a>' if crumb_dir else '')
                     + ' / ' + html.escape(os.path.basename(rel)) + '</div>')
            self.send_html(page(os.path.basename(rel), crumb + body))
            return
        super().do_GET()

class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True

if __name__ == '__main__':
    with Server(('0.0.0.0', PORT), Handler) as httpd:
        print('serving', ROOT, 'at :', PORT, 'prefix=', PFX or '/', '(built-in md renderer v2)', flush=True)
        httpd.serve_forever()
