#!/usr/bin/env python3
"""LM Publisher control CLI.  All live network mutations require --live."""
from __future__ import annotations

import argparse
import asyncio
import contextlib
import datetime as dt
import fcntl
import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from io import BytesIO
from pathlib import Path
from typing import Any, Iterator

import requests
from PIL import Image

try:
    from zoneinfo import ZoneInfo
except ImportError:  # pragma: no cover
    ZoneInfo = None  # type: ignore


ROOT = Path(__file__).resolve().parents[1]
STATE = ROOT / "estado"
ENV_FILE = ROOT / ".env"
LOCK_FILE = ROOT / ".lmpub.lock"
DEFAULT_TIMEOUT = 20
BUFFER_API = "https://api.buffer.com"
FACEBOOK_TOOL_VERSION = "20260721_00"
TZ = ZoneInfo("Europe/Lisbon") if ZoneInfo else dt.timezone.utc
STORY_TEST_ITEMS = (
    ("9517339324", "composio"),
    ("9517288241", "buffer"),
    ("9517206218", "metricool"),
)
BEFORE_AFTER_FORMATS = {"before_after", "before_after_story"}
BEFORE_AFTER_PUBLICATION_FORMATS = {"post_image", "story_before_after"}


def load_env(path: Path = ENV_FILE) -> dict[str, str]:
    values: dict[str, str] = {}
    if path.exists():
        for raw in path.read_text(encoding="utf-8").splitlines():
            line = raw.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            values[key.strip()] = value.strip().strip('"').strip("'")
    for key in ("BUFFER_ACCESS_TOKEN", "FB_PAGE_ACCESS_TOKEN", "METRICOOL_USER_TOKEN", "TELEGRAM_BOT_TOKEN", "COMPOSIO_API_KEY"):
        if os.environ.get(key):
            values[key] = os.environ[key]
    return values


def read_json(name: str) -> Any:
    return json.loads((STATE / name).read_text(encoding="utf-8"))


def atomic_json(name: str, data: Any) -> None:
    target = STATE / name
    if target.exists():
        stamp = now().strftime("%Y%m%d_%H%M%S_%f")
        shutil.copy2(target, target.with_name(f"{target.name}.bak_{stamp}"))
    fd, tmp = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".tmp", dir=STATE)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            json.dump(data, fh, ensure_ascii=False, indent=2)
            fh.write("\n")
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp, target)
        os.chmod(target, 0o644)
    finally:
        with contextlib.suppress(FileNotFoundError):
            os.unlink(tmp)


@contextlib.contextmanager
def publisher_lock() -> Iterator[None]:
    LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
    with LOCK_FILE.open("a+") as fh:
        try:
            fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            raise RuntimeError("lmpub já está a correr")
        try:
            yield
        finally:
            fcntl.flock(fh.fileno(), fcntl.LOCK_UN)


def now() -> dt.datetime:
    return dt.datetime.now(TZ)


def today() -> dt.date:
    override = os.environ.get("LMPUB_TODAY")
    return dt.date.fromisoformat(override) if override else now().date()


def folder_url(folder: str, filename: str) -> str:
    base = load_env().get("BRIDGE", "").rstrip("/")
    return f"{base}/{folder}/{filename}"


def story_capa_url(folder: str) -> str:
    vertical_url = folder_url(folder, "story_capa.jpg")
    try:
        if remote_head(vertical_url)[0] and remote_image_size(vertical_url) == (1080, 1920):
            return vertical_url
    except (requests.RequestException, OSError):
        pass

    source = requests.get(folder_url(folder, "capa.jpg"), timeout=DEFAULT_TIMEOUT)
    source.raise_for_status()
    with Image.open(BytesIO(source.content)) as opened:
        image = opened.convert("RGB")
        image.thumbnail((1080, 1920), Image.Resampling.LANCZOS)
        corners = [image.getpixel((0, 0)), image.getpixel((image.width - 1, 0)),
                   image.getpixel((0, image.height - 1)), image.getpixel((image.width - 1, image.height - 1))]
        background = tuple(sum(pixel[i] for pixel in corners) // len(corners) for i in range(3))
        canvas = Image.new("RGB", (1080, 1920), background)
        canvas.paste(image, ((1080 - image.width) // 2, (1920 - image.height) // 2))

    generated = STATE / "generated"
    generated.mkdir(parents=True, exist_ok=True)
    safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", folder)
    target = generated / f"{safe}_story_capa.jpg"
    canvas.save(target, "JPEG", quality=95, subsampling=0)
    return folder_url("estado", f"generated/{target.name}")


def media_url(folder: str, media: str) -> str:
    return story_capa_url(folder) if media == "story_capa.jpg" else folder_url(folder, media)


def json_list(data: Any, key: str) -> list[dict[str, Any]]:
    if isinstance(data, list):
        return data
    value = data.get(key, []) if isinstance(data, dict) else []
    return value if isinstance(value, list) else []


def published_entries() -> list[dict[str, Any]]:
    return json_list(read_json("PUBLICACOES.json"), "publicacoes")


def matching_publication(folder: str, channel: str, fmt: str) -> dict[str, Any] | None:
    for item in published_entries():
        if item.get("teste") is True:
            continue
        # A terminal provider failure is evidence that delivery did not happen,
        # not an anti-duplication proof. Submitted/scheduled/unknown records stay
        # blocked until reconciliation because their outcome is uncertain.
        if item.get("status") == "failed":
            continue
        if (item.get("pasta_biblioteca") == folder and
                item.get("canal") == channel and item.get("formato") == fmt):
            return item
    return None


def remote_head(url: str) -> tuple[bool, str]:
    try:
        response = requests.head(url, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
    except requests.RequestException as exc:
        return False, f"bridge indisponível ({exc.__class__.__name__})"
    if response.status_code != 200:
        return False, f"bridge devolveu HTTP {response.status_code}"
    accept_ranges = response.headers.get("Accept-Ranges", "").lower()
    if "bytes" not in accept_ranges:
        return False, "bridge sem Accept-Ranges: bytes"
    return True, "ok"


def remote_image_size(url: str) -> tuple[int, int]:
    response = requests.get(url, timeout=DEFAULT_TIMEOUT)
    response.raise_for_status()
    with Image.open(BytesIO(response.content)) as image:
        return image.size


def marker_exists(url: str) -> bool:
    try:
        return requests.head(url, timeout=DEFAULT_TIMEOUT, allow_redirects=True).status_code == 200
    except requests.RequestException:
        return False


def blocked(folder: str, slot: dict[str, Any] | None = None) -> str | None:
    if slot and (slot.get("bloqueado") is True or str(slot.get("status", "")).lower() == "bloqueado"):
        return "slot bloqueado"
    if marker_exists(folder_url(folder, "_NAO_PUBLICAR")):
        return "existe _NAO_PUBLICAR"
    return None


def approved(folder: str) -> bool:
    legacy = any(x.get("pasta") == folder and x.get("veredicto") == "APROVADO"
                 for x in json_list(read_json("APROVACOES_DONO.json"), "aprovacoes"))
    if legacy:
        return True
    batch_file = STATE / "APROVACOES_BEFORE_AFTER.json"
    if not batch_file.exists():
        return False
    batch = json.loads(batch_file.read_text(encoding="utf-8"))
    return any(x.get("pasta") == folder and x.get("veredicto") == "APROVADO"
               for x in json_list(batch, "aprovacoes"))


def validate(folder: str, channel: str, fmt: str, slot: dict[str, Any] | None = None,
             media: str = "reel.mp4") -> tuple[bool, str]:
    if not approved(folder):
        return False, "pasta sem veredicto APROVADO"
    if not (slot or {}).get("_teste") and matching_publication(folder, channel, fmt):
        return False, f"anti-duplicação: já existe {channel}/{fmt}"
    if channel == "instagram" and fmt == "reel" and folder in reel_attempts():
        return False, "Reel com tentativa anterior; reconciliacao necessaria"
    reason = blocked(folder, slot)
    if reason:
        return False, reason
    is_before_after = fmt in BEFORE_AFTER_PUBLICATION_FORMATS
    if not is_before_after:
        ok, reason = remote_head(folder_url(folder, "reel.mp4"))
        if not ok:
            return False, reason
    if is_before_after or media != "reel.mp4":
        try:
            resolved_media_url = media_url(folder, media)
        except (requests.RequestException, OSError) as exc:
            return False, f"não foi possível preparar media ({exc.__class__.__name__})"
        ok, reason = remote_head(resolved_media_url)
        if not ok:
            return False, reason
    if channel == "instagram" and fmt in {"story_capa", "story_before_after"}:
        try:
            width, height = remote_image_size(resolved_media_url)
        except (requests.RequestException, OSError) as exc:
            return False, f"não foi possível validar dimensões da story ({exc.__class__.__name__})"
        if width * 16 != height * 9:
            return False, f"story_capa exige 9:16 sem crop; media é {width}x{height}"
    if channel == "instagram" and fmt == "post_image":
        try:
            width, height = remote_image_size(resolved_media_url)
        except (requests.RequestException, OSError) as exc:
            return False, f"não foi possível validar dimensões do post ({exc.__class__.__name__})"
        if width * 5 != height * 4:
            return False, f"post_image exige 4:5 sem crop; media é {width}x{height}"
    return True, "ok"


def get_caption(folder: str) -> str:
    response = requests.get(folder_url(folder, "legenda.txt"), timeout=DEFAULT_TIMEOUT)
    response.raise_for_status()
    return response.text.strip()


def short_caption(caption: str) -> str:
    first = re.split(r"\n|(?<=[.!?])\s+", caption.strip(), maxsplit=1)[0]
    tags = re.findall(r"#[\wÀ-ÿ-]+", caption)
    return (first + " " + " ".join(tags[:5])).strip()


def channels_for(slot: dict[str, Any]) -> list[tuple[str, str, str]]:
    if slot.get("formato") == "before_after":
        return [("instagram", "post_image", "post.jpg")]
    if slot.get("formato") == "before_after_story":
        return [("instagram", "story_before_after", "story.jpg")]
    if slot.get("formato") == "flash":
        return [("instagram", "reel", "flash.mp4"), ("tiktok", "video", "flash.mp4")]
    channels = [("instagram", "reel", "reel.mp4"), ("facebook", "reel", "reel.mp4"),
            ("tiktok", "video", "reel.mp4"), ("instagram", "story_capa", "story_capa.jpg"),
            ("instagram", "story_reel", "reel.mp4")]
    wanted = slot.get("_channels")
    if wanted:
        wanted = set(wanted)
        channels = [item for item in channels if item[0] in wanted or f"{item[0]}/{item[1]}" in wanted]
    return channels


def _request(method: str, url: str, **kwargs: Any) -> requests.Response:
    kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
    return requests.request(method, url, **kwargs)


def buffer_graphql(query: str, token: str) -> dict[str, Any]:
    backoff_file = STATE / "BUFFER_BACKOFF.json"
    if backoff_file.exists():
        until = float(json.loads(backoff_file.read_text()).get("until", 0))
        if now().timestamp() < until:
            raise RuntimeError("Buffer em pausa por limite API ate " + dt.datetime.fromtimestamp(until, TZ).isoformat())
    response = _request("POST", BUFFER_API,
                        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
                        json={"query": query})
    if response.status_code == 429:
        delay = max(60, int(response.headers.get("Retry-After", "900")))
        atomic_json("BUFFER_BACKOFF.json", {"until": now().timestamp() + delay})
    if response.status_code >= 400:
        raise RuntimeError(f"Buffer HTTP {response.status_code}")
    body = response.json()
    if body.get("errors"):
        messages = "; ".join(str(item.get("message", "erro sem detalhe"))
                             for item in body["errors"] if isinstance(item, dict))
        raise RuntimeError(f"Buffer GraphQL error: {messages or 'sem detalhe'}")
    return body.get("data", {})


def buffer_channels(env: dict[str, str]) -> list[dict[str, Any]]:
    token = env.get("BUFFER_ACCESS_TOKEN", "")
    if not token:
        raise RuntimeError("BUFFER_ACCESS_TOKEN ausente")
    orgs = buffer_graphql("query { account { organizations { id name } } }", token).get("account", {}).get("organizations", [])
    channels: list[dict[str, Any]] = []
    for org in orgs:
        oid = str(org.get("id", "")).replace('"', "")
        if not oid:
            continue
        query = f'query {{ channels(input: {{ organizationId: "{oid}" }}) {{ id name service serviceId organizationId }} }}'
        channels.extend(buffer_graphql(query, token).get("channels", []))
    return channels


def publish_buffer(folder: str, channel: str, fmt: str, media: str, caption: str, env: dict[str, str]) -> dict[str, Any]:
    token = env.get("BUFFER_ACCESS_TOKEN", "")
    channel_id = env.get("BUFFER_CHANNEL_IG", "")
    if not token or not channel_id:
        raise RuntimeError("BUFFER_ACCESS_TOKEN ou BUFFER_CHANNEL_IG ausente")
    asset = "image" if fmt in {"story_capa", "story_before_after", "post_image"} else "video"
    if fmt in {"story_capa", "story_reel", "story_before_after"}:
        metadata = " metadata: { instagram: { type: story shouldShareToFeed: false } }"
    elif fmt == "reel":
        metadata = " metadata: { instagram: { type: reel shouldShareToFeed: true } }"
    else:
        metadata = ""
    mutation = f'''mutation {{ createPost(input: {{
      text: {json.dumps(caption)} channelId: {json.dumps(channel_id)}
      schedulingType: automatic mode: shareNow
      {metadata}
      assets: [{{ {asset}: {{ url: {json.dumps(media_url(folder, media))} }} }}]
    }}) {{ ... on PostActionSuccess {{ post {{ id status sentAt externalLink error {{ message }} }} }} ... on MutationError {{ message }} }} }}'''
    data = buffer_graphql(mutation, token)
    result = data.get("createPost", {})
    if result.get("message"):
        raise RuntimeError(f"Buffer createPost rejeitado: {result.get('message')}")
    return result.get("post", result)


def composio_payload(result: dict[str, Any]) -> dict[str, Any]:
    if result.get("successful") is not True:
        raise RuntimeError("Composio nao confirmou sucesso")
    data = result.get("data", {})
    if isinstance(data, dict) and isinstance(data.get("response_data"), dict):
        data = data["response_data"]
    if not isinstance(data, dict):
        raise RuntimeError("Payload Composio invalido")
    return data


def reel_attempts() -> dict[str, Any]:
    p = STATE / "REEL_ATTEMPTS.json"
    return json.loads(p.read_text()) if p.exists() else {}


def publish_instagram_reel(folder: str, media: str, caption: str) -> dict[str, Any]:
    attempts = reel_attempts()
    if folder in attempts:
        raise RuntimeError("Reel tem tentativa anterior; reconciliar antes de repetir")
    who = composio_payload(composio_execute("INSTAGRAM_GET_USER_INFO", {"ig_user_id": "me"}))
    if who.get("username") != "lm.shirt.revivalist" or not who.get("id"):
        raise RuntimeError("Conta Instagram diferente da autorizada")
    attempt = {"status": "creating", "started_at": now().isoformat(), "ig_user_id": str(who["id"])}
    attempts[folder] = attempt
    atomic_json("REEL_ATTEMPTS.json", attempts)
    try:
        container = composio_payload(composio_execute("INSTAGRAM_POST_IG_USER_MEDIA", {
            "ig_user_id": str(who["id"]), "media_type": "REELS", "share_to_feed": True,
            "video_url": media_url(folder, media), "caption": caption.replace("#", "%23")}))
        cid = container.get("id") or container.get("creation_id")
        if not cid:
            raise RuntimeError("Composio nao devolveu container do Reel")
        attempt.update(status="publishing", creation_id=str(cid))
        atomic_json("REEL_ATTEMPTS.json", attempts)
        published = composio_payload(composio_execute("INSTAGRAM_POST_IG_USER_MEDIA_PUBLISH", {
            "ig_user_id": str(who["id"]), "creation_id": str(cid), "max_wait_seconds": 120}))
        mid = published.get("id") or published.get("media_id")
        if not mid:
            raise RuntimeError("Publicacao sem media_id verificavel")
        attempt.update(status="verification_pending", media_id=str(mid))
        atomic_json("REEL_ATTEMPTS.json", attempts)
        verified = composio_payload(composio_execute("INSTAGRAM_GET_IG_MEDIA", {
            "ig_media_id": str(mid), "fields": "id,permalink,timestamp,media_product_type"}))
        if (str(verified.get("id")) != str(mid) or verified.get("media_product_type") != "REELS"
                or not str(verified.get("permalink", "")).startswith("https://www.instagram.com/")
                or not verified.get("timestamp")):
            raise RuntimeError("Reel ainda sem confirmacao publica valida")
        attempt.update(status="published", permalink=verified["permalink"], timestamp=verified["timestamp"])
        atomic_json("REEL_ATTEMPTS.json", attempts)
        return verified
    except Exception:
        # Preserve container/media IDs and block automatic resubmission after
        # timeouts or uncertain publication outcomes.
        attempt["requires_reconciliation"] = True
        atomic_json("REEL_ATTEMPTS.json", attempts)
        raise


def publish_instagram_image(folder: str, fmt: str, media: str, caption: str) -> dict[str, Any]:
    who = composio_execute("INSTAGRAM_GET_USER_INFO", {"ig_user_id": "me"})
    ig_id = nested_id(who) or "me"
    arguments: dict[str, Any] = {
        "ig_user_id": ig_id,
        "image_url": media_url(folder, media),
    }
    if fmt == "story_before_after":
        arguments["media_type"] = "STORIES"
    else:
        # Composio's Instagram schema requires URL-encoded hashtag markers.
        arguments["caption"] = caption.replace("#", "%23")
        arguments["alt_text"] = f"Antes e depois de restauro: {caption.splitlines()[0][:900]}"
    container = composio_execute("INSTAGRAM_POST_IG_USER_MEDIA", arguments)
    creation_id = nested_id(container)
    if not creation_id:
        raise RuntimeError("Composio não devolveu creation_id")
    result = composio_execute("INSTAGRAM_POST_IG_USER_MEDIA_PUBLISH", {
        "ig_user_id": ig_id,
        "creation_id": creation_id,
        "max_wait_seconds": 60,
    })
    if result.get("successful") is False:
        raise RuntimeError(f"Composio rejeitou publicação Instagram: {result.get('error') or 'sem detalhe'}")
    return result


def publish_buffer_story(image_url: str, env: dict[str, str]) -> dict[str, Any]:
    token, channel_id = env.get("BUFFER_ACCESS_TOKEN", ""), env.get("BUFFER_CHANNEL_IG", "")
    if not token or not channel_id:
        raise RuntimeError("Buffer não configurado")
    mutation = f'''mutation {{ createPost(input: {{
      text: "" channelId: {json.dumps(channel_id)} schedulingType: automatic mode: shareNow needsApproval: false
      metadata: {{ instagram: {{ type: story shouldShareToFeed: false }} }}
      assets: [{{ image: {{ url: {json.dumps(image_url)} }} }}]
    }}) {{ ... on PostActionSuccess {{ post {{ id status sentAt externalLink error {{ message }} }} }} ... on MutationError {{ message }} }} }}'''
    result = buffer_graphql(mutation, token).get("createPost", {})
    if result.get("message"):
        raise RuntimeError("Buffer rejeitou a Story")
    return result.get("post", result)


def composio_facebook_execute(slug: str, arguments: dict[str, Any], env: dict[str, str]) -> dict[str, Any]:
    # The machine's authenticated Composio CLI owns the active Facebook OAuth
    # connections.  An API key can belong to a different Composio project and
    # therefore cannot safely be used to auto-resolve those connections.
    response = composio_execute(slug, arguments)
    if response.get("successful") is not True:
        raise RuntimeError(f"Composio rejeitou {slug}: {response.get('error') or 'sem detalhe'}")
    return response


def create_facebook_video(arguments: dict[str, Any], env: dict[str, str]) -> dict[str, Any]:
    for attempt in range(2):
        try:
            body = composio_facebook_execute("FACEBOOK_CREATE_VIDEO_POST", arguments, env)
            if "could not be read" not in json.dumps(body).lower() or attempt:
                return body
        except Exception as exc:
            if attempt or "could not be read" not in str(exc).lower():
                raise
    raise RuntimeError("Facebook rejeitou o vídeo")


def publish_facebook(folder: str, caption: str, title: str, env: dict[str, str]) -> dict[str, Any]:
    page = env.get("FB_PAGE_ID", "1326814130505190")
    arguments = {"page_id": page, "file_url": folder_url(folder, "reel.mp4"),
                 "description": caption, "title": title}
    return create_facebook_video(arguments, env)


def metricool_count() -> int:
    month = now().strftime("%Y-%m")
    return sum(1 for x in published_entries()
               if x.get("canal") == "tiktok" and str(x.get("submetido_em") or x.get("publicado_em") or "").startswith(month))


def publish_tiktok(folder: str, caption: str, media: str, env: dict[str, str], slot: dict[str, Any] | None = None) -> dict[str, Any]:
    count = metricool_count()
    slot = slot or find_slot(folder)
    remaining = 20 - count
    if count >= 20:
        raise RuntimeError(f"quota Metricool esgotada: {count}/20")
    if slot.get("formato") == "flash" and remaining <= 6:
        raise RuntimeError(f"quota Metricool: {count}/20; flash suspenso para reservar principais")
    when = parse_slot_time(slot)
    if not when:
        raise RuntimeError("slot sem data/hora válida")
    scheduled = when + dt.timedelta(minutes=75)
    info = {"providers": [{"network": "tiktok"}], "autoPublish": True, "draft": False,
            "text": short_caption(caption), "media": [folder_url(folder, media)],
            "publicationDate": {"dateTime": scheduled.strftime("%Y-%m-%dT%H:%M:%S"), "timezone": "Europe/Lisbon"},
            "tiktokData": {"title": str(slot.get("titulo") or folder)},
            "userId": str(env.get("METRICOOL_USER_ID") or "5151954")}
    return asyncio.run(metricool_call("createScheduledPost", {
        "date": scheduled.isoformat(), "blogId": str(env.get("METRICOOL_BLOG_ID") or "6695614"),
        "info": json.dumps(info, ensure_ascii=False),
    }))


async def metricool_call(tool: str, arguments: dict[str, Any]) -> dict[str, Any]:
    import httpx
    from mcp import ClientSession
    from mcp.client.streamable_http import streamable_http_client
    token_path = Path("/home/lmpub/lm/hermes/mcp-tokens/metricool.json")
    token = json.loads(token_path.read_text(encoding="utf-8")).get("access_token", "")
    if not token:
        raise RuntimeError("OAuth Metricool ausente; renova no Hermes")
    async with httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"}, timeout=DEFAULT_TIMEOUT) as http:
        async with streamable_http_client("https://ai.metricool.com/mcp", http_client=http) as (read, write, _):
            async with ClientSession(read, write) as session:
                await session.initialize()
                result = await session.call_tool(tool, arguments)
                if result.isError:
                    detail = " ".join(getattr(item, "text", str(item)) for item in result.content).strip()
                    raise RuntimeError(f"Metricool MCP rejeitou {tool}: {detail or 'sem detalhe'}")
                if result.structuredContent:
                    return dict(result.structuredContent)
                return {"content": [getattr(x, "text", str(x)) for x in result.content]}


def composio_execute(slug: str, arguments: dict[str, Any]) -> dict[str, Any]:
    proc = subprocess.run(
        ["/home/lmpub/.local/bin/composio", "execute", slug, "-d", json.dumps(arguments)],
        text=True, capture_output=True, timeout=180, check=False,
    )
    if proc.returncode:
        raise RuntimeError(f"Composio rejeitou {slug}")
    try:
        return json.loads(proc.stdout)
    except json.JSONDecodeError as exc:
        raise RuntimeError("resposta Composio inválida") from exc


def nested_id(data: Any) -> str | None:
    if isinstance(data, dict):
        for key in ("id", "creation_id", "media_id"):
            if data.get(key):
                return str(data[key])
        for value in data.values():
            found = nested_id(value)
            if found:
                return found
    elif isinstance(data, list):
        for value in data:
            found = nested_id(value)
            if found:
                return found
    return None


def exception_detail(exc: BaseException) -> str:
    if isinstance(exc, BaseExceptionGroup):
        return "; ".join(exception_detail(x) for x in exc.exceptions)
    return str(exc) or exc.__class__.__name__


def vinted_item(item_id: str) -> dict[str, Any]:
    items = json_list(read_json("shirts_db.json"), "shirts")
    item = next((x for x in items if str(x.get("shirt_id")) == item_id), None)
    if not item or item.get("status") != "AVAILABLE":
        raise RuntimeError(f"Vinted {item_id} não está AVAILABLE")
    return item


def vinted_main_image(item: dict[str, Any]) -> str:
    response = requests.get(str(item["vinted_url"]), headers={"User-Agent": "Mozilla/5.0"}, timeout=DEFAULT_TIMEOUT)
    response.raise_for_status()
    patterns = (
        r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)',
        r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image',
    )
    match = next((m for p in patterns if (m := re.search(p, response.text, re.I))), None)
    if not match:
        raise RuntimeError("Vinted sem fotografia principal")
    url = html.unescape(match.group(1))
    check = requests.head(url, timeout=DEFAULT_TIMEOUT, allow_redirects=True)
    if check.status_code != 200 or not check.headers.get("Content-Type", "").startswith("image/"):
        raise RuntimeError("fotografia Vinted indisponível")
    return url


def story_test_record(item: dict[str, Any], transport: str, result: dict[str, Any]) -> None:
    record(f"vinted_{item['shirt_id']}", {"titulo": item.get("title"), "clube": item.get("brand")},
           "instagram", f"story_test_{transport}", result)


def cmd_testar_stories(args: argparse.Namespace) -> int:
    if not args.owner_approved:
        print("RECUSA falta autorização pontual do dono")
        return 1
    env = load_env()
    failures = 0
    with publisher_lock():
        for item_id, transport in STORY_TEST_ITEMS:
            try:
                item = vinted_item(item_id)
                fmt = f"story_test_{transport}"
                if matching_publication(f"vinted_{item_id}", "instagram", fmt):
                    raise RuntimeError("anti-duplicação: teste já registado")
                image_url = vinted_main_image(item)
                if not args.live:
                    print(f"DRY-RUN {transport}: {item_id} {item.get('title')}")
                    continue
                if transport == "composio":
                    who = composio_execute("INSTAGRAM_GET_USER_INFO", {"ig_user_id": "me"})
                    ig_id = nested_id(who) or "me"
                    container = composio_execute("INSTAGRAM_POST_IG_USER_MEDIA", {
                        "ig_user_id": ig_id, "image_url": image_url, "media_type": "STORIES"})
                    creation_id = nested_id(container)
                    if not creation_id:
                        raise RuntimeError("Composio não devolveu creation_id")
                    result = composio_execute("INSTAGRAM_POST_IG_USER_MEDIA_PUBLISH", {
                        "ig_user_id": ig_id, "creation_id": creation_id, "max_wait_seconds": 60})
                elif transport == "buffer":
                    result = publish_buffer_story(image_url, env)
                else:
                    when = now() + dt.timedelta(minutes=2)
                    info = {"providers": [{"network": "instagram"}], "autoPublish": True, "draft": False,
                            "text": "", "media": [{"url": image_url}],
                            "publicationDate": {"dateTime": when.strftime("%Y-%m-%dT%H:%M:%S"), "timezone": "Europe/London"},
                            "instagramData": {"type": "STORY"}, "userId": "5151954"}
                    result = asyncio.run(metricool_call("createScheduledPost", {
                        "date": when.isoformat(), "blogId": str(env.get("METRICOOL_BLOG_ID") or "6695614"),
                        "info": json.dumps(info, ensure_ascii=False)}))
                story_test_record(item, transport, result)
                print(f"OK {transport}: {item_id}")
            except Exception as exc:
                failures += 1
                print(f"FALHA {transport}: {item_id} ({exception_detail(exc)})")
    return 1 if failures else 0



def reconcile_metricool(apply: bool = False) -> int:
    data = read_json("PUBLICACOES.json")
    entries = json_list(data, "publicacoes")
    candidates = [x for x in entries if x.get("metodo") == "metricool-mcp"
                  and x.get("status") != "published"]
    if not candidates:
        return 0
    dates = [str(x.get("submetido_em") or x.get("publicado_em") or "")[:10] for x in candidates]
    dates = [x for x in dates if re.fullmatch(r"\d{4}-\d{2}-\d{2}", x)]
    if not dates:
        print("METRICOOL sem datas verificaveis")
        return 1
    start = dt.datetime.combine(dt.date.fromisoformat(min(dates)), dt.time(), TZ)
    try:
        raw = asyncio.run(metricool_call("getScheduledPosts", {
            "brandId": str(load_env().get("METRICOOL_BLOG_ID") or "6695614"),
            "fromDate": start.isoformat(), "toDate": (now() + dt.timedelta(days=1)).isoformat(),
            "timezone": "Europe/Lisbon"}))
        posts = []
        for content in raw.get("content", []):
            obj = json.loads(content) if isinstance(content, str) else content
            if isinstance(obj, dict) and isinstance(obj.get("data"), list):
                posts.extend(obj["data"])
        if not posts:
            raise RuntimeError("Metricool nao devolveu agendamentos verificaveis")
    except Exception as exc:
        print(f"METRICOOL nao verificado: {exception_detail(exc)}")
        return 1
    failures = 0
    changed = False
    for item in candidates:
        submitted = str(item.get("submetido_em") or item.get("publicado_em") or "")
        matches = [x for x in posts if (
            item.get("external_id") and str(x.get("id")) == str(item["external_id"])) or (
            not item.get("external_id") and x.get("tiktokData", {}).get("title") == item.get("projeto")
            and str(x.get("creationDate", {}).get("dateTime", ""))[:10] == submitted[:10])]
        if len(matches) != 1:
            failures += 1
            print(f"METRICOOL sem correspondencia unica: {item.get('pasta_biblioteca')}")
            continue
        post = matches[0]
        providers = [x for x in post.get("providers", []) if x.get("network") == "tiktok"]
        if len(providers) != 1:
            failures += 1
            continue
        provider = providers[0]
        status = "published" if provider.get("status") == "PUBLISHED" and provider.get("publicUrl") else "scheduled"
        if provider.get("status") in {"ERROR", "FAILED"}:
            status = "failed"
        failures += status == "failed"
        print(f"METRICOOL {status} {item.get('pasta_biblioteca')}")
        if apply:
            item.setdefault("submetido_em", item.get("publicado_em"))
            item["external_id"] = str(post["id"])
            item["status"] = status
            item["publicado_em"] = None
            item["permalink"] = provider.get("publicUrl") if status == "published" else None
            # Metricool exposes the planned date, not the actual send timestamp.
            item["agendado_para"] = post.get("publicationDate")
            item["verificado_em"] = now().isoformat(timespec="seconds")
            item["delivery_error"] = provider.get("detailedStatus") if status == "failed" else None
            changed = True
    if changed:
        atomic_json("PUBLICACOES.json", data)
    return 1 if failures else 0


def delivery_state(post: dict[str, Any]) -> str:
    if post.get("status") == "error":
        return "failed"
    if post.get("status") == "sent" and post.get("sentAt"):
        return "published"
    return "submitted"


def reconcile_buffer(apply: bool = False) -> int:
    data = read_json("PUBLICACOES.json")
    entries = json_list(data, "publicacoes")
    candidates = [x for x in entries if x.get("metodo") == "buffer"
                  and x.get("status") != "published"
                  and re.fullmatch(r"[0-9a-f]{24}", str(x.get("external_id") or x.get("permalink") or ""))]
    # Terminal provider failures remain in the ledger for audit, but they are
    # resolved outcomes. They must not make every later publisher tick fail.
    errors = 0
    def due(item):
        if item.get("status") != "failed":
            return True
        try:
            return now() - dt.datetime.fromisoformat(item["verificado_em"]) >= dt.timedelta(hours=6)
        except (KeyError, ValueError, TypeError):
            return True
    candidates = [x for x in candidates if due(x)]
    if not candidates:
        return 0
    changed = False
    token = load_env().get("BUFFER_ACCESS_TOKEN", "")
    query = "query { " + " ".join(
        "p" + str(i) + ":post(input:{id:" + json.dumps(x.get("external_id") or x.get("permalink"))
        + "}){id status sentAt externalLink error{message}}" for i, x in enumerate(candidates)) + " }"
    try:
        results = buffer_graphql(query, token)
    except Exception as exc:
        print(f"BUFFER consulta agrupada nao verificada: {exception_detail(exc)}")
        return 1
    for i, item in enumerate(candidates):
        pid = item.get("external_id") or item.get("permalink")
        try:
            post = results.get("p" + str(i))
            if not post or post.get("id") != pid:
                raise RuntimeError("Buffer nao confirmou o objeto pedido")
            status = delivery_state(post)
            print(f"BUFFER {status} {item.get('pasta_biblioteca')} {pid}")
            if apply:
                item.setdefault("submetido_em", item.get("publicado_em"))
                item["external_id"] = pid
                item["status"] = status
                item["publicado_em"] = post.get("sentAt") if status == "published" else None
                item["permalink"] = post.get("externalLink") if status == "published" else None
                item["delivery_error"] = (post.get("error") or {}).get("message")
                item["verificado_em"] = now().isoformat(timespec="seconds")
                changed = True
        except Exception as exc:
            errors += 1
            print(f"BUFFER nao verificado {pid}: {exception_detail(exc)}")
    if changed:
        atomic_json("PUBLICACOES.json", data)
    return 1 if errors else 0


def buffer_terminal_failures() -> list[dict[str, Any]]:
    return [x for x in published_entries()
            if x.get("metodo") == "buffer" and x.get("status") == "failed"]


def record(folder: str, slot: dict[str, Any], channel: str, fmt: str, result: dict[str, Any]) -> dict[str, Any]:
    data = read_json("PUBLICACOES.json")
    entries = json_list(data, "publicacoes")
    permalink = result.get("permalink") or result.get("url") or nested_id(result)
    entry = {"projeto": slot.get("titulo", folder), "pasta_biblioteca": folder,
                    "clube": slot.get("clube"), "canal": channel, "formato": fmt,
                    "publicado_em": now().isoformat(timespec="seconds"),
                    "permalink": permalink, "metodo": (
                        "composio-reels" if channel == "instagram" and fmt == "reel" else
                        "composio" if channel == "instagram" and fmt in BEFORE_AFTER_PUBLICATION_FORMATS
                        else {"instagram": "buffer", "facebook": "composio", "tiktok": "metricool-mcp"}.get(channel, "buffer")
                    ),
                    "nota": "lmpub"}
    if slot.get("_teste"):
        entry["teste"] = True
        entry["nota"] = "lmpub · teste autorizado pelo dono"
    method = entry["metodo"]
    entry["submetido_em"] = entry["publicado_em"]
    entry["external_id"] = nested_id(result)
    if method == "composio-reels":
        entry["status"] = "published"
        entry["publicado_em"] = result["timestamp"]
        entry["permalink"] = result["permalink"]
    elif method == "buffer":
        entry["status"] = delivery_state(result)
        entry["publicado_em"] = result.get("sentAt") if entry["status"] == "published" else None
        entry["permalink"] = result.get("externalLink") if entry["status"] == "published" else None
        entry["delivery_error"] = (result.get("error") or {}).get("message")
    elif method == "metricool-mcp":
        entry["status"] = "scheduled"
        entry["publicado_em"] = None
        entry["permalink"] = None
    else:
        entry["status"] = "legacy_confirmation"
    entries.append(entry)
    if isinstance(data, dict):
        data["publicacoes"] = entries
        data["atualizado_em"] = now().isoformat(timespec="seconds")
        data["atualizado_por"] = "lmpub"
    atomic_json("PUBLICACOES.json", data)
    return entry


def execute(folder: str, slot: dict[str, Any], live: bool) -> int:
    if not slot.get("_teste") and slot.get("pronto") is not True:
        print(f"RECUSA {folder}: slot não está pronto")
        return 1
    caption = ""
    plan = channels_for(slot)
    if not live:
        print(f"DRY-RUN {folder}: " + ", ".join(f"{c}/{f}" for c, f, _ in plan))
    failures = 0
    for channel, fmt, media in plan:
        ok, reason = validate(folder, channel, fmt, slot, media)
        if not ok:
            print(f"RECUSA {folder} {channel}/{fmt}: {reason}")
            failures += 1
            continue
        if not live:
            continue
        try:
            if not caption:
                caption = get_caption(folder)
            if channel == "instagram" and fmt in BEFORE_AFTER_PUBLICATION_FORMATS:
                result = publish_instagram_image(folder, fmt, media, caption)
            elif channel == "instagram" and fmt == "reel":
                result = publish_instagram_reel(folder, media, caption)
            elif channel == "instagram":
                result = publish_buffer(folder, channel, fmt, media, caption, load_env())
            elif channel == "facebook":
                result = publish_facebook(folder, caption, str(slot.get("titulo") or folder), load_env())
            else:
                result = publish_tiktok(folder, caption, media, load_env(), slot)
            entry = record(folder, slot, channel, fmt, result)
            print(f"{entry['status'].upper()} {folder} {channel}/{fmt}")
            if entry["status"] == "failed":
                failures += 1
        except Exception as exc:  # each channel is independent
            print(f"FALHA {folder} {channel}/{fmt}: {exception_detail(exc)}")
            failures += 1
    return 1 if failures else 0


def parse_slot_time(slot: dict[str, Any]) -> dt.datetime | None:
    try:
        date = dt.date.fromisoformat(str(slot["data"]))
        hour = str(slot.get("hora", "19:00")).split("-")[0]
        h, m = (int(x) for x in hour.split(":", 1))
        return dt.datetime.combine(date, dt.time(h, m), TZ)
    except (KeyError, ValueError, TypeError):
        return None


def due_slots() -> list[dict[str, Any]]:
    cal = read_json("CALENDARIO_PUBLICACAO.json")
    slots = json_list(cal, "fila")
    current = now()
    due: list[dict[str, Any]] = []
    for slot in slots:
        when = parse_slot_time(slot)
        if when and when.date() == today() and dt.timedelta(0) <= current - when <= dt.timedelta(minutes=90):
            due.append(slot)
    return due[:4]


def next_slot() -> dict[str, Any] | None:
    slots = json_list(read_json("CALENDARIO_PUBLICACAO.json"), "fila")
    future = [(parse_slot_time(x), x) for x in slots if parse_slot_time(x) and parse_slot_time(x) > now()]
    return min(future, key=lambda item: item[0])[1] if future else None


def cmd_slot(args: argparse.Namespace) -> int:
    with publisher_lock():
        delivery_failures = (reconcile_buffer(apply=True) | reconcile_metricool(apply=True)) if args.live else 0
        if args.date:
            try:
                dt.date.fromisoformat(args.date)
            except ValueError:
                print("RECUSA data inválida; usa AAAA-MM-DD")
                return 1
            if args.live:
                print("RECUSA slot por data é apenas para --dry-run")
                return 1
            slots = [x for x in json_list(read_json("CALENDARIO_PUBLICACAO.json"), "fila")
                     if x.get("data") == args.date]
            if not slots:
                print(f"SEM SLOT {args.date}")
                return 1
            status = 0
            for slot in slots:
                status |= execute(str(slot["pasta"]), slot, False)
            return status
        slots = due_slots()
        if not slots:
            nxt = next_slot()
            if nxt and args.dry_run:
                return execute(str(nxt["pasta"]), nxt, False)
            if nxt:
                print(f"PRÓXIMO {nxt.get('data')} {nxt.get('hora', '21:00')} {nxt.get('pasta')}")
            else:
                print("SEM SLOTS")
            return delivery_failures
        status = delivery_failures
        for slot in slots:
            status |= execute(str(slot["pasta"]), slot, args.live)
        return status


def find_slot(folder: str) -> dict[str, Any]:
    for slot in json_list(read_json("CALENDARIO_PUBLICACAO.json"), "fila"):
        if slot.get("pasta") == folder:
            return slot
    return {"pasta": folder, "titulo": folder}


def cmd_publicar(args: argparse.Namespace) -> int:
    with publisher_lock():
        slot = find_slot(args.folder)
        if args.teste:
            if not args.owner_approved:
                print("RECUSA teste sem autorização pontual do dono")
                return 1
            slot["_teste"] = True
        if args.quando == "agora":
            slot["data"] = today().isoformat()
            slot["hora"] = now().strftime("%H:%M")
        elif args.quando:
            slot["data"] = args.quando.split("T", 1)[0]
            slot["hora"] = args.quando.split("T", 1)[1][:5] if "T" in args.quando else now().strftime("%H:%M")
        if args.canais:
            wanted = {x.strip() for x in args.canais.split(",")}
            slot["_channels"] = wanted
        return execute(args.folder, slot, args.live)


def cmd_estado(_: argparse.Namespace) -> int:
    entries = published_entries()
    print("ULTIMOS REGISTOS (submissao nao equivale a publicacao)")
    for item in entries[-10:]:
        print(f"{item.get('status', 'legacy_unverified')} {item.get('publicado_em')} {item.get('pasta_biblioteca')} {item.get('canal')}/{item.get('formato')}")
    nxt = next_slot()
    print("PRÓXIMO SLOT", (f"{nxt.get('data')} {nxt.get('hora', '21:00')} {nxt.get('pasta')}" if nxt else "nenhum"))
    return 0


def cmd_metricas(args: argparse.Namespace) -> int:
    env = load_env()
    if not env.get("BUFFER_ACCESS_TOKEN") and not env.get("METRICOOL_USER_TOKEN"):
        print("VERMELHO métricas: tokens Buffer/Metricool ausentes")
        return 1
    print(f"Métricas dos últimos {args.dias} dias: leitura configurada; consultar Graph/Metricool.")
    return 0


def cmd_reordenar(args: argparse.Namespace) -> int:
    with publisher_lock():
        data = read_json("CALENDARIO_PUBLICACAO.json")
        fila = json_list(data, "fila")
        target = next((x for x in fila if x.get("pasta") == args.folder), None)
        if not target:
            print("RECUSA pasta não está na fila")
            return 1
        if any(x.get("data") == args.date and x is not target for x in fila):
            print("RECUSA já existe slot nessa data")
            return 1
        if args.hora:
            try:
                dt.time.fromisoformat(args.hora)
            except ValueError:
                print("RECUSA hora inválida; usa HH:MM")
                return 1
        before = adjacency_errors(fila)
        old_date, old_time = target.get("data"), target.get("hora", "21:00")
        target["data"] = args.date
        if args.hora:
            target["hora"] = args.hora
        reason = new_adjacency_error(fila, before)
        if reason:
            target["data"], target["hora"] = old_date, old_time
            print(f"RECUSA viola regra: {reason}")
            return 1
        save_calendar(data, fila)
        print(f"OK reordenado {args.folder} para {args.date} {target.get('hora', '21:00')}")
        return 0


def canonical_club(value: Any) -> str:
    club = re.sub(r"[^a-z0-9]+", " ", str(value or "").casefold()).strip()
    aliases = {
        "sporting cp": "sporting", "fc porto": "porto", "ac milan": "milan",
        "manchester utd": "manchester united", "real madrid home": "real madrid",
    }
    return aliases.get(club, club)


def calendar_order(fila: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return sorted(fila, key=lambda x: (str(x.get("data", "9999-99-99")), str(x.get("hora", "21:00"))))


def adjacency_error(fila: list[dict[str, Any]]) -> str | None:
    ordered = [x for x in calendar_order(fila)
               if x.get("hora", "19:00") == "19:00" and x.get("formato") != "before_after_story"]
    for left, right in zip(ordered, ordered[1:]):
        club = canonical_club(left.get("clube"))
        if club and club == canonical_club(right.get("clube")):
            return f"clubes adjacentes: {left.get('pasta')} / {right.get('pasta')} ({club})"
    return None


def adjacency_errors(fila: list[dict[str, Any]]) -> set[tuple[str, str, str]]:
    ordered = [x for x in calendar_order(fila)
               if x.get("hora", "19:00") == "19:00" and x.get("formato") != "before_after_story"]
    errors: set[tuple[str, str, str]] = set()
    for left, right in zip(ordered, ordered[1:]):
        club = canonical_club(left.get("clube"))
        if club and club == canonical_club(right.get("clube")):
            errors.add((str(left.get("pasta")), str(right.get("pasta")), club))
    return errors


def new_adjacency_error(fila: list[dict[str, Any]], before: set[tuple[str, str, str]]) -> str | None:
    added = adjacency_errors(fila) - before
    if not added:
        return None
    left, right, club = sorted(added)[0]
    return f"clubes adjacentes: {left} / {right} ({club})"


def slot_problem(slot: dict[str, Any], require_ready: bool = True) -> str | None:
    folder = str(slot.get("pasta", ""))
    if require_ready and slot.get("pronto") is not True:
        return f"{folder} não está pronto"
    if not approved(folder):
        return f"{folder} não está APROVADO"
    reason = blocked(folder, slot)
    return f"{folder}: {reason}" if reason else None


def slot_by_folder(fila: list[dict[str, Any]], folder: str) -> dict[str, Any] | None:
    matches = [x for x in fila if x.get("pasta") == folder]
    return next((x for x in matches if x.get("formato") == "before_after"),
                next((x for x in matches if x.get("formato") not in {"flash", "before_after_story"}),
                     matches[0] if matches else None))


def slot_by_date(fila: list[dict[str, Any]], date: str) -> dict[str, Any] | None:
    matches = [x for x in fila if x.get("data") == date]
    return next((x for x in matches if x.get("hora") == "19:00"), matches[0] if matches else None)


def save_calendar(data: Any, fila: list[dict[str, Any]]) -> None:
    ordered = calendar_order(fila)
    if isinstance(data, dict):
        data["fila"] = ordered
        data["atualizado_em"] = now().isoformat(timespec="seconds")
        data["atualizado_por"] = "lmpub"
    else:
        data[:] = ordered
    atomic_json("CALENDARIO_PUBLICACAO.json", data)


def cmd_trocar(args: argparse.Namespace) -> int:
    with publisher_lock():
        data = read_json("CALENDARIO_PUBLICACAO.json")
        fila = json_list(data, "fila")
        a, b = slot_by_folder(fila, args.folder_a), slot_by_folder(fila, args.folder_b)
        if not a or not b or a is b:
            print("RECUSA pastas distintas têm de existir na fila")
            return 1
        date_a, date_b = str(a.get("data", "")), str(b.get("data", ""))
        before = adjacency_errors(fila)
        # A peça que sobe tem sempre de estar pronta; uma peça incompleta apenas pode descer.
        for slot, destination in ((a, date_b), (b, date_a)):
            problem = slot_problem(slot, require_ready=destination < str(slot.get("data", "")))
            if problem:
                print(f"RECUSA {problem}")
                return 1
        a["data"], b["data"] = date_b, date_a
        reason = new_adjacency_error(fila, before)
        if reason:
            a["data"], b["data"] = date_a, date_b
            print(f"RECUSA {reason}")
            return 1
        save_calendar(data, fila)
        print(f"OK troca {args.folder_a} ({date_a}→{date_b}) / {args.folder_b} ({date_b}→{date_a})")
        return 0


def cmd_saltar(args: argparse.Namespace) -> int:
    with publisher_lock():
        data = read_json("CALENDARIO_PUBLICACAO.json")
        fila = json_list(data, "fila")
        slot = slot_by_date(fila, args.date)
        if not slot:
            print("RECUSA não existe slot nessa data")
            return 1
        problem = slot_problem(slot)
        if problem:
            print(f"RECUSA {problem}")
            return 1
        last = max(dt.date.fromisoformat(str(x["data"])) for x in fila if x.get("data"))
        old = str(slot["data"])
        before = adjacency_errors(fila)
        slot["data"] = (last + dt.timedelta(days=1)).isoformat()
        reason = new_adjacency_error(fila, before)
        if reason:
            slot["data"] = old
            print(f"RECUSA {reason}")
            return 1
        save_calendar(data, fila)
        print(f"OK saltado {old} para {slot['data']} ({slot.get('pasta')})")
        return 0


def cmd_adiar(args: argparse.Namespace) -> int:
    with publisher_lock():
        dt.date.fromisoformat(args.new_date)
        data = read_json("CALENDARIO_PUBLICACAO.json")
        fila = json_list(data, "fila")
        slot = slot_by_date(fila, args.date)
        if not slot:
            print("RECUSA não existe slot nessa data")
            return 1
        if slot_by_date(fila, args.new_date):
            print("RECUSA já existe slot na nova data; usa lmpub trocar")
            return 1
        problem = slot_problem(slot)
        if problem:
            print(f"RECUSA {problem}")
            return 1
        old = str(slot["data"])
        before = adjacency_errors(fila)
        slot["data"] = args.new_date
        reason = new_adjacency_error(fila, before)
        if reason:
            slot["data"] = old
            print(f"RECUSA {reason}")
            return 1
        save_calendar(data, fila)
        print(f"OK adiado {old}→{args.new_date} ({slot.get('pasta')})")
        return 0


def cmd_vinted(args: argparse.Namespace) -> int:
    with publisher_lock():
        data = read_json("shirts_db.json")
        items = data if isinstance(data, list) else data.get("shirts", [])
        if args.action == "sold":
            item = next((x for x in items if str(x.get("shirt_id")) == args.id), None)
            if not item:
                print("RECUSA id Vinted não encontrado")
                return 1
            item["status"] = "SOLD"
            atomic_json("shirts_db.json", data)
            print(f"OK SOLD {args.id}")
            return 0
        print("RECUSA Vinted SUSPENSA até ordem expressa do dono")
        return 1
        available = [x for x in items if x.get("status") == "AVAILABLE"]
        available.sort(key=lambda x: (x.get("last_story_date") or "0000", x.get("times_posted", 0), -x.get("favs", 0)))
        print("\n".join(f"{x.get('shirt_id')} {x.get('title')}" for x in available[:10]))
        return 0


def cmd_validar(_: argparse.Namespace) -> int:
    env = load_env()
    checks: list[tuple[str, bool, str]] = [("BRIDGE", bool(env.get("BRIDGE")), "configuração")]
    if env.get("BRIDGE"):
        ok, _ = remote_head(folder_url("estado", "PUBLICACOES.json"))
        checks[0] = ("BRIDGE", ok, "HEAD PUBLICACOES.json")
    if env.get("BUFFER_ACCESS_TOKEN") and env.get("BUFFER_CHANNEL_IG"):
        try:
            channels = buffer_channels(env)
            channel_id = env["BUFFER_CHANNEL_IG"]
            found = any(str(x.get("id")) == channel_id and str(x.get("service", "")).lower() == "instagram" for x in channels)
            checks.append(("BUFFER_API", found, "leitura do canal, nao prova publicacao"))
            checks.append(("BUFFER_RECONCILIATION", reconcile_buffer(False) == 0,
                           "consulta de entregas pendentes"))
        except (requests.RequestException, RuntimeError, ValueError):
            checks.append(("BUFFER", False, "GraphQL API"))
    else:
        checks.append(("BUFFER", False, "token/canal ausente"))
    try:
        result = composio_facebook_execute("FACEBOOK_GET_PAGE_DETAILS", {
            "page_id": env.get("FB_PAGE_ID", "1326814130505190"), "fields": "id"}, env)
        checks.append(("FACEBOOK", bool(result), "Composio OAuth/GET page"))
    except Exception:
        checks.append(("FACEBOOK", False, "Composio OAuth/GET page"))
    if Path("/home/lmpub/lm/hermes/mcp-tokens/metricool.json").exists():
        try:
            checks.append(("METRICOOL", asyncio.run(metricool_tools_ready()), "MCP OAuth/list_tools"))
        except Exception:
            checks.append(("METRICOOL", False, "MCP OAuth/list_tools"))
    else:
        checks.append(("METRICOOL", False, "OAuth Hermes ausente"))
    if env.get("TELEGRAM_BOT_TOKEN"):
        try:
            response = _request("GET", f"https://api.telegram.org/bot{env['TELEGRAM_BOT_TOKEN']}/getMe")
            checks.append(("TELEGRAM", response.status_code < 400, "GET getMe"))
        except requests.RequestException:
            checks.append(("TELEGRAM", False, "GET getMe"))
    else:
        checks.append(("TELEGRAM", False, "token ausente"))
    failed = 0
    for name, ok, detail in checks:
        print(f"{'VERDE' if ok else 'VERMELHO'} {name} ({detail})")
        failed += not ok
    historical_buffer_failures = buffer_terminal_failures()
    if historical_buffer_failures:
        print(f"AVISO BUFFER_DELIVERY ({len(historical_buffer_failures)} falhas terminais históricas; não bloqueiam o tick)")
    return 1 if failed else 0


async def metricool_tools_ready() -> bool:
    import httpx
    from mcp import ClientSession
    from mcp.client.streamable_http import streamable_http_client
    token = json.loads(Path("/home/lmpub/lm/hermes/mcp-tokens/metricool.json").read_text(encoding="utf-8")).get("access_token", "")
    async with httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"}, timeout=DEFAULT_TIMEOUT) as http:
        async with streamable_http_client("https://ai.metricool.com/mcp", http_client=http) as (read, write, _):
            async with ClientSession(read, write) as session:
                await session.initialize()
                names = {x.name for x in (await session.list_tools()).tools}
                return {"createScheduledPost", "getScheduledPosts"}.issubset(names)


def cmd_reconciliar(args: argparse.Namespace) -> int:
    with publisher_lock():
        return reconcile_buffer(apply=args.live) | reconcile_metricool(apply=args.live)


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="lmpub")
    sub = p.add_subparsers(dest="command", required=True)
    s = sub.add_parser("slot"); s.add_argument("date", nargs="?"); s.add_argument("--live", action="store_true"); s.add_argument("--dry-run", action="store_true"); s.set_defaults(func=cmd_slot)
    s = sub.add_parser("publicar"); s.add_argument("folder"); s.add_argument("--quando"); s.add_argument("--canais"); s.add_argument("--live", action="store_true"); s.add_argument("--teste", action="store_true"); s.add_argument("--owner-approved", action="store_true"); s.set_defaults(func=cmd_publicar)
    s = sub.add_parser("estado"); s.set_defaults(func=cmd_estado)
    s = sub.add_parser("metricas"); s.add_argument("--dias", type=int, default=7); s.set_defaults(func=cmd_metricas)
    s = sub.add_parser("reordenar"); s.add_argument("folder"); s.add_argument("date"); s.add_argument("--hora"); s.set_defaults(func=cmd_reordenar)
    s = sub.add_parser("trocar"); s.add_argument("folder_a"); s.add_argument("folder_b"); s.set_defaults(func=cmd_trocar)
    s = sub.add_parser("saltar"); s.add_argument("date"); s.set_defaults(func=cmd_saltar)
    s = sub.add_parser("adiar"); s.add_argument("date"); s.add_argument("new_date"); s.set_defaults(func=cmd_adiar)
    s = sub.add_parser("vinted"); s.add_argument("action", choices=["drop", "closet", "sold"]); s.add_argument("id", nargs="?"); s.set_defaults(func=cmd_vinted)
    s = sub.add_parser("validar"); s.set_defaults(func=cmd_validar)
    s = sub.add_parser("reconciliar"); s.add_argument("--desde"); s.add_argument("--live", action="store_true", help="atualiza apenas estados locais; nunca publica"); s.set_defaults(func=cmd_reconciliar)
    s = sub.add_parser("testar-stories"); s.add_argument("--owner-approved", action="store_true"); s.add_argument("--live", action="store_true"); s.set_defaults(func=cmd_testar_stories)
    return p


def main(argv: list[str] | None = None) -> int:
    try:
        args = parser().parse_args(argv)
        return int(args.func(args))
    except (FileNotFoundError, json.JSONDecodeError, RuntimeError) as exc:
        print(f"RECUSA {exc}")
        return 1


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