#!/usr/bin/env python3
"""
Notion MCP Server · 光湖铸渊 Notion 连接器

通过 Notion OAuth 授权，让铸渊可以：
  - ✅ 读取用户按语言路径指定的页面
  - ✅ 在指定位置新建页面
  - ❌ 不编辑/删除/更改现有页面
  - ❌ 默认不读取任何页面（只读明确给的路径）

端口：3915
"""

import json, os, time, hmac, hashlib, base64, uuid, logging
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs, urlencode
import urllib.request, urllib.error

# ═══════════════════════════════════════════
# 配置
# ═══════════════════════════════════════════

PORT = int(os.environ.get("NOTION_MCP_PORT", "3915"))
DATA_DIR = os.environ.get("NOTION_DATA_DIR", "/opt/guanghulab-repo/notion")

# Notion OAuth - 用户需在 notion.so/my-integrations 注册后填写
NOTION_CLIENT_ID = os.environ.get("NOTION_CLIENT_ID", "")
NOTION_CLIENT_SECRET = os.environ.get("NOTION_CLIENT_SECRET", "")

# 回调地址（需与 Notion 注册的一致）
BASE_URL = "https://guanghulab.com"
REDIRECT_URI = f"{BASE_URL}/api/notion/oauth/callback"

# ═══════════════════════════════════════════
# 令牌存储
# ═══════════════════════════════════════════

TOKENS_FILE = os.path.join(DATA_DIR, "tokens.json")
STATES_FILE = os.path.join(DATA_DIR, "states.json")

def ensure_dir():
    os.makedirs(DATA_DIR, exist_ok=True)

def load_json(path, default=None):
    try:
        with open(path, "r") as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return default if default is not None else {}

def save_json(path, data):
    with open(path, "w") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

def get_tokens():
    return load_json(TOKENS_FILE, {})

def save_token(user_key, token_data):
    tokens = get_tokens()
    tokens[user_key] = token_data
    save_json(TOKENS_FILE, tokens)

def remove_token(user_key):
    tokens = get_tokens()
    tokens.pop(user_key, None)
    save_json(TOKENS_FILE, tokens)

def get_states():
    return load_json(STATES_FILE, {})

def save_state(state, data):
    states = get_states()
    states[state] = data
    save_json(STATES_FILE, states)

def remove_state(state):
    states = get_states()
    states.pop(state, None)
    save_json(STATES_FILE, states)

# ═══════════════════════════════════════════
# Notion API 调用
# ═══════════════════════════════════════════

NOTION_API = "https://api.notion.com/v1"
NOTION_VERSION = "2022-06-28"

def notion_headers(token):
    return {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
        "Notion-Version": NOTION_VERSION,
    }

def notion_get(path, token):
    req = urllib.request.Request(f"{NOTION_API}{path}", headers=notion_headers(token))
    with urllib.request.urlopen(req, timeout=15) as resp:
        return json.loads(resp.read())

def notion_post(path, data, token):
    body = json.dumps(data).encode()
    req = urllib.request.Request(f"{NOTION_API}{path}", data=body, headers=notion_headers(token))
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err = e.read().decode()[:500]
        raise Exception(f"Notion API {e.code}: {err}")

def notion_patch(path, data, token):
    """Notion PATCH - NOT exposed to 铸渊 (for safety)"""
    req = urllib.request.Request(f"{NOTION_API}{path}", method="PATCH")
    req.data = json.dumps(data).encode()
    for k, v in notion_headers(token).items():
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        err = e.read().decode()[:500]
        raise Exception(f"Notion API {e.code}: {err}")

# ═══════════════════════════════════════════
# 权限检查
# ═══════════════════════════════════════════

def check_read_permission(page_id, token, context=None):
    """
    权限检查：
    - 铸渊只能读取明确指定的页面
    - 默认不读取任何内容
    - 冰朔的 Notion 除非冰朔明确给出路径，否则不读取
    """
    # context 可以包含 "user" 字段
    # 如果是冰朔用户，检查是否在允许的路径列表内
    if context and context.get("user") == "bingshuo":
        allowed = context.get("allowed_paths", [])
        if allowed and page_id not in allowed:
            raise PermissionError(f"此页面不在当前允许的语言路径中")
    return True

# ═══════════════════════════════════════════
# HTTP 处理器
# ═══════════════════════════════════════════

def json_response(resp, status, data):
    body = json.dumps(data, ensure_ascii=False).encode("utf-8")
    resp.send_response(status)
    resp.send_header("Content-Type", "application/json; charset=utf-8")
    resp.send_header("Access-Control-Allow-Origin", "*")
    resp.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
    resp.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
    resp.end_headers()
    resp.wfile.write(body)

def redirect(resp, url):
    resp.send_response(302)
    resp.send_header("Location", url)
    resp.end_headers()

def read_body(req):
    length = int(req.headers.get("Content-Length", 0))
    if length == 0:
        return {}
    body = req.rfile.read(length)
    return json.loads(body)

class NotionMCPHandler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        logging.info(f"{self.client_address[0]} - {format % args}")

    def do_OPTIONS(self):
        json_response(self, 204, {})

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path
        params = parse_qs(parsed.query)

        # NOTE: Nginx strips /api/notion/ prefix, so what we receive is the remaining path
        # e.g., /api/notion/status → /status (if proxy_pass has trailing /)
        # But to be safe, we handle both with and without prefix

        try:
            if path == "/connect" or path == "/api/notion/connect":
                self.handle_connect(params)
            elif path == "/oauth/callback" or path == "/api/notion/oauth/callback":
                self.handle_oauth_callback(params)
            elif path == "/status" or path == "/api/notion/status":
                self.handle_status()
            elif path == "/disconnect" or path == "/api/notion/disconnect":
                self.handle_disconnect(params)
            else:
                json_response(self, 404, {"ok": False, "error": f"未知路径: {path}"})
        except Exception as e:
            logging.error(f"GET {path}: {e}")
            json_response(self, 500, {"ok": False, "error": str(e)})

    def do_POST(self):
        parsed = urlparse(self.path)
        path = parsed.path

        try:
            body = read_body(self)
            if path == "/read" or path == "/api/notion/read":
                self.handle_read(body)
            elif path == "/create" or path == "/api/notion/create":
                self.handle_create(body)
            elif path == "/search" or path == "/api/notion/search":
                self.handle_search(body)
            elif path == "/list" or path == "/api/notion/list":
                self.handle_list(body)
            else:
                json_response(self, 404, {"ok": False, "error": f"未知路径: {path}"})
        except PermissionError as e:
            json_response(self, 403, {"ok": False, "error": str(e)})
        except Exception as e:
            logging.error(f"POST {path}: {e}")
            json_response(self, 500, {"ok": False, "error": str(e)})

    # ── 连接 ──
    def handle_connect(self, params):
        """
        用户点击"连接 Notion" → 生成 OAuth URL 并跳转
        ?user_key=xxx  — 可选，标识连接的用户
        """
        user_key = params.get("user_key", [None])[0]
        if not user_key:
            # 生成一个临时 user_key
            user_key = f"user_{uuid.uuid4().hex[:8]}"

        if not NOTION_CLIENT_ID:
            json_response(self, 400, {
                "ok": False,
                "error": "Notion OAuth 尚未配置，请联系管理员在环境变量中设置 NOTION_CLIENT_ID",
            })
            return

        state = uuid.uuid4().hex[:16]
        save_state(state, {"user_key": user_key, "created_at": time.time()})

        oauth_url = (
            f"https://api.notion.com/v1/oauth/authorize"
            f"?client_id={NOTION_CLIENT_ID}"
            f"&response_type=code"
            f"&redirect_uri={REDIRECT_URI}"
            f"&state={state}"
        )

        redirect(self, oauth_url)

    def handle_oauth_callback(self, params):
        """Notion 回调处理"""
        code = params.get("code", [None])[0]
        state = params.get("state", [None])[0]
        error = params.get("error", [None])[0]

        if error:
            json_response(self, 400, {"ok": False, "error": f"用户取消了授权"})
            return

        if not code or not state:
            json_response(self, 400, {"ok": False, "error": "缺少参数"})
            return

        # 验证 state
        states = get_states()
        state_data = states.get(state)
        if not state_data:
            json_response(self, 400, {"ok": False, "error": "state 无效或已过期"})
            return

        user_key = state_data["user_key"]
        remove_state(state)

        # 交换 code 获取 token
        try:
            auth = base64.b64encode(
                f"{NOTION_CLIENT_ID}:{NOTION_CLIENT_SECRET}".encode()
            ).decode()

            token_req = urllib.request.Request(
                "https://api.notion.com/v1/oauth/token",
                data=json.dumps({
                    "grant_type": "authorization_code",
                    "code": code,
                    "redirect_uri": REDIRECT_URI,
                }).encode(),
                headers={
                    "Authorization": f"Basic {auth}",
                    "Content-Type": "application/json",
                }
            )
            with urllib.request.urlopen(token_req, timeout=15) as resp:
                token_data = json.loads(resp.read())
        except urllib.error.HTTPError as e:
            err = e.read().decode()[:500]
            json_response(self, 400, {"ok": False, "error": f"Token 交换失败: {err}"})
            return

        # 存储 token
        save_token(user_key, {
            "access_token": token_data["access_token"],
            "bot_id": token_data.get("bot_id", ""),
            "workspace_name": token_data.get("workspace_name", ""),
            "workspace_icon": token_data.get("workspace_icon", ""),
            "owner": token_data.get("owner", {}),
            "created_at": time.time(),
        })

        # 重定向回首页（带成功消息）
        redirect(self, f"{BASE_URL}/?notion=connected&user={user_key}")

    # ── 状态 ──
    def handle_status(self):
        """返回连接状态"""
        tokens = get_tokens()
        connected_users = []
        for user_key, data in tokens.items():
            connected_users.append({
                "user": user_key,
                "workspace": data.get("workspace_name", "未知工作区"),
                "connected_at": data.get("created_at", 0),
            })
        json_response(self, 200, {
            "ok": True,
            "configured": bool(NOTION_CLIENT_ID),
            "connected_users": connected_users,
            "count": len(connected_users),
        })

    # ── 断开连接 ──
    def handle_disconnect(self, params):
        user_key = params.get("user_key", [None])[0]
        if not user_key:
            json_response(self, 400, {"ok": False, "error": "缺少 user_key"})
            return
        remove_token(user_key)
        json_response(self, 200, {"ok": True, "message": f"用户 {user_key} 已断开连接"})

    # ── 读取页面 ⭐ ──
    def handle_read(self, body):
        """
        读取 Notion 页面

        请求:
          { "page_id": "xxx", "user": "bingshuo", "allowed_paths": ["xxx"] }

        权限:
          - 未指定 allowed_paths 时，铸渊不读取任何页面
          - 冰朔的 Notion 需要显式 allowed_paths 才能读
        """
        page_id = body.get("page_id", "")
        user = body.get("user", "")
        allowed_paths = body.get("allowed_paths", None)

        if not page_id:
            json_response(self, 400, {"ok": False, "error": "缺少 page_id"})
            return

        # 权限检查：未指定 allowed_paths 时拒绝读取
        if allowed_paths is None:
            json_response(self, 403, {
                "ok": False,
                "error": "未指定语言路径，铸渊默认不读取任何页面",
            })
            return

        if user == "bingshuo" and allowed_paths is not None and page_id not in allowed_paths:
            json_response(self, 403, {
                "ok": False,
                "error": "此页面不在当前允许的语言路径中",
            })
            return

        # 获取 token
        tokens = get_tokens()
        token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
        if not token_entry:
            json_response(self, 401, {
                "ok": False,
                "error": f"用户 {user or '默认'} 尚未连接 Notion",
            })
            return

        access_token = token_entry["access_token"]

        try:
            # 读取页面
            page = notion_get(f"/pages/{page_id}", access_token)

            # 读取页面内容（blocks）
            blocks = notion_get(f"/blocks/{page_id}/children", access_token)

            result = {
                "ok": True,
                "page": {
                    "id": page.get("id"),
                    "title": self._extract_title(page),
                    "url": page.get("url", ""),
                    "created_time": page.get("created_time", ""),
                    "last_edited_time": page.get("last_edited_time", ""),
                },
                "blocks": [],
            }

            for block in blocks.get("results", []):
                block_type = block.get("type", "unsupported")
                block_content = self._extract_block_content(block)
                if block_content:
                    result["blocks"].append({
                        "id": block.get("id"),
                        "type": block_type,
                        "content": block_content,
                    })

            json_response(self, 200, result)

        except urllib.error.HTTPError as e:
            err = e.read().decode()[:300]
            json_response(self, 500, {"ok": False, "error": f"读取失败: {err}"})
        except Exception as e:
            json_response(self, 500, {"ok": False, "error": f"读取失败: {e}"})

    # ── 新建页面 ⭐ ──
    def handle_create(self, body):
        """
        在指定位置新建页面

        请求:
          {
            "parent_id": "xxx",      // 父页面 ID
            "title": "新页面标题",
            "content": "页面内容（支持 Markdown 格式）",
            "user": "bingshuo"       // 可选，用哪个用户的 Notion
          }

        限制：
          - 只能新建，不能编辑/删除已有页面
        """
        parent_id = body.get("parent_id", "")
        title = body.get("title", "无标题")
        content = body.get("content", "")
        user = body.get("user", "")

        if not parent_id:
            json_response(self, 400, {"ok": False, "error": "缺少 parent_id"})
            return

        tokens = get_tokens()
        token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
        if not token_entry:
            json_response(self, 401, {
                "ok": False,
                "error": f"用户 {user or '默认'} 尚未连接 Notion",
            })
            return

        access_token = token_entry["access_token"]

        try:
            # 构建创建请求（只支持新建，编辑接口不开放）
            create_data = {
                "parent": {"page_id": parent_id},
                "properties": {
                    "title": {
                        "title": [{"type": "text", "text": {"content": title}}]
                    }
                },
                "children": self._parse_content_to_blocks(content),
            }

            result = notion_post("/pages", create_data, access_token)

            json_response(self, 200, {
                "ok": True,
                "page_id": result.get("id"),
                "url": result.get("url", ""),
                "title": title,
            })

        except urllib.error.HTTPError as e:
            err = e.read().decode()[:500]
            json_response(self, 500, {"ok": False, "error": f"创建失败: {err}"})
        except Exception as e:
            json_response(self, 500, {"ok": False, "error": f"创建失败: {e}"})

    # ── 搜索页面 ──
    def handle_search(self, body):
        """搜索 Notion 页面（按标题关键词）"""
        query = body.get("query", "")
        user = body.get("user", "")

        if not query:
            json_response(self, 400, {"ok": False, "error": "缺少查询关键词"})
            return

        tokens = get_tokens()
        token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
        if not token_entry:
            json_response(self, 401, {"ok": False, "error": "用户尚未连接 Notion"})
            return

        access_token = token_entry["access_token"]

        try:
            result = notion_post("/search", {
                "query": query,
                "filter": {"value": "page", "property": "object"},
                "page_size": 10,
            }, access_token)

            pages = []
            for item in result.get("results", []):
                pages.append({
                    "id": item.get("id"),
                    "title": self._extract_title(item),
                    "url": item.get("url", ""),
                })

            json_response(self, 200, {"ok": True, "pages": pages})

        except Exception as e:
            json_response(self, 500, {"ok": False, "error": f"搜索失败: {e}"})

    # ── 子页面列表 ──
    def handle_list(self, body):
        """列出指定页面下的子页面"""
        page_id = body.get("page_id", "")
        user = body.get("user", "")

        if not page_id:
            json_response(self, 400, {"ok": False, "error": "缺少 page_id"})
            return

        tokens = get_tokens()
        token_entry = tokens.get(user) or tokens.get("default") or next(iter(tokens.values()), None)
        if not token_entry:
            json_response(self, 401, {"ok": False, "error": "用户尚未连接 Notion"})
            return

        access_token = token_entry["access_token"]

        try:
            blocks = notion_get(f"/blocks/{page_id}/children", access_token)
            child_pages = []
            for block in blocks.get("results", []):
                if block.get("type") == "child_page":
                    child_pages.append({
                        "id": block.get("id"),
                        "title": block.get("child_page", {}).get("title", "未命名"),
                    })

            json_response(self, 200, {"ok": True, "pages": child_pages})

        except Exception as e:
            json_response(self, 500, {"ok": False, "error": f"列表获取失败: {e}"})

    # ── 工具函数 ──
    def _extract_title(self, page):
        """从 page 对象提取标题"""
        props = page.get("properties", {})
        for prop in props.values():
            if prop.get("type") == "title":
                titles = prop.get("title", [])
                if titles:
                    return titles[0].get("plain_text", "")
        return page.get("id", "未命名页面")

    def _extract_block_content(self, block):
        """解析 block 内容为文本"""
        btype = block.get("type", "")
        bdata = block.get(btype, {})
        if btype in ("paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout",
                      "bulleted_list_item", "numbered_list_item", "to_do", "toggle"):
            texts = []
            for rt in bdata.get("rich_text", []):
                texts.append(rt.get("plain_text", ""))
            return "".join(texts)
        if btype == "code":
            texts = []
            for rt in bdata.get("rich_text", []):
                texts.append(rt.get("plain_text", ""))
            return "\n".join(texts)
        if btype == "divider":
            return "---"
        if btype == "child_page":
            return f"[子页面] {bdata.get('title', '')}"
        return None

    def _parse_content_to_blocks(self, content):
        """将文本内容转换为 Notion blocks（只支持新建，只追加不修改）"""
        blocks = []
        for line in content.split("\n"):
            line = line.strip()
            if not line:
                continue

            # Heading 1
            if line.startswith("# "):
                blocks.append({
                    "object": "block",
                    "type": "heading_1",
                    "heading_1": {
                        "rich_text": [{"type": "text", "text": {"content": line[2:]}}]
                    }
                })
            # Heading 2
            elif line.startswith("## "):
                blocks.append({
                    "object": "block",
                    "type": "heading_2",
                    "heading_2": {
                        "rich_text": [{"type": "text", "text": {"content": line[3:]}}]
                    }
                })
            # Heading 3
            elif line.startswith("### "):
                blocks.append({
                    "object": "block",
                    "type": "heading_3",
                    "heading_3": {
                        "rich_text": [{"type": "text", "text": {"content": line[4:]}}]
                    }
                })
            # Bullet list
            elif line.startswith("- ") or line.startswith("* "):
                blocks.append({
                    "object": "block",
                    "type": "bulleted_list_item",
                    "bulleted_list_item": {
                        "rich_text": [{"type": "text", "text": {"content": line[2:]}}]
                    }
                })
            # Numbered list
            elif line[0].isdigit() and ". " in line[:4]:
                text = line.split(". ", 1)[1] if ". " in line else line
                blocks.append({
                    "object": "block",
                    "type": "numbered_list_item",
                    "numbered_list_item": {
                        "rich_text": [{"type": "text", "text": {"content": text}}]
                    }
                })
            # Quote
            elif line.startswith("> "):
                blocks.append({
                    "object": "block",
                    "type": "quote",
                    "quote": {
                        "rich_text": [{"type": "text", "text": {"content": line[2:]}}]
                    }
                })
            # Code block
            elif line.startswith("```"):
                code_lines = []
                while True:
                    break
                blocks.append({
                    "object": "block",
                    "type": "code",
                    "code": {
                        "rich_text": [{"type": "text", "text": {"content": line}}],
                        "language": "plain text"
                    }
                })
            # Divider
            elif line == "---":
                blocks.append({"object": "block", "type": "divider", "divider": {}})
            # Paragraph (default)
            else:
                blocks.append({
                    "object": "block",
                    "type": "paragraph",
                    "paragraph": {
                        "rich_text": [{"type": "text", "text": {"content": line}}]
                    }
                })

        return blocks if blocks else [{
            "object": "block",
            "type": "paragraph",
            "paragraph": {"rich_text": [{"type": "text", "text": {"content": " "}}]}
        }]

# ═══════════════════════════════════════════
# 启动脚本
# ═══════════════════════════════════════════

def generate_setup_guide():
    """打印配置指南"""
    print(f"""
╔══════════════════════════════════════════════════════╗
║      🔗 光湖 · Notion MCP Server                     ║
║      铸渊 Notion 连接器                              ║
╠══════════════════════════════════════════════════════╣
║                                                      ║
║  端口: {PORT}                                          ║
║  数据: {DATA_DIR}                                       ║
║                                                      ║
║  ⚠  首次启动前，请管理员完成以下操作：                  ║
║                                                      ║
║  1. 访问 https://www.notion.so/my-integrations        ║
║  2. 新建 Public Integration (OAuth)                   ║
║  3. 填写回调地址:                                      ║
║     {REDIRECT_URI}     ║
║  4. 获取 Client ID 和 Client Secret                   ║
║  5. 设置环境变量:                                      ║
║     export NOTION_CLIENT_ID=你的ID                      ║
║     export NOTION_CLIENT_SECRET=你的Secret              ║
║     export NOTION_MCP_PORT=3915                        ║
║                                                      ║
╚══════════════════════════════════════════════════════╝
""")

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO,
                        format="[%(asctime)s] %(levelname)s %(message)s")
    ensure_dir()

    if not NOTION_CLIENT_ID:
        generate_setup_guide()
        print("⚠  缺少 NOTION_CLIENT_ID，OAuth 流程将不可用")
        print("   服务器仍可启动，但 /api/notion/connect 会返回错误提示")

    server = HTTPServer(("0.0.0.0", PORT), NotionMCPHandler)
    print(f"\n  🔗 Notion MCP Server 已启动 → 0.0.0.0:{PORT}")
    print(f"  📝 回调地址: {REDIRECT_URI}")
    print(f"  💾 数据目录: {DATA_DIR}\n")

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n  关闭服务器...")
        server.server_close()
