from __future__ import annotations

import argparse
import asyncio
import base64
import hashlib
import hmac
import html
import io
import json
import os
import re
import subprocess
import tempfile
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

try:
    import fcntl
except ImportError:  # pragma: no cover - production runs on Linux
    fcntl = None

import aiohttp
from aiogram import Bot
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from PIL import Image, ImageOps

from vk_parser_app.config import settings
from vk_parser_app.db import close_pool, fetch_setting, get_pool


SEPARATOR_RE = re.compile(r"^━+$")
HASHTAG_LINE_RE = re.compile(r"^(?:#[\wа-яё]+\s*)+$", re.IGNORECASE)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Publish selected accepted posts to Ghost")
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--ids", help="Comma-separated raw_post ids")
    group.add_argument("--all-ready", action="store_true", help="Publish every photo-ready post not yet published to site")
    group.add_argument("--all-ready-videos", action="store_true", help="Publish video-only ready posts")
    parser.add_argument("--ghost-url", default="https://f-n8.ru")
    parser.add_argument("--key-file", default="/opt/vk-parser/.site-poster-key")
    parser.add_argument("--limit", type=int, default=0, help="Maximum posts to publish in this run; 0 means unlimited")
    parser.add_argument("--retry-failed", action="store_true", help="Include previous site publish_failed rows")
    return parser.parse_args()


def ghost_token(key: str) -> str:
    key_id, secret = key.strip().split(":", 1)
    now = int(time.time())

    def encode(value: dict[str, Any] | bytes) -> str:
        raw = value if isinstance(value, bytes) else json.dumps(value, separators=(",", ":")).encode()
        return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

    header = encode({"alg": "HS256", "typ": "JWT", "kid": key_id})
    payload = encode({"iat": now, "exp": now + 300, "aud": "/admin/"})
    signature = encode(hmac.new(bytes.fromhex(secret), f"{header}.{payload}".encode(), hashlib.sha256).digest())
    return f"{header}.{payload}.{signature}"


def split_post_text(value: str) -> tuple[str, str]:
    lines = [line.strip() for line in str(value or "").replace("\r", "").split("\n")]
    while lines and not lines[0]:
        lines.pop(0)
    title = lines.pop(0) if lines else "Новость Forma N8"
    while lines and (not lines[0] or SEPARATOR_RE.fullmatch(lines[0])):
        lines.pop(0)
    while lines and (not lines[-1] or HASHTAG_LINE_RE.fullmatch(lines[-1])):
        lines.pop()
    body = "\n".join(lines).strip()
    return title[:255], body


def paragraphs(value: str) -> str:
    chunks = [chunk.strip() for chunk in re.split(r"\n\s*\n", value) if chunk.strip()]
    return "".join(f"<p>{html.escape(chunk).replace(chr(10), '<br>')}</p>" for chunk in chunks)


def excerpt(value: str, limit: int = 260) -> str:
    clean = re.sub(r"\s+", " ", value).strip()
    if len(clean) <= limit:
        return clean
    return clean[: limit - 1].rsplit(" ", 1)[0] + "…"


def compress_photo(data: bytes) -> tuple[bytes, int, int]:
    with Image.open(io.BytesIO(data)) as source:
        image = ImageOps.exif_transpose(source)
        if image.mode not in {"RGB", "RGBA"}:
            image = image.convert("RGB")
        image.thumbnail((2000, 2000), Image.Resampling.LANCZOS)
        output = io.BytesIO()
        image.save(output, "WEBP", quality=82, method=6)
        return output.getvalue(), image.width, image.height


def video_thumbnail(data: bytes) -> tuple[bytes, int, int]:
    video_path = ""
    image_path = ""
    try:
        with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as video_file:
            video_file.write(data)
            video_path = video_file.name
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as image_file:
            image_path = image_file.name
        command = [
            "ffmpeg", "-y", "-ss", "1", "-i", video_path,
            "-frames:v", "1", "-vf", "scale='min(2000,iw)':-2", "-q:v", "3", image_path,
        ]
        result = subprocess.run(command, capture_output=True, timeout=90)
        if result.returncode != 0 or not Path(image_path).exists() or Path(image_path).stat().st_size == 0:
            command[3] = "0"
            result = subprocess.run(command, capture_output=True, timeout=90)
        if result.returncode != 0:
            raise RuntimeError("ffmpeg thumbnail extraction failed")
        return compress_photo(Path(image_path).read_bytes())
    finally:
        for path in (video_path, image_path):
            if path:
                try:
                    os.unlink(path)
                except FileNotFoundError:
                    pass


def normalize_video(data: bytes, duration: int, height: int | None) -> bytes:
    max_bytes = 40 * 1024 * 1024
    if len(data) <= max_bytes and (not height or height <= 720):
        return data
    input_path = ""
    output_path = ""
    try:
        with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as input_file:
            input_file.write(data)
            input_path = input_file.name
        with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as output_file:
            output_path = output_file.name
        safe_duration = max(1, int(duration or 1))
        target_total_kbps = int((38 * 1024 * 8) / safe_duration)
        video_kbps = max(280, min(2800, target_total_kbps - 96))
        command = [
            "ffmpeg", "-y", "-i", input_path,
            "-map_metadata", "-1", "-c:v", "libx264", "-preset", "fast",
            "-b:v", f"{video_kbps}k", "-maxrate", f"{video_kbps}k", "-bufsize", f"{video_kbps * 2}k",
        ]
        if height and int(height) > 720:
            command.extend(["-vf", "scale=-2:720"])
        command.extend(["-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", output_path])
        result = subprocess.run(command, capture_output=True, timeout=max(180, safe_duration * 3))
        if result.returncode != 0 or not Path(output_path).exists():
            raise RuntimeError("ffmpeg video normalization failed")
        normalized = Path(output_path).read_bytes()
        if len(normalized) > 48 * 1024 * 1024:
            raise RuntimeError(f"normalized video remains too large: {len(normalized)} bytes")
        return normalized
    finally:
        for path in (input_path, output_path):
            if path:
                try:
                    os.unlink(path)
                except FileNotFoundError:
                    pass


def gallery_html(images: list[dict[str, Any]], title: str) -> str:
    if not images:
        return ""
    if len(images) == 1:
        image = images[0]
        return (
            '<figure class="kg-card kg-image-card kg-width-wide">'
            f'<img src="{html.escape(image["url"], quote=True)}" '
            f'width="{image["width"]}" height="{image["height"]}" '
            f'alt="{html.escape(title, quote=True)}" loading="lazy">'
            "</figure>"
        )
    rows = []
    for offset in range(0, len(images), 3):
        cells = []
        for image in images[offset : offset + 3]:
            cells.append(
                '<div class="kg-gallery-image">'
                f'<img src="{html.escape(image["url"], quote=True)}" '
                f'width="{image["width"]}" height="{image["height"]}" '
                f'alt="{html.escape(title, quote=True)}" loading="lazy">'
                "</div>"
            )
        rows.append('<div class="kg-gallery-row">' + "".join(cells) + "</div>")
    return '<figure class="kg-card kg-gallery-card kg-width-wide"><div class="kg-gallery-container">' + "".join(rows) + "</div></figure>"


def videos_html(videos: list[dict[str, Any]], title: str, poster_url: str) -> str:
    return "".join(
        '<figure class="kg-card kg-video-card kg-width-wide">'
        f'<video src="{html.escape(video["url"], quote=True)}" '
        f'poster="{html.escape(poster_url, quote=True)}" controls playsinline preload="metadata" '
        f'aria-label="{html.escape(title, quote=True)}"></video>'
        "</figure>"
        for video in videos
    )


def lexical_paragraph(text: str) -> dict[str, Any]:
    return {
        "children": [{"detail": 0, "format": 0, "mode": "normal", "style": "", "text": text, "type": "extended-text", "version": 1}],
        "direction": None,
        "format": "",
        "indent": 0,
        "type": "paragraph",
        "version": 1,
    }


def video_lexical(videos: list[dict[str, Any]], body: str, thumbnail: dict[str, Any]) -> str:
    children: list[dict[str, Any]] = []
    for video in videos:
        children.append(
            {
                "type": "video",
                "version": 1,
                "src": video["url"],
                "caption": "",
                "fileName": video["file_name"],
                "mimeType": "video/mp4",
                "width": video.get("width"),
                "height": video.get("height"),
                "duration": video.get("duration") or 0,
                "thumbnailSrc": thumbnail["url"],
                "customThumbnailSrc": "",
                "thumbnailWidth": thumbnail["width"],
                "thumbnailHeight": thumbnail["height"],
                "cardWidth": "wide",
                "loop": False,
            }
        )
    children.extend(lexical_paragraph(chunk.strip()) for chunk in re.split(r"\n\s*\n", body) if chunk.strip())
    return json.dumps(
        {"root": {"children": children, "direction": None, "format": "", "indent": 0, "type": "root", "version": 1}},
        ensure_ascii=False,
        separators=(",", ":"),
    )


class GhostClient:
    def __init__(self, session: aiohttp.ClientSession, base_url: str, key: str) -> None:
        self.session = session
        self.base_url = base_url.rstrip("/")
        self.key = key

    def headers(self) -> dict[str, str]:
        return {"Authorization": f"Ghost {ghost_token(self.key)}", "Accept-Version": "v6.0"}

    async def upload_image(self, data: bytes, name: str, ref: str) -> str:
        form = aiohttp.FormData()
        form.add_field("file", data, filename=name, content_type="image/webp")
        form.add_field("purpose", "image")
        form.add_field("ref", ref)
        async with self.session.post(
            f"{self.base_url}/ghost/api/admin/images/upload/", data=form, headers=self.headers()
        ) as response:
            payload = await response.json(content_type=None)
            if response.status >= 300:
                raise RuntimeError(f"Ghost image upload {response.status}: {payload}")
            return str(payload["images"][0]["url"])

    async def upload_media(self, data: bytes, name: str, content_type: str) -> str:
        form = aiohttp.FormData()
        form.add_field("file", data, filename=name, content_type=content_type)
        async with self.session.post(
            f"{self.base_url}/ghost/api/admin/media/upload/", data=form, headers=self.headers()
        ) as response:
            payload = await response.json(content_type=None)
            if response.status >= 300:
                raise RuntimeError(f"Ghost media upload {response.status}: {payload}")
            return str(payload["media"][0]["url"])

    async def create_post(self, post: dict[str, Any]) -> dict[str, Any]:
        suffix = "/" if "lexical" in post else "/?source=html"
        async with self.session.post(
            f"{self.base_url}/ghost/api/admin/posts{suffix}",
            json={"posts": [post]},
            headers=self.headers(),
        ) as response:
            payload = await response.json(content_type=None)
            if response.status >= 300:
                raise RuntimeError(f"Ghost post create {response.status}: {payload}")
            return dict(payload["posts"][0])


async def main() -> None:
    args = parse_args()
    key = os.environ.get("SITE_POSTER_GHOST_ADMIN_KEY", "").strip()
    if not key:
        key = Path(args.key_file).read_text(encoding="utf-8").strip()
    pool = await get_pool()
    if args.all_ready or args.all_ready_videos:
        media_condition = (
            """EXISTS (
                    SELECT 1 FROM raw_post_media rpm
                    WHERE rpm.raw_post_id=rp.id AND rpm.media_type='video'
                      AND COALESCE(rpm.editor_hidden,FALSE)=FALSE
                      AND COALESCE(rpm.tg_file_id,rpm.storage_attachment_id,'')<>''
                  ) AND NOT EXISTS (
                    SELECT 1 FROM raw_post_media rpm
                    WHERE rpm.raw_post_id=rp.id AND rpm.media_type='photo'
                      AND COALESCE(rpm.editor_hidden,FALSE)=FALSE
                      AND COALESCE(rpm.tg_file_id,rpm.storage_attachment_id,'')<>''
                  )"""
            if args.all_ready_videos
            else """EXISTS (
                    SELECT 1 FROM raw_post_media rpm
                    WHERE rpm.raw_post_id=rp.id AND rpm.media_type='photo'
                      AND COALESCE(rpm.editor_hidden,FALSE)=FALSE
                      AND COALESCE(rpm.tg_file_id,rpm.storage_attachment_id,'')<>''
                  )"""
        )
        candidates = [
            dict(row)
            for row in await pool.fetch(
                f"""
                SELECT rp.id,
                       COALESCE(rp.final_category_tag,rp.rewrite_category_tag,'') AS category,
                       COALESCE(rp.final_source_tag,rp.rewrite_source_tag,s.tag,'') AS source
                FROM raw_posts rp
                JOIN sources s ON s.id=rp.source_id
                JOIN content_categories cc ON cc.sort_order=COALESCE(rp.final_category_id,rp.rewrite_category_id)
                LEFT JOIN post_publications pp ON pp.raw_post_id=rp.id AND pp.platform='site'
                WHERE rp.status='storage_ready'
                  AND rp.rewrite_status='ready'
                  AND COALESCE(rp.editorial_status,'review') IN ('accepted','published','publish_failed')
                  AND cc.site_enabled=TRUE
                  AND COALESCE(pp.status,'pending') {"<> 'published'" if args.retry_failed else "= 'pending'"}
                  AND {media_condition}
                ORDER BY COALESCE(rp.reviewed_at,rp.edited_at,rp.rewritten_at,rp.created_at),rp.id
                """
            )
        ]
        recent = [
            dict(row)
            for row in await pool.fetch(
                """
                SELECT COALESCE(rp.final_category_tag,rp.rewrite_category_tag,'') AS category,
                       COALESCE(rp.final_source_tag,rp.rewrite_source_tag,s.tag,'') AS source
                FROM post_publications pp
                JOIN raw_posts rp ON rp.id=pp.raw_post_id
                JOIN sources s ON s.id=rp.source_id
                WHERE pp.platform='site' AND pp.status='published'
                ORDER BY pp.published_at DESC NULLS LAST,pp.id DESC LIMIT 20
                """
            )
        ]
        selected: list[dict[str, Any]] = []
        while candidates:
            previous = selected[-1] if selected else (recent[0] if recent else {})
            recent_window = recent + selected[-20:]
            best = min(
                range(len(candidates)),
                key=lambda index: (
                    int(bool(candidates[index]["category"]) and candidates[index]["category"] == previous.get("category"))
                    + int(bool(candidates[index]["source"]) and candidates[index]["source"] == previous.get("source")),
                    sum(3 for row in recent_window if row.get("category") == candidates[index]["category"])
                    + sum(4 for row in recent_window if row.get("source") == candidates[index]["source"]),
                    index,
                ),
            )
            selected.append(candidates.pop(best))
        ids = [int(row["id"]) for row in selected]
        if args.limit > 0:
            ids = ids[: args.limit]
    else:
        ids = [int(item) for item in str(args.ids or "").split(",") if item.strip()]
    if not ids:
        print("NO_READY_POSTS")
        await close_pool()
        return
    print(f"QUEUE {len(ids)}")
    rows = await pool.fetch(
        """
        SELECT rp.*, s.name AS source_name, s.tag AS source_tag,
               cc.site_name, cc.site_slug, cc.site_enabled,
               pp.status AS site_publication_status
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        JOIN content_categories cc ON cc.sort_order=COALESCE(rp.final_category_id, rp.rewrite_category_id)
        LEFT JOIN post_publications pp ON pp.raw_post_id=rp.id AND pp.platform='site'
        WHERE rp.id=ANY($1::bigint[])
        """,
        ids,
    )
    by_id = {int(row["id"]): dict(row) for row in rows}
    local_api = str(await fetch_setting("local_bot_api_url", settings.local_bot_api_url) or "").strip()
    bot_session = AiohttpSession(api=TelegramAPIServer.from_base(local_api.rstrip("/"), is_local=True)) if local_api else AiohttpSession()
    bot = Bot(settings.tg_bot_token, session=bot_session)
    timeout = aiohttp.ClientTimeout(total=120)
    published_base = datetime.now(timezone.utc) - timedelta(minutes=len(ids))
    try:
        async with aiohttp.ClientSession(timeout=timeout) as http_session:
            ghost = GhostClient(http_session, args.ghost_url, key)
            for position, raw_post_id in enumerate(ids):
                post = by_id.get(raw_post_id)
                if not post:
                    print(f"SKIP {raw_post_id}: missing or unmapped category")
                    continue
                if not post.get("site_enabled"):
                    print(f"SKIP {raw_post_id}: category disabled for site")
                    continue
                if post.get("site_publication_status") == "published":
                    print(f"SKIP {raw_post_id}: already published")
                    continue
                try:
                    media_rows = await pool.fetch(
                        """
                        SELECT * FROM raw_post_media
                        WHERE raw_post_id=$1 AND media_type IN ('photo','video')
                          AND COALESCE(editor_hidden,FALSE)=FALSE
                          AND COALESCE(tg_file_id,storage_attachment_id,'')<>''
                        ORDER BY sort_order,id
                        """,
                        raw_post_id,
                    )
                    uploaded_photos = []
                    uploaded_videos = []
                    thumbnail = None
                    for index, media in enumerate(media_rows):
                        file_id = str(media["tg_file_id"] or media["storage_attachment_id"])
                        telegram_file = await bot.get_file(file_id)
                        buffer = io.BytesIO()
                        await bot.download_file(telegram_file.file_path, destination=buffer)
                        data = buffer.getvalue()
                        if media["media_type"] == "photo":
                            compressed, width, height = compress_photo(data)
                            url = await ghost.upload_image(compressed, f"post-{raw_post_id}-{index + 1}.webp", f"raw-post-{raw_post_id}-{index + 1}")
                            uploaded_photos.append({"type": "photo", "url": url, "width": width, "height": height, "bytes": len(compressed)})
                        else:
                            data = normalize_video(data, int(media.get("duration_sec") or 0), media.get("height"))
                            url = await ghost.upload_media(data, f"post-{raw_post_id}-{index + 1}.mp4", "video/mp4")
                            uploaded_videos.append({
                                "type": "video", "url": url, "bytes": len(data),
                                "file_name": f"post-{raw_post_id}-{index + 1}.mp4",
                                "width": media.get("width"), "height": media.get("height"),
                                "duration": media.get("duration_sec") or 0,
                            })
                            if thumbnail is None:
                                compressed, width, height = video_thumbnail(data)
                                thumb_url = await ghost.upload_image(compressed, f"post-{raw_post_id}-video-cover.webp", f"raw-post-{raw_post_id}-video-cover")
                                thumbnail = {"type": "thumbnail", "url": thumb_url, "width": width, "height": height, "bytes": len(compressed)}
                    feature = uploaded_photos[0] if uploaded_photos else thumbnail
                    if not feature:
                        raise RuntimeError("no downloadable site media")
                    title, body = split_post_text(post.get("final_text") or post.get("rewritten_text") or "")
                    producer = str(post.get("source_name") or "").strip()
                    content = gallery_html(uploaded_photos[1:], title) + videos_html(uploaded_videos, title, feature["url"]) + paragraphs(body)
                    attachments = uploaded_photos + uploaded_videos + ([thumbnail] if thumbnail else [])
                    payload = {
                        "title": title,
                        "status": "published",
                        "published_at": (published_base + timedelta(minutes=position)).isoformat(),
                        "custom_excerpt": excerpt(body),
                        "meta_description": excerpt(body),
                        "feature_image": feature["url"],
                        "feature_image_alt": title,
                        "tags": [
                            {"name": str(post["site_name"]), "slug": str(post["site_slug"])},
                            {"name": f"#producer:{producer}"},
                            {"name": f"#raw-post:{raw_post_id}"},
                        ],
                    }
                    if uploaded_videos and not uploaded_photos:
                        payload["lexical"] = video_lexical(uploaded_videos, body, thumbnail)
                    else:
                        payload["html"] = content
                    created = await ghost.create_post(payload)
                    await pool.execute(
                        """
                        INSERT INTO post_publications(raw_post_id,platform,status,target_id,external_id,url,error,attachments_json,published_at)
                        VALUES($1,'site','published',$2,$3,$4,NULL,$5::jsonb,NOW())
                        ON CONFLICT(raw_post_id,platform) DO UPDATE SET
                          status='published',target_id=EXCLUDED.target_id,external_id=EXCLUDED.external_id,
                          url=EXCLUDED.url,error=NULL,attachments_json=EXCLUDED.attachments_json,
                          published_at=NOW(),updated_at=NOW()
                        """,
                        raw_post_id,
                        args.ghost_url,
                        str(created["id"]),
                        str(created["url"]),
                        json.dumps(attachments, ensure_ascii=False),
                    )
                    print(f"PUBLISHED {raw_post_id} {created['url']} media={len(attachments)} bytes={sum(x['bytes'] for x in attachments)}")
                except Exception as exc:
                    await pool.execute(
                        """
                        INSERT INTO post_publications(raw_post_id,platform,status,target_id,error,updated_at)
                        VALUES($1,'site','publish_failed',$2,$3,NOW())
                        ON CONFLICT(raw_post_id,platform) DO UPDATE SET
                          status='publish_failed',target_id=EXCLUDED.target_id,error=EXCLUDED.error,updated_at=NOW()
                        """,
                        raw_post_id,
                        args.ghost_url,
                        str(exc)[:2000],
                    )
                    print(f"FAILED {raw_post_id}: {exc}")
    finally:
        await bot.session.close()
        await close_pool()


def run_locked() -> None:
    if fcntl is None:
        asyncio.run(main())
        return
    with Path("/tmp/forma-n8-site-poster.lock").open("w") as lock_file:
        try:
            fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            print("ALREADY_RUNNING")
            return
        asyncio.run(main())


if __name__ == "__main__":
    run_locked()
