#!/usr/bin/env python3
"""SNS の予約投稿（X・LinkedIn）。

予定表 sns/schedule.json に入っている投稿のうち、時刻が来たものを投稿し、
結果を sns/posted.json に残す。GitHub Actions から15分ごとに動かす。

使い方:
  python tools/sns_autopost.py check            予定表と投稿文を確かめる（投稿しない）
  python tools/sns_autopost.py run --dry-run    時刻が来ているものを表示するだけ
  python tools/sns_autopost.py run              投稿する（Actions 用）
  python tools/sns_autopost.py preview          日時と本文の一覧 sns/schedule_preview.md を作り直す

二重投稿を防ぐため、投稿の前に「投稿中」と記録して push し、push できなければ投稿しない。
「投稿中」のまま残ったもの（投稿できたか分からないもの）は、自動ではやり直さない。

外部ライブラリは使わない（標準ライブラリだけ）。
"""

import argparse
import base64
import datetime as dt
import hashlib
import hmac
import html
import json
import os
import re
import secrets
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCHEDULE = os.path.join(ROOT, "sns", "schedule.json")
POSTED = os.path.join(ROOT, "sns", "posted.json")

PLATFORMS = ("x", "linkedin")
STALE_HOURS = 12            # 予定時刻からこれ以上遅れたら投稿せず「見送り」にする
X_LIMIT = 280               # X の重み付き文字数の上限（Premium なら X_LONG_POSTS=1 で外す）
LINKEDIN_LIMIT = 3000
LINKEDIN_COMMENT_LIMIT = 1250
LINKEDIN_VERSION = os.environ.get("LINKEDIN_VERSION") or "202605"
JST = dt.timezone(dt.timedelta(hours=9))
UA = "PixelstoMotors-autopost/1.0"


# ---------- 予定表・記録 ----------

def load_json(path, default):
    if not os.path.exists(path):
        return default
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def save_json(path, data):
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")


def parse_time(s):
    t = dt.datetime.fromisoformat(s)
    if t.tzinfo is None:
        raise ValueError(f"post_at にタイムゾーンがありません（例 2026-10-25T08:00:00+09:00）: {s}")
    return t


def load_source(entry):
    path = os.path.join(ROOT, entry["source"])
    with open(path, encoding="utf-8") as f:
        return json.load(f)


LANGS = ("ja", "en")


def lang_of(entry):
    return entry.get("lang", "ja")


def texts_for(entry, src):
    """投稿文を取り出す。lang が en のときは posts.x_en / linkedin_en / linkedin_comment_en と
    meta.url_en（なければ meta.url）を使う（マンガの英語の投稿）。"""
    posts = src.get("posts", {})
    meta = src.get("meta", {})
    sfx = "_en" if lang_of(entry) == "en" else ""
    if entry["platform"] == "x":
        return {"text": posts.get("x" + sfx, "").strip()}
    return {
        "text": posts.get("linkedin" + sfx, "").strip(),
        "comment": posts.get("linkedin_comment" + sfx, "").strip(),
        "url": meta.get("url" + sfx) or meta.get("url", ""),
        "title": meta.get("title" + sfx) or meta.get("title", ""),
        "description": meta.get("meta_description" + sfx) or meta.get("meta_description", ""),
    }


# ---------- 文字数 ----------

URL_RE = re.compile(r"https?://\S+")


def x_weighted_length(text):
    """X の数え方：URL は23、ラテン文字などは1、日本語・絵文字などは2。"""
    n = 23 * len(URL_RE.findall(text))
    for ch in URL_RE.sub("", text):
        c = ord(ch)
        light = c <= 4351 or 8192 <= c <= 8205 or 8208 <= c <= 8223 or 8242 <= c <= 8247
        n += 1 if light else 2
    return n


def validate(entry, src):
    """問題があれば説明の文字列のリストを返す。"""
    errs = []
    t = texts_for(entry, src)
    if not t["text"]:
        key = {"x": "x", "linkedin": "linkedin"}[entry["platform"]] + ("_en" if lang_of(entry) == "en" else "")
        errs.append(f"投稿文が空です（posts.{key}）")
    if entry["platform"] == "x":
        n = x_weighted_length(t["text"])
        if n > X_LIMIT and os.environ.get("X_LONG_POSTS") != "1":
            errs.append(f"X の文字数が上限を超えています（{n} / {X_LIMIT}）")
    else:
        if len(t["text"]) > LINKEDIN_LIMIT:
            errs.append(f"LinkedIn の文字数が上限を超えています（{len(t['text'])} / {LINKEDIN_LIMIT}）")
        if t["comment"] and len(t["comment"]) > LINKEDIN_COMMENT_LIMIT:
            errs.append(f"LinkedIn のコメントが長すぎます（{len(t['comment'])} / {LINKEDIN_COMMENT_LIMIT}）")
        if not t["url"]:
            errs.append("meta.url（記事の URL）がありません")
    return errs


def check_schedule(schedule):
    errs = []
    ids = set()
    for i, e in enumerate(schedule.get("posts", [])):
        where = f"posts[{i}] {e.get('id', '(id なし)')}"
        for k in ("id", "source", "platform", "post_at"):
            if k not in e:
                errs.append(f"{where}: {k} がありません")
        if any(k not in e for k in ("id", "source", "platform", "post_at")):
            continue
        if e["id"] in ids:
            errs.append(f"{where}: id が重複しています")
        ids.add(e["id"])
        try:
            parse_time(e["post_at"])
        except ValueError as ex:
            errs.append(f"{where}: {ex}")
        if lang_of(e) not in LANGS:
            errs.append(f"{where}: lang は ja か en です")
            continue
        if e["platform"] not in PLATFORMS:
            errs.append(f"{where}: platform は x か linkedin です")
            continue
        try:
            src = load_source(e)
        except (OSError, json.JSONDecodeError) as ex:
            errs.append(f"{where}: 投稿文のファイルが読めません（{ex}）")
            continue
        errs += [f"{where}: {m}" for m in validate(e, src)]
    return errs


# ---------- HTTP ----------

class ApiError(Exception):
    def __init__(self, status, body, where):
        super().__init__(f"{where}: HTTP {status} {body[:500]}")
        self.status = status


def http(method, url, headers=None, body=None, where=""):
    data = body
    if isinstance(body, (dict, list)):
        data = json.dumps(body, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(url, data=data, method=method, headers={"User-Agent": UA, **(headers or {})})
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            raw = r.read()
            return r.status, dict(r.headers), raw
    except urllib.error.HTTPError as ex:
        raise ApiError(ex.code, ex.read().decode("utf-8", "replace"), where) from None


# ---------- X ----------

def oauth1_header(method, url, ck, cs, tk, ts):
    q = lambda s: urllib.parse.quote(str(s), safe="~")
    params = {
        "oauth_consumer_key": ck,
        "oauth_nonce": secrets.token_hex(16),
        "oauth_signature_method": "HMAC-SHA1",
        "oauth_timestamp": str(int(time.time())),
        "oauth_token": tk,
        "oauth_version": "1.0",
    }
    base_params = "&".join(f"{q(k)}={q(v)}" for k, v in sorted(params.items()))
    base = "&".join([method.upper(), q(url), q(base_params)])
    key = f"{q(cs)}&{q(ts)}".encode()
    params["oauth_signature"] = base64.b64encode(hmac.new(key, base.encode(), hashlib.sha1).digest()).decode()
    return "OAuth " + ", ".join(f'{q(k)}="{q(v)}"' for k, v in sorted(params.items()))


def post_x(t):
    env = {k: os.environ.get(k, "") for k in ("X_API_KEY", "X_API_SECRET", "X_ACCESS_TOKEN", "X_ACCESS_SECRET")}
    missing = [k for k, v in env.items() if not v]
    if missing:
        raise RuntimeError(f"X の鍵が設定されていません: {', '.join(missing)}")
    url = os.environ.get("X_API_BASE", "https://api.x.com") + "/2/tweets"
    auth = oauth1_header("POST", url, env["X_API_KEY"], env["X_API_SECRET"], env["X_ACCESS_TOKEN"], env["X_ACCESS_SECRET"])
    _, _, raw = http("POST", url, {"Authorization": auth, "Content-Type": "application/json"}, {"text": t["text"]}, "X 投稿")
    pid = json.loads(raw)["data"]["id"]
    return {"post_id": pid, "url": f"https://x.com/i/web/status/{pid}"}


# ---------- LinkedIn ----------

def li_headers(token, versioned=True):
    h = {"Authorization": f"Bearer {token}", "X-Restli-Protocol-Version": "2.0.0", "Content-Type": "application/json"}
    if versioned:
        h["LinkedIn-Version"] = LINKEDIN_VERSION
    return h


def li_base():
    return os.environ.get("LINKEDIN_API_BASE", "https://api.linkedin.com")


def li_person(token):
    urn = os.environ.get("LINKEDIN_PERSON_URN", "")
    if urn:
        return urn
    _, _, raw = http("GET", li_base() + "/v2/userinfo", {"Authorization": f"Bearer {token}"}, where="LinkedIn 本人確認")
    return f"urn:li:person:{json.loads(raw)['sub']}"


def og_image(page_url):
    """記事ページの og:image の URL を取る。取れなければ None。"""
    try:
        _, _, raw = http("GET", page_url, where="記事ページ")
    except Exception:
        return None
    page = raw.decode("utf-8", "replace")
    for pat in (r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)',
                r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']'):
        m = re.search(pat, page)
        if m:
            return urllib.parse.urljoin(page_url, html.unescape(m.group(1)))
    return None


def li_thumbnail(token, person, page_url):
    """記事の表紙画像を LinkedIn に上げて image の URN を返す。失敗したら None（表紙なしで投稿する）。"""
    try:
        img = og_image(page_url)
        if not img:
            return None
        _, _, data = http("GET", img, where="表紙画像")
        _, _, raw = http("POST", li_base() + "/rest/images?action=initializeUpload", li_headers(token),
                         {"initializeUploadRequest": {"owner": person}}, "LinkedIn 画像の準備")
        v = json.loads(raw)["value"]
        http("PUT", v["uploadUrl"], {"Authorization": f"Bearer {token}"}, data, "LinkedIn 画像の送信")
        return v["image"]
    except Exception as ex:
        print(f"  表紙画像は付けずに投稿します（{ex}）")
        return None


def post_linkedin(t):
    token = os.environ.get("LINKEDIN_ACCESS_TOKEN", "")
    if not token:
        raise RuntimeError("LinkedIn のトークンが設定されていません: LINKEDIN_ACCESS_TOKEN")
    person = li_person(token)
    article = {"source": t["url"], "title": t["title"] or t["url"]}
    if t["description"]:
        article["description"] = t["description"]
    thumb = li_thumbnail(token, person, t["url"])
    if thumb:
        article["thumbnail"] = thumb
    body = {
        "author": person,
        "commentary": li_escape(t["text"]),
        "visibility": "PUBLIC",
        "distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []},
        "content": {"article": article},
        "lifecycleState": "PUBLISHED",
        "isReshareDisabledByAuthor": False,
    }
    _, headers, _ = http("POST", li_base() + "/rest/posts", li_headers(token), body, "LinkedIn 投稿")
    urn = {k.lower(): v for k, v in headers.items()}.get("x-restli-id", "")
    result = {"post_id": urn, "url": f"https://www.linkedin.com/feed/update/{urn}/" if urn else ""}
    if t.get("comment") and urn:
        try:
            enc = urllib.parse.quote(urn, safe="")
            _, h2, _ = http("POST", f"{li_base()}/rest/socialActions/{enc}/comments", li_headers(token),
                            {"actor": person, "object": urn, "message": {"text": t["comment"]}}, "LinkedIn コメント")
            result["comment_id"] = {k.lower(): v for k, v in h2.items()}.get("x-restli-id", "posted")
        except Exception as ex:
            # 本文は出ているので、コメントだけ失敗として残す（本文はやり直さない）
            result["comment_error"] = str(ex)[:500]
    return result


def li_escape(text):
    """LinkedIn の commentary は little text 形式。記号はエスケープが要る（しないと本文が途中で切れる）。
    ハッシュタグ（#語）はハッシュタグとして残す。"""
    s = re.sub(r"([\\|{}@\[\]()<>#*_~])", r"\\\1", text)
    return re.sub(r"\\#(\w+)", r"{hashtag|\\#|\1}", s)


# ---------- 一覧（sns/schedule_preview.md） ----------

PREVIEW = os.path.join(ROOT, "sns", "schedule_preview.md")
WEEKDAYS = "月火水木金土日"
STATUS_JA = {"posted": "投稿済み", "failed": "失敗", "unknown": "要確認（出たか不明）",
             "posting": "要確認（投稿中で止まった）", "skipped_stale": "見送り"}


def md_quote(text):
    """本文を引用の形にする。改行はそのまま残し、行頭の # が見出しにならないようにする。"""
    lines = []
    for line in text.split("\n"):
        line = re.sub(r"^(\s*)([#>\-*+]|\d+\.)", r"\1\\\2", line)
        lines.append("> " + line + "  " if line else ">")
    return "\n".join(lines)


def write_preview(schedule=None, posted=None):
    schedule = schedule if schedule is not None else load_json(SCHEDULE, {"posts": []})
    posted = posted if posted is not None else load_json(POSTED, {})
    entries = sorted(schedule.get("posts", []), key=lambda e: parse_time(e["post_at"]))
    upcoming = [e for e in entries if e["id"] not in posted]
    done = [e for e in reversed(entries) if e["id"] in posted]

    out = ["# SNS 予約一覧", "",
           "予定表（`sns/schedule.json`）から自動で作る一覧。**このファイルは手で直さない**（次の更新で上書きされる）。",
           "中身を変えたいときは、`sns/LOG{番号}_sns_posts.json` か予定表を直す。", "",
           f"## これから出す（{len(upcoming)}本）", ""]
    if not upcoming:
        out += ["予約はありません。", ""]
    for e in upcoming:
        out += preview_block(e, "予約中")
    out += [f"## 出したもの・止まったもの（{len(done)}本）", ""]
    if not done:
        out += ["まだありません。", ""]
    for e in done:
        p = posted[e["id"]]
        status = STATUS_JA.get(p.get("status"), p.get("status", ""))
        out += preview_block(e, status, p)
    with open(PREVIEW, "w", encoding="utf-8") as f:
        f.write("\n".join(out).rstrip() + "\n")


def preview_block(e, status, result=None):
    t = parse_time(e["post_at"]).astimezone(JST)
    when = f"{t.month}/{t.day}（{WEEKDAYS[t.weekday()]}）{t.hour}:{t.minute:02d}"
    name = {"x": "X", "linkedin": "LinkedIn"}.get(e["platform"], e["platform"])
    name += "（英語）" if lang_of(e) == "en" else "（日本語）"
    lines = [f"### {when} ・ {name} ・ {e['id']} ・ {status}", ""]
    try:
        src = load_source(e)
        tx = texts_for(e, src)
        meta = src.get("meta", {})
        sfx = "_en" if lang_of(e) == "en" else ""
        info = f"リンク先：[{meta.get('title' + sfx) or meta.get('title', '')}]({meta.get('url' + sfx) or meta.get('url', '')})"
        if e["platform"] == "x":
            info += f" ／ 文字数 {x_weighted_length(tx['text'])} / {X_LIMIT}"
        lines += [info, "", md_quote(tx["text"]), ""]
    except (OSError, json.JSONDecodeError, KeyError) as ex:
        lines += [f"（投稿文が読めません：{ex}）", ""]
    if result:
        if result.get("url"):
            lines += [f"投稿：{result['url']}", ""]
        if result.get("error"):
            lines += [f"エラー：`{result['error'][:300]}`", ""]
    return lines


# ---------- git ----------

def git(*args):
    return subprocess.run(["git", "-C", ROOT, *args], capture_output=True, text=True)


def commit_and_push(message):
    """posted.json と一覧を commit して push。成功したら True。"""
    if os.environ.get("AUTOPOST_GIT") != "1":
        return True
    try:
        write_preview()
        git("add", "sns/schedule_preview.md")
    except Exception as ex:
        print(f"一覧の更新に失敗しました（投稿には影響しません）: {ex}")
    git("add", "sns/posted.json")
    r = git("commit", "-m", message)
    if r.returncode != 0 and "nothing to commit" not in (r.stdout + r.stderr):
        print(r.stdout, r.stderr)
        return False
    for _ in range(3):
        if git("push", "origin", "HEAD").returncode == 0:
            return True
        # 途中で別の push があったら取り込んでやり直す
        if git("pull", "--rebase", "origin", "HEAD").returncode != 0:
            git("rebase", "--abort")
            return False
    return False


# ---------- 実行 ----------

def now_utc():
    s = os.environ.get("AUTOPOST_NOW")
    return parse_time(s) if s else dt.datetime.now(dt.timezone.utc)


def run(dry_run):
    schedule = load_json(SCHEDULE, {"posts": []})
    errs = check_schedule(schedule)
    if errs:
        print("予定表に問題があります。直すまで投稿しません。")
        for e in errs:
            print("  - " + e)
        return 1

    posted = load_json(POSTED, {})
    now = now_utc()
    failed = False
    due = sorted((e for e in schedule["posts"] if e["id"] not in posted and parse_time(e["post_at"]) <= now),
                 key=lambda e: parse_time(e["post_at"]))
    if not due:
        print("時刻が来ている投稿はありません。")
        return 0

    for e in due:
        late = now - parse_time(e["post_at"])
        stamp = now.astimezone(JST).isoformat(timespec="seconds")
        if late > dt.timedelta(hours=STALE_HOURS):
            print(f"[{e['id']}] 予定から {late} 遅れているので見送ります")
            if not dry_run:
                posted[e["id"]] = {"status": "skipped_stale", "at": stamp, "post_at": e["post_at"]}
                save_json(POSTED, posted)
                commit_and_push(f"SNS自動投稿：{e['id']} を見送り（予定から{STALE_HOURS}時間以上遅れ）")
            continue

        t = texts_for(e, load_source(e))
        if dry_run:
            print(f"[{e['id']}] {e['platform']} に投稿します（試し・送らない）\n----\n{t['text']}\n----")
            continue

        # 1) 先に「投稿中」を push。push できなければ投稿しない（二重投稿を防ぐ）
        posted[e["id"]] = {"status": "posting", "at": stamp, "platform": e["platform"]}
        save_json(POSTED, posted)
        if not commit_and_push(f"SNS自動投稿：{e['id']} を投稿中"):
            print(f"[{e['id']}] 記録を push できなかったので投稿しません")
            return 1

        # 2) 投稿
        try:
            result = post_x(t) if e["platform"] == "x" else post_linkedin(t)
            posted[e["id"]] = {"status": "posted", "at": stamp, "platform": e["platform"], **result}
            print(f"[{e['id']}] 投稿しました {result.get('url', '')}")
            if result.get("comment_error"):
                print(f"[{e['id']}] コメントは失敗しました: {result['comment_error']}")
                failed = True
        except Exception as ex:
            msg = str(ex)
            if isinstance(ex, ApiError) and ex.status == 401:
                msg = "認証が切れているか鍵が違います。" + (
                    "LinkedIn のトークン（60日で切れる）を作り直してください。 " if e["platform"] == "linkedin" else "") + msg
            # 相手から断られた（HTTP のエラーが返った・鍵がない）なら出ていない＝failed。
            # 通信が途中で切れたなどで分からないときは unknown（手で確かめる）
            status = "failed" if isinstance(ex, (ApiError, RuntimeError)) else "unknown"
            posted[e["id"]] = {"status": status, "at": stamp, "platform": e["platform"], "error": msg[:800]}
            print(f"[{e['id']}] 投稿に失敗しました（{status}）: {msg}")
            failed = True

        # 3) 結果を push
        save_json(POSTED, posted)
        commit_and_push(f"SNS自動投稿：{e['id']} の結果（{posted[e['id']]['status']}）")
    return 1 if failed else 0


def verify():
    """鍵が使えるかを、投稿せずに確かめる（X は自分の名前を読む・LinkedIn は本人確認）。"""
    ok = True
    try:
        env = {k: os.environ.get(k, "") for k in ("X_API_KEY", "X_API_SECRET", "X_ACCESS_TOKEN", "X_ACCESS_SECRET")}
        if not all(env.values()):
            raise RuntimeError("X の鍵が足りません")
        url = os.environ.get("X_API_BASE", "https://api.x.com") + "/2/users/me"
        auth = oauth1_header("GET", url, env["X_API_KEY"], env["X_API_SECRET"], env["X_ACCESS_TOKEN"], env["X_ACCESS_SECRET"])
        _, _, raw = http("GET", url, {"Authorization": auth}, where="X 本人確認")
        print(f"X: OK（@{json.loads(raw)['data']['username']}）")
    except Exception as ex:
        ok = False
        print(f"X: NG {ex}")
    try:
        token = os.environ.get("LINKEDIN_ACCESS_TOKEN", "")
        if not token:
            raise RuntimeError("LinkedIn のトークンがありません")
        _, _, raw = http("GET", li_base() + "/v2/userinfo", {"Authorization": f"Bearer {token}"}, where="LinkedIn 本人確認")
        print(f"LinkedIn: OK（{json.loads(raw).get('name', '')}）")
    except Exception as ex:
        ok = False
        print(f"LinkedIn: NG {ex}")
    return 0 if ok else 1


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("command", choices=["check", "run", "preview", "verify"])
    ap.add_argument("--dry-run", action="store_true")
    a = ap.parse_args()
    if a.command == "verify":
        return verify()
    if a.command == "preview":
        write_preview()
        print(f"一覧を書きました: {os.path.relpath(PREVIEW, ROOT)}")
        return 0
    if a.command == "check":
        errs = check_schedule(load_json(SCHEDULE, {"posts": []}))
        for e in errs:
            print("  - " + e)
        print("問題ありません。" if not errs else f"{len(errs)} 件の問題があります。")
        return 1 if errs else 0
    return run(a.dry_run)


if __name__ == "__main__":
    sys.exit(main())
