#!/usr/bin/env python3
"""OpenClaw note CRUD orchestrator for Feishu docs."""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from typing import Any, Dict, List, Optional, Tuple


class CLIError(Exception):
    def __init__(self, message: str, code: str = "cli_error", detail: Any = None):
        super().__init__(message)
        self.code = code
        self.detail = detail


DOCX_URL_RE = re.compile(r"/docx/([A-Za-z0-9_-]+)")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Feishu note CRUD via lark-cli")
    parser.add_argument(
        "--op",
        required=True,
        choices=[
            "create",
            "read",
            "update",
            "delete",
            "search",
            "media-insert",
            "media-download",
            "comment",
            "section-update",
        ],
        help="note operation",
    )
    parser.add_argument("--doc", help="doc token or doc/docx URL")
    parser.add_argument("--title", help="note title")
    parser.add_argument("--markdown", help="note markdown content")
    parser.add_argument("--query", help="search keyword")
    parser.add_argument("--mode", default="append", help="update mode")
    parser.add_argument("--new-title", help="rename title while update")
    parser.add_argument("--selection-by-title", help="title-based locator for update")
    parser.add_argument(
        "--selection-with-ellipsis", help="content locator for range update/delete"
    )
    parser.add_argument("--folder-token", help="target folder token when creating")
    parser.add_argument("--wiki-node", help="target wiki node token when creating")
    parser.add_argument("--wiki-space", help="target wiki space when creating")
    parser.add_argument("--page-size", default="15", help="search page size")
    parser.add_argument("--page-token", help="search page token")
    parser.add_argument("--file", help="local file path for media insert")
    parser.add_argument(
        "--media-type",
        default="image",
        choices=["image", "file"],
        help="media type for insert: image | file",
    )
    parser.add_argument(
        "--token", help="media token or whiteboard token for media download"
    )
    parser.add_argument(
        "--resource-type",
        default="media",
        choices=["media", "whiteboard"],
        help="resource type for media download: media | whiteboard",
    )
    parser.add_argument("--output", help="local output path for media download")
    parser.add_argument(
        "--overwrite", action="store_true", help="overwrite existing download file"
    )
    parser.add_argument("--caption", help="image caption for media insert")
    parser.add_argument(
        "--align", choices=["left", "center", "right"], help="image alignment"
    )
    parser.add_argument("--comment", help="plain text comment body")
    parser.add_argument(
        "--comment-json",
        help="raw reply_elements JSON array for advanced comment payloads",
    )
    parser.add_argument(
        "--block-id", help="comment anchor block ID for local comment mode"
    )
    parser.add_argument(
        "--full-comment",
        action="store_true",
        help="create full-document comment instead of local comment",
    )
    parser.add_argument("--section-title", help="title locator like '## Section'")
    parser.add_argument(
        "--section-mode",
        choices=["replace", "delete", "insert_before", "insert_after"],
        default="replace",
        help="section update mode",
    )
    parser.add_argument(
        "--as",
        dest="identity",
        default="bot",
        help="bot | user (default: bot for cloud deployments)",
    )
    parser.add_argument("--config-dir", help="optional LARKSUITE_CLI_CONFIG_DIR")
    parser.add_argument(
        "--lark-cli-prefix",
        action="append",
        default=[],
        help="repeatable argv prefix, e.g. --lark-cli-prefix npx --lark-cli-prefix -y ...",
    )
    parser.add_argument(
        "--lark-cli-bin",
        default="lark-cli",
        help="(legacy) lark-cli executable path; ignored if --lark-cli-prefix is provided",
    )
    return parser.parse_args()


def build_env(config_dir: Optional[str]) -> Dict[str, str]:
    env = os.environ.copy()
    if config_dir:
        env["LARKSUITE_CLI_CONFIG_DIR"] = config_dir
    return env


def run_cli(
    cli_prefix: List[str],
    env: Dict[str, str],
    args: List[str],
    cwd: Optional[str] = None,
) -> Any:
    # Windows: if prefix points to a .py script, run via current Python.
    if (
        len(cli_prefix) == 1
        and cli_prefix[0].endswith(".py")
        and os.path.exists(cli_prefix[0])
    ):
        cli_prefix = [sys.executable, cli_prefix[0]]

    proc = subprocess.run(
        cli_prefix + args,
        capture_output=True,
        text=True,
        env=env,
        cwd=cwd,
    )
    stdout = proc.stdout.strip()
    stderr = proc.stderr.strip()

    parsed: Any = None
    if stdout:
        try:
            parsed = json.loads(stdout)
        except json.JSONDecodeError as exc:
            raise CLIError(
                f"lark-cli returned non-JSON stdout: {stdout[:400]}",
                code="invalid_cli_output",
                detail={"stderr": stderr, "args": args},
            ) from exc

    if proc.returncode != 0:
        detail = parsed if isinstance(parsed, dict) else {"stderr": stderr}
        raise CLIError(
            f"lark-cli command failed: {' '.join(args)}",
            code="lark_cli_failed",
            detail=detail,
        )
    return parsed


def localize_cli_path(path: str) -> Tuple[str, str]:
    abs_path = os.path.abspath(path)
    parent = os.path.dirname(abs_path) or os.getcwd()
    return parent, f"./{os.path.basename(abs_path)}"


def unwrap_payload(payload: Any) -> Any:
    if isinstance(payload, dict) and "ok" in payload:
        if not payload.get("ok", False):
            error = payload.get("error") or {}
            raise CLIError(
                error.get("message", "lark-cli returned an error envelope"),
                code=error.get("type", "lark_error"),
                detail=payload,
            )
        return payload.get("data")
    return payload


def parse_doc_token(doc: str) -> str:
    if not doc:
        return ""
    if doc.startswith("http://") or doc.startswith("https://"):
        match = DOCX_URL_RE.search(doc)
        if match:
            return match.group(1)
        return ""
    return doc


def create_note(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.title:
        raise CLIError("missing --title for create", code="validation")
    if not args.markdown:
        raise CLIError("missing --markdown for create", code="validation")
    cmd = [
        "docs",
        "+create",
        "--as",
        args.identity,
        "--title",
        args.title,
        "--markdown",
        args.markdown,
    ]
    if args.folder_token:
        cmd.extend(["--folder-token", args.folder_token])
    if args.wiki_node:
        cmd.extend(["--wiki-node", args.wiki_node])
    if args.wiki_space:
        cmd.extend(["--wiki-space", args.wiki_space])
    data = unwrap_payload(run_cli(cli_prefix, env, cmd))
    if not isinstance(data, dict):
        raise CLIError("unexpected docs +create payload", code="invalid_create_payload")
    return data


def read_note(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.doc:
        raise CLIError("missing --doc for read", code="validation")
    cmd = [
        "docs",
        "+fetch",
        "--as",
        args.identity,
        "--doc",
        args.doc,
        "--format",
        "json",
    ]
    data = unwrap_payload(run_cli(cli_prefix, env, cmd))
    if not isinstance(data, dict):
        raise CLIError("unexpected docs +fetch payload", code="invalid_read_payload")
    return data


def update_note(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.doc:
        raise CLIError("missing --doc for update", code="validation")
    cmd = [
        "docs",
        "+update",
        "--as",
        args.identity,
        "--doc",
        args.doc,
        "--mode",
        args.mode,
    ]
    if args.markdown:
        cmd.extend(["--markdown", args.markdown])
    if args.new_title:
        cmd.extend(["--new-title", args.new_title])
    if args.selection_by_title:
        cmd.extend(["--selection-by-title", args.selection_by_title])
    if args.selection_with_ellipsis:
        cmd.extend(["--selection-with-ellipsis", args.selection_with_ellipsis])
    data = unwrap_payload(run_cli(cli_prefix, env, cmd))
    if not isinstance(data, dict):
        raise CLIError("unexpected docs +update payload", code="invalid_update_payload")
    return data


def search_note(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    cmd = [
        "docs",
        "+search",
        "--as",
        args.identity,
        "--format",
        "json",
        "--page-size",
        args.page_size,
    ]
    if args.query:
        cmd.extend(["--query", args.query])
    if args.page_token:
        cmd.extend(["--page-token", args.page_token])
    data = unwrap_payload(run_cli(cli_prefix, env, cmd))
    if not isinstance(data, dict):
        raise CLIError("unexpected docs +search payload", code="invalid_search_payload")
    return data


def delete_note(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.doc:
        raise CLIError("missing --doc for delete", code="validation")
    token = parse_doc_token(args.doc)
    if not token:
        raise CLIError(
            "delete only supports doc token or /docx/<token> URL",
            code="validation",
        )

    # Try direct delete first. Some tenants require explicit type param.
    attempts = [
        [
            "api",
            "DELETE",
            f"/open-apis/drive/v1/files/{token}",
            "--as",
            args.identity,
            "--format",
            "json",
        ],
        [
            "api",
            "DELETE",
            f"/open-apis/drive/v1/files/{token}",
            "--as",
            args.identity,
            "--params",
            '{"type":"docx"}',
            "--format",
            "json",
        ],
    ]
    last_exc: Optional[CLIError] = None
    for cmd in attempts:
        try:
            payload = run_cli(cli_prefix, env, cmd)
            return {
                "deleted_doc_token": token,
                "raw": unwrap_payload(payload),
            }
        except CLIError as exc:
            last_exc = exc
    assert last_exc is not None
    raise last_exc


def media_insert(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.doc:
        raise CLIError("missing --doc for media-insert", code="validation")
    if not args.file:
        raise CLIError("missing --file for media-insert", code="validation")

    file_cwd, cli_file = localize_cli_path(args.file)
    cmd = [
        "docs",
        "+media-insert",
        "--as",
        args.identity,
        "--doc",
        args.doc,
        "--file",
        cli_file,
        "--type",
        args.media_type,
    ]
    if args.align:
        cmd.extend(["--align", args.align])
    if args.caption:
        cmd.extend(["--caption", args.caption])

    data = unwrap_payload(run_cli(cli_prefix, env, cmd, cwd=file_cwd))
    if not isinstance(data, dict):
        raise CLIError(
            "unexpected docs +media-insert payload", code="invalid_media_insert_payload"
        )
    return data


def media_download(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.token:
        raise CLIError("missing --token for media-download", code="validation")
    if not args.output:
        raise CLIError("missing --output for media-download", code="validation")

    output_cwd, cli_output = localize_cli_path(args.output)
    cmd = [
        "docs",
        "+media-download",
        "--as",
        args.identity,
        "--token",
        args.token,
        "--type",
        args.resource_type,
        "--output",
        cli_output,
    ]
    if args.overwrite:
        cmd.append("--overwrite")

    data = unwrap_payload(run_cli(cli_prefix, env, cmd, cwd=output_cwd))
    if not isinstance(data, dict):
        raise CLIError(
            "unexpected docs +media-download payload",
            code="invalid_media_download_payload",
        )
    saved_path = data.get("saved_path")
    if isinstance(saved_path, str) and not os.path.isabs(saved_path):
        data["saved_path"] = os.path.abspath(os.path.join(output_cwd, saved_path))
    return data


def build_comment_content(args: argparse.Namespace) -> str:
    if args.comment_json:
        try:
            parsed = json.loads(args.comment_json)
        except json.JSONDecodeError as exc:
            raise CLIError(
                "--comment-json must be valid JSON", code="validation"
            ) from exc
        if not isinstance(parsed, list):
            raise CLIError(
                "--comment-json must be a reply_elements JSON array",
                code="validation",
            )
        return args.comment_json

    if args.comment:
        return json.dumps(
            [{"type": "text", "text": args.comment}],
            ensure_ascii=False,
        )

    raise CLIError(
        "missing --comment or --comment-json for comment", code="validation"
    )


def add_comment(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.doc:
        raise CLIError("missing --doc for comment", code="validation")

    cmd = [
        "drive",
        "+add-comment",
        "--as",
        args.identity,
        "--doc",
        args.doc,
        "--content",
        build_comment_content(args),
    ]
    if args.full_comment:
        cmd.append("--full-comment")
    if args.selection_with_ellipsis:
        cmd.extend(["--selection-with-ellipsis", args.selection_with_ellipsis])
    if args.block_id:
        cmd.extend(["--block-id", args.block_id])

    data = unwrap_payload(run_cli(cli_prefix, env, cmd))
    if not isinstance(data, dict):
        raise CLIError(
            "unexpected drive +add-comment payload", code="invalid_comment_payload"
        )
    return data


def normalize_section_markdown(section_title: str, markdown: str) -> str:
    stripped = markdown.lstrip()
    if stripped.startswith("#"):
        return markdown
    return f"{section_title}\n\n{markdown}"


def update_section(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if not args.doc:
        raise CLIError("missing --doc for section-update", code="validation")
    if not args.section_title:
        raise CLIError("missing --section-title for section-update", code="validation")

    mode_map = {
        "replace": "replace_range",
        "delete": "delete_range",
        "insert_before": "insert_before",
        "insert_after": "insert_after",
    }
    cli_mode = mode_map[args.section_mode]
    cmd = [
        "docs",
        "+update",
        "--as",
        args.identity,
        "--doc",
        args.doc,
        "--mode",
        cli_mode,
        "--selection-by-title",
        args.section_title,
    ]
    if args.new_title:
        cmd.extend(["--new-title", args.new_title])
    if args.section_mode != "delete":
        if not args.markdown:
            raise CLIError(
                "missing --markdown for non-delete section-update",
                code="validation",
            )
        markdown = args.markdown
        if args.section_mode == "replace":
            markdown = normalize_section_markdown(args.section_title, markdown)
        cmd.extend(["--markdown", markdown])

    data = unwrap_payload(run_cli(cli_prefix, env, cmd))
    if not isinstance(data, dict):
        raise CLIError(
            "unexpected section update payload", code="invalid_section_update_payload"
        )
    enriched = dict(data)
    enriched["section_title"] = args.section_title
    enriched["section_mode"] = args.section_mode
    return enriched


def run(
    args: argparse.Namespace, env: Dict[str, str], cli_prefix: List[str]
) -> Dict[str, Any]:
    if args.op == "create":
        return create_note(args, env, cli_prefix)
    if args.op == "read":
        return read_note(args, env, cli_prefix)
    if args.op == "update":
        return update_note(args, env, cli_prefix)
    if args.op == "search":
        return search_note(args, env, cli_prefix)
    if args.op == "delete":
        return delete_note(args, env, cli_prefix)
    if args.op == "media-insert":
        return media_insert(args, env, cli_prefix)
    if args.op == "media-download":
        return media_download(args, env, cli_prefix)
    if args.op == "comment":
        return add_comment(args, env, cli_prefix)
    if args.op == "section-update":
        return update_section(args, env, cli_prefix)
    raise CLIError(f"unsupported op: {args.op}", code="validation")


def main() -> int:
    args = parse_args()
    env = build_env(args.config_dir)
    from _resolve_lark_cli import as_prefix

    cli_prefix = as_prefix(args.lark_cli_bin, args.lark_cli_prefix)
    try:
        data = run(args, env, cli_prefix)
        out = {
            "ok": True,
            "op": args.op,
            "data": data,
            "error": None,
        }
    except CLIError as exc:
        out = {
            "ok": False,
            "op": args.op,
            "data": None,
            "error": {
                "code": exc.code,
                "message": str(exc),
                "detail": exc.detail,
            },
        }
    json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
    sys.stdout.write("\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
