"""Content updates — Google Business Profile + social captions + blog + images.

SAME SAFETY MODEL as remediate.py: propose -> email/Slack approval link ->
only on Approve does anything get published. Nothing here posts on its own.

WHAT THIS CAN ACTUALLY DO (read before you configure it):
  • Google Business Profile — real auto-publish IS possible (description
    updates + Local Posts, now with an attached image), but Google gates it
    behind manual approval: you need a Google Cloud project, OAuth
    credentials, and an *API access request* Google reviews by hand (your
    GBP must be verified 60+ days, have a real business website). Until
    then, this module still runs in draft-only mode: it writes the exact
    description/post text into the approval email.
  • Facebook Page — real auto-publish, including a generated image, works
    with a Page Access Token (Meta Graph API), which you generate yourself
    in Meta Business Suite.
  • Instagram Business — real auto-publish is possible, but Instagram's API
    requires its own Meta app review (same style of one-time approval as
    GBP) plus an Instagram Business account linked to a Facebook Page.
  • LinkedIn Page — real auto-publish (text only, no image yet) needs your
    own LinkedIn Marketing API app, which LinkedIn reviews separately.
  • X — no auto-publish here (X's API pricing changes often and isn't
    worth building against blind). Stays a draft caption.
  • Images — generated locally with Pillow (free, no paid image-generation
    API), from your business name/keyword/colors. Not a stock photo or
    AI-drawn scene — a clean branded text/color card, good enough for a
    social post header image.

USAGE (mirrors remediate.py):
  python -m seo_tool.content_updates                # dry run: see drafts
  python -m seo_tool.content_updates --apply         # publish approved plan
"""
from __future__ import annotations
import argparse
import io
import json
import random
import re
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import urljoin

import requests
import yaml
from bs4 import BeautifulSoup

APP_DIR = Path(__file__).parent.parent
CU_CFG = APP_DIR / "content_updates.yaml"
STATE_FILE = APP_DIR / "content_state.json"

DEFAULT_CFG = """\
# Content update config — Google Business Profile + social drafts.
# See the top of seo_tool/content_updates.py for what each part actually
# does today (GBP + Facebook can auto-publish; others draft-only).
enabled: false

google_business_profile:
  enabled: false
  location_id: ""          # accounts/{account_id}/locations/{location_id}
  client_id: ""
  client_secret: ""
  refresh_token: ""        # from a one-time OAuth consent (see README)
  update_description: true
  post_new_update: true
  post_frequency_days: 7

social:
  facebook:
    enabled: false
    page_id: ""
    page_access_token: ""
  instagram:
    enabled: false
    ig_user_id: ""            # Instagram Business account ID
    access_token: ""          # needs Meta's content-publishing app review
  linkedin:
    enabled: false
    organization_urn: ""      # e.g. urn:li:organization:12345
    access_token: ""          # needs LinkedIn Marketing API app review
  draft_only:               # generates copy-paste-ready captions, no API
  - instagram
  - linkedin
  - x

blog:
  enabled: false
  frequency_days: 14
  # Publishes as a new page (e.g. /blog/your-slug.html) over the same
  # FTP/SFTP connection configured in remediate.yaml — no separate CMS
  # login needed. Content is template-built from your keywords/locations,
  # not invented facts, so review the draft before approving.

content_rules:
  tone: professional        # professional | friendly | bold
  max_gbp_post_chars: 1500
  max_caption_chars:
    instagram: 2200
    linkedin: 3000
    x: 280
"""

TONE_OPENERS = {
    "professional": ["Now available:", "Update:", "New this week:"],
    "friendly": ["Guess what \u2014", "Hey there!", "Quick update from us:"],
    "bold": ["Big news.", "This just changed.", "Don't miss this:"],
}


def load_cfg() -> dict:
    if not CU_CFG.exists():
        CU_CFG.write_text(DEFAULT_CFG, encoding="utf-8")
        return yaml.safe_load(DEFAULT_CFG)
    return yaml.safe_load(CU_CFG.read_text(encoding="utf-8"))


def _load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text(encoding="utf-8"))
        except Exception:
            return {}
    return {}


def _save_state(state: dict):
    STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")


# ----------------------------------------------------------------------
# Draft generation — pure text, no network. Safe to call in dry-run mode.
# ----------------------------------------------------------------------

# ----------------------------------------------------------------------
# Site shell — pulls the real header/footer/CSS off the live homepage so
# generated blog pages look like they belong on the site, not a bare page.
# ----------------------------------------------------------------------

def fetch_site_shell(site_url: str) -> dict:
    """Best-effort: works well for typical HTML/Bootstrap sites (a <header>
    or <nav>, a <footer>, and normal <link rel=stylesheet> tags). Falls
    back to empty strings for anything it can't confidently find — the
    caller then uses a plain fallback template instead."""
    resp = requests.get(site_url, timeout=15,
                        headers={"User-Agent": "Mozilla/5.0 (SEO-AI-Optimizer)"})
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    # Rewrite every href/src to absolute, so copied tags still resolve
    # correctly from a page that lives at a different path (e.g. /blog/x).
    for tag in soup.find_all(["link", "script", "img"]):
        for attr in ("href", "src"):
            if tag.get(attr):
                tag[attr] = urljoin(site_url, tag[attr])

    head_bits = []
    for tag in soup.find_all(["link", "style"]):
        if tag.name == "style":
            head_bits.append(str(tag))
        else:
            rel = " ".join(tag.get("rel") or []).lower()
            if "stylesheet" in rel or "icon" in rel:
                head_bits.append(str(tag))

    header = soup.find("header") or soup.find("nav")
    footer = soup.find("footer")

    # Try to reuse the site's own main-content wrapper class (e.g. its
    # actual "container"/"wrapper" div) instead of guessing a Bootstrap
    # default — falls back to "container" if nothing obvious is found.
    content_class = "container"
    candidate = soup.find(lambda t: t.name in ("div", "section", "main")
                          and t.get("class")
                          and any(kw in " ".join(t.get("class")).lower()
                                 for kw in ("container", "wrapper", "content", "main")))
    if candidate:
        content_class = " ".join(candidate.get("class"))

    body_scripts = []
    for tag in soup.find_all("script", src=True):
        src = tag.get("src", "").lower()
        if any(k in src for k in ("bootstrap", "jquery")):
            body_scripts.append(str(tag))

    return {
        "head_extra": "\n".join(head_bits),
        "header_html": str(header) if header else "",
        "footer_html": str(footer) if footer else "",
        "body_scripts": "\n".join(body_scripts),
        "content_class": content_class,
        "ok": bool(header or footer or head_bits),
    }


def _get_site_shell(site: dict) -> dict:
    """Cached per site for a day so we're not re-fetching the homepage on
    every single blog post — reused across a scheduled run's lifetime."""
    state = _load_state()
    site_state = state.setdefault(site["name"], {})
    cached = site_state.get("shell")
    cached_at = site_state.get("shell_at")
    if cached and cached_at and \
       (datetime.now() - datetime.fromisoformat(cached_at)) < timedelta(days=1):
        return cached
    try:
        shell = fetch_site_shell(site["url"])
    except Exception:
        shell = {"head_extra": "", "header_html": "", "footer_html": "",
                 "body_scripts": "", "ok": False}
    site_state["shell"] = shell
    site_state["shell_at"] = datetime.now().isoformat()
    _save_state(state)
    return shell


# ----------------------------------------------------------------------
# Free, local image generation (Pillow) — a branded card, not a stock
# photo or AI-drawn scene, but real and free with no API key needed.
# ----------------------------------------------------------------------

def _load_font(size: int, bold: bool = False):
    from PIL import ImageFont
    candidates = [
        f"/usr/share/fonts/truetype/dejavu/DejaVuSans{'-Bold' if bold else ''}.ttf",
        f"C:\\Windows\\Fonts\\{'arialbd' if bold else 'arial'}.ttf",
        f"/System/Library/Fonts/Supplemental/Arial{' Bold' if bold else ''}.ttf",
        "/Library/Fonts/Arial.ttf",
    ]
    for path in candidates:
        try:
            return ImageFont.truetype(path, size)
        except Exception:
            continue
    return ImageFont.load_default()


def generate_post_image(site: dict, topic: str, loc: str) -> bytes:
    """1200x630 (standard social link-preview size). Business name + one
    keyword + the site URL on a branded color card. Free, local, no API."""
    from PIL import Image, ImageDraw

    W, H = 1200, 630
    ink, gold, cream, dim = (18, 21, 26), (201, 163, 90), (234, 230, 220), (140, 144, 153)
    img = Image.new("RGB", (W, H), ink)
    draw = ImageDraw.Draw(img)
    draw.rectangle([0, 0, W, 16], fill=gold)
    draw.rectangle([0, H - 16, W, H], fill=gold)

    f_name = _load_font(58, bold=True)
    f_kw = _load_font(30)
    f_url = _load_font(24)

    def wrap(text, font, max_w):
        words, lines, cur = text.split(), [], ""
        for w in words:
            trial = (cur + " " + w).strip()
            if draw.textlength(trial, font=font) <= max_w:
                cur = trial
            else:
                lines.append(cur)
                cur = w
        if cur:
            lines.append(cur)
        return lines

    name_lines = wrap(site["name"], f_name, W - 120)
    y = 200
    for line in name_lines[:2]:
        draw.text((60, y), line, font=f_name, fill=cream)
        y += 70

    draw.text((60, y + 20), f"{topic.title()} in {loc}", font=f_kw, fill=gold)
    draw.text((60, H - 60), site["url"], font=f_url, fill=dim)

    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


def _publish_image_to_site(site: dict, image_bytes: bytes, filename: str) -> str | None:
    """Uploads the generated image to the client's own site over the same
    FTP/SFTP connection as auto-fixes, so platforms that require a public
    image URL (Instagram, GBP) have something real to point at. Returns
    None if there's no working connection configured yet."""
    from . import remediate
    try:
        rcfg = remediate.load_cfg()
        conn = remediate.connect(rcfg)
        conn.write(f"social-images/{filename}", image_bytes)
        return site["url"].rstrip("/") + f"/social-images/{filename}"
    except Exception:
        return None


def fetch_recent_news(topic: str, location: str, timeout: int = 10) -> list[dict]:
    """Real, current, dated news via Google News RSS — free, no API key,
    no signup. Returns up to 3 real recent items with a title, source,
    link, and publish date. Never fabricates: if the fetch fails or finds
    nothing relevant, returns an empty list and the blog post simply skips
    that section rather than inventing a 'trend'."""
    import xml.etree.ElementTree as ET
    from urllib.parse import quote

    query = quote(f"{topic} {location}".strip())
    url = f"https://news.google.com/rss/search?q={query}&hl=en-IN&gl=IN&ceid=IN:en"
    try:
        resp = requests.get(url, timeout=timeout,
                            headers={"User-Agent": "Mozilla/5.0 (SEO-AI-Optimizer)"})
        resp.raise_for_status()
        root = ET.fromstring(resp.content)
    except Exception:
        return []

    items = []
    for item in root.findall(".//item")[:3]:
        title = (item.findtext("title") or "").strip()
        link = (item.findtext("link") or "").strip()
        pub = (item.findtext("pubDate") or "").strip()
        source_el = item.find("source")
        source = (source_el.text if source_el is not None else "").strip()
        # Google News titles are formatted "Headline - Source"; strip the
        # trailing source since we already have it separately.
        if source and title.endswith(f" - {source}"):
            title = title[: -(len(source) + 3)]
        if title and link:
            items.append({"title": title, "link": link,
                         "source": source or "a news source",
                         "published": pub[:16]})  # e.g. "Wed, 16 Jul 2026"
    return items


def _news_section(topic: str, loc: str) -> str:
    """Builds an 'In the news' section from real, dated, linked stories.
    Only ever quotes the headline itself (short, factual, not the article
    body) and links out to the real source — never reproduces article text."""
    items = fetch_recent_news(topic, loc)
    if not items:
        return ""
    lines = ["<h2>What's happening right now</h2>",
            f"<p>A few recent, real developments related to {topic} "
            f"worth knowing about:</p><ul>"]
    for it in items:
        date_bit = f" ({it['published']})" if it["published"] else ""
        lines.append(f'<li><a href="{it["link"]}" rel="noopener" target="_blank">'
                    f'{it["title"]}</a> \u2014 {it["source"]}{date_bit}</li>')
    lines.append("</ul>")
    return "\n".join(lines)


def generate_blog_post(site: dict, rules: dict, seed: int) -> dict:
    """Template-built, not invented: every sentence is assembled from your
    own keywords/locations, so nothing false gets published about you.
    Aims for a genuinely substantial article (multiple sections, a
    checklist, ~1500+ characters) rather than a thin stub."""
    kws = site.get("target_keywords", []) or ["our services"]
    locs = site.get("target_locations", []) or ["your area"]
    r = random.Random(seed)
    topic = kws[r.randrange(len(kws))]
    loc = locs[r.randrange(len(locs))]
    other_kws = [k for k in kws if k != topic]
    other_locs = [l for l in locs if l != loc]

    slug = re.sub(r"[^a-z0-9]+", "-", f"{topic}-{loc}".lower()).strip("-")
    title = f"{topic.title()} in {loc}: A Practical Guide — {site['name']}"
    meta = (f"A practical guide to {topic} in {loc}, from {site['name']}. "
            f"{site['url']}")[:158]

    other_kw_list = ", ".join(other_kws) if other_kws else ""
    other_loc_list = ", ".join(other_locs) if other_locs else ""

    intro = (f"<p>Looking into {topic} in {loc}? A little groundwork before you commit to anyone "
             f"saves time later and avoids surprises. This guide walks through what actually "
             f"matters when comparing options, and where {site['name']} fits into the picture.</p>"
             f"<p>None of this needs to be complicated. The goal here is simply to help you ask "
             f"better questions before you commit to anyone \u2014 whether that ends up being "
             f"{site['name']} or someone else.</p>")

    checklist = (
        f"<h2>What to check before you decide</h2>"
        f"<p>Not every provider approaches {topic} the same way. A short checklist worth going "
        f"through first:</p>"
        f"<ul>"
        f"<li><strong>Local experience</strong> \u2014 someone already familiar with {loc} "
        f"tends to move faster and run into fewer surprises along the way.</li>"
        f"<li><strong>Clear pricing</strong> \u2014 get a written quote before any work starts, "
        f"not after. Ask specifically what's included and what would count as extra.</li>"
        f"<li><strong>Responsiveness</strong> \u2014 how quickly someone replies to a first "
        f"enquiry is often a preview of how they'll treat you as an ongoing customer.</li>"
        f"<li><strong>References or reviews</strong> \u2014 a quick search, or simply asking "
        f"directly, goes a long way before you commit.</li>"
        f"<li><strong>Clarity on scope</strong> \u2014 a good provider will tell you plainly "
        f"what's realistic for {topic}, rather than promising everything at once.</li>"
        f"</ul>")

    local_section = (
        f"<h2>Why {loc} specifically matters</h2>"
        f"<p>Every area has its own quirks \u2014 access, timing, local rules, or just how "
        f"quickly things typically need to move. Working with a team already used to {loc}, "
        f"rather than one applying a generic playbook everywhere, tends to make the whole "
        f"process smoother from the first conversation through to completion.</p>")

    beyond_section = ""
    if other_kw_list:
        loc_bit = f" and {other_loc_list}" if other_loc_list else ""
        beyond_section = (
            f"<h2>Beyond {topic}</h2>"
            f"<p>{site['name']} also covers {other_kw_list} for customers in {loc}{loc_bit}. "
            f"If your needs span more than one of these, it's usually worth asking about all "
            f"of them in the same conversation rather than starting separate enquiries \u2014 "
            f"it tends to save time on both sides.</p>")
    else:
        beyond_section = (
            f"<h2>Questions worth asking upfront</h2>"
            f"<p>Beyond {topic} itself, it's worth asking any provider in {loc} how they "
            f"handle things when a job turns out bigger or smaller than first expected. "
            f"A straightforward answer here \u2014 rather than a vague one \u2014 is usually "
            f"a good sign of how the rest of the relationship will go.</p>")

    faq_section = (
        f"<h2>A couple of common questions</h2>"
        f"<p><strong>How do I get a straight answer quickly?</strong><br>"
        f"A direct conversation is usually faster than back-and-forth over a contact form. "
        f"Describe exactly what you need for {topic} in {loc}, and a specific answer is "
        f"normally possible in a single reply.</p>"
        f"<p><strong>What should I have ready before reaching out?</strong><br>"
        f"A rough idea of timing, location details, and what you're trying to achieve. The "
        f"more specific the starting question, the more useful the first answer will be.</p>")

    closing = (
        f"<h2>Getting started</h2>"
        f"<p>Reach out to {site['name']} via <a href=\"{site['url']}\">{site['url']}</a> and "
        f"describe what you're after \u2014 most questions about {topic} in {loc} can be "
        f"answered in a single reply.</p>")

    timeline_section = (
        f"<h2>What a typical timeline looks like</h2>"
        f"<p>Once you've had that first conversation about {topic}, the next steps are "
        f"usually straightforward: a short scoping discussion to confirm exactly what "
        f"you need, a clear quote in writing before anything starts, then the actual "
        f"work itself. Ask upfront how long each stage typically takes for {topic} in "
        f"{loc} specifically \u2014 a provider who's done this many times before should "
        f"be able to give you a real answer, not a vague estimate.</p>"
        f"<p>It's also worth asking what happens if your needs change partway through. "
        f"Flexibility here is often a better signal of a good working relationship than "
        f"the initial price quote alone.</p>")

    news_section = _news_section(topic, loc)

    body = (f"<h1>{title}</h1>\n{intro}\n{checklist}\n{local_section}\n"
           f"{news_section}\n{beyond_section}\n{timeline_section}\n"
           f"{faq_section}\n{closing}\n")

    return {"slug": slug, "title": title, "meta_description": meta, "body_html": body}


def _blog_page_html(site: dict, post: dict, shell: dict) -> str:
    url = site["url"].rstrip("/") + f"/blog/{post['slug']}.html"
    head_extra = shell.get("head_extra", "")
    header_html = shell.get("header_html", "")
    footer_html = shell.get("footer_html", "")
    body_scripts = shell.get("body_scripts", "")

    if not shell.get("ok"):
        # Fallback: couldn't confidently find a header/footer/stylesheet on
        # the live homepage — ship a clean, simple, self-contained page
        # instead of guessing wrong and shipping something broken.
        head_extra = ("<style>body{font-family:-apple-system,Segoe UI,Arial,"
                     "sans-serif;max-width:760px;margin:0 auto;padding:2rem 1.2rem;"
                     "line-height:1.6;color:#1c1f24} h1{font-size:1.8rem}</style>")
        header_html = footer_html = body_scripts = ""

    return (f"<!DOCTYPE html>\n<html lang=\"en\"><head>\n"
           f"<meta charset=\"utf-8\">\n"
           f"<title>{post['title']}</title>\n"
           f"<meta name=\"description\" content=\"{post['meta_description']}\">\n"
           f"<link rel=\"canonical\" href=\"{url}\">\n"
           f"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
           f"{head_extra}\n</head><body>\n"
           f"{header_html}\n"
           f"<main class=\"{shell.get('content_class', 'container')} py-5\">\n"
           f"{post['body_html']}</main>\n"
           f"{footer_html}\n{body_scripts}\n</body></html>\n")


def generate_gbp_description(site: dict, rules: dict) -> str:
    kws = site.get("target_keywords", [])[:4]
    locs = site.get("target_locations", ["your area"])
    kw_phrase = ", ".join(kws) if kws else "quality service"
    desc = (f"{site['name']} serves {', '.join(locs)} with {kw_phrase}. "
            f"We focus on reliable, locally-trusted service \u2014 "
            f"visit {site['url']} to learn more or get in touch today.")
    return desc[:750]  # GBP description hard limit


def generate_gbp_post(site: dict, rules: dict, seed: int) -> dict:
    tone = rules.get("tone", "professional")
    opener = random.Random(seed).choice(TONE_OPENERS.get(tone, TONE_OPENERS["professional"]))
    kws = site.get("target_keywords", [])
    kw = kws[random.Random(seed + 1).randrange(len(kws))] if kws else "our services"
    loc = (site.get("target_locations") or ["your area"])[0]
    text = (f"{opener} {site['name']} is your go-to for {kw} in {loc}. "
            f"{site['url']}")
    return {"summary": text[:rules.get("max_gbp_post_chars", 1500)],
            "cta_type": "LEARN_MORE", "cta_url": site["url"]}


def generate_social_caption(platform: str, site: dict, rules: dict, seed: int) -> str:
    tone = rules.get("tone", "professional")
    opener = random.Random(seed + hash(platform) % 100).choice(
        TONE_OPENERS.get(tone, TONE_OPENERS["professional"]))
    kws = site.get("target_keywords", [])
    kw_phrase = ", ".join(kws[:3]) if kws else "what we do"
    loc = (site.get("target_locations") or ["your area"])[0]
    hashtags = ""
    if platform in ("instagram", "x"):
        tags = ["#" + k.replace(" ", "") for k in kws[:3]]
        tags.append("#" + loc.replace(" ", ""))
        hashtags = " " + " ".join(tags)
    text = f"{opener} {site['name']} \u2014 {kw_phrase} in {loc}. {site['url']}{hashtags}"
    limit = rules.get("max_caption_chars", {}).get(platform, 2000)
    return text[:limit]


# ----------------------------------------------------------------------
# Propose (dry run) — builds the same {summary, diff} shape remediate.py
# uses, so app.py's approval flow works unchanged for either kind.
# ----------------------------------------------------------------------

def propose(site: dict) -> dict:
    cfg = load_cfg()
    if not cfg.get("enabled"):
        return {"summary": {}, "diff": "",
                "note": "content_updates.yaml has enabled: false"}
    rules = cfg.get("content_rules", {})
    state = _load_state()
    site_state = state.setdefault(site["name"], {})
    seed = int(datetime.now().strftime("%Y%j"))  # changes daily, stable within a day

    summary: dict[str, list[str]] = {}
    diff_parts = []

    gbp = cfg.get("google_business_profile", {})
    if gbp.get("enabled"):
        if gbp.get("update_description"):
            new_desc = generate_gbp_description(site, rules)
            old_desc = site_state.get("gbp_description", "")
            if new_desc != old_desc:
                summary["Google Business Profile \u2192 description"] = ["update"]
                diff_parts.append(f"--- current description ---\n{old_desc or '(none on file)'}\n"
                                  f"--- proposed description ---\n{new_desc}")
        if gbp.get("post_new_update"):
            last = site_state.get("gbp_last_post_at")
            due = True
            if last:
                due = (datetime.now() - datetime.fromisoformat(last)) >= \
                      timedelta(days=gbp.get("post_frequency_days", 7))
            if due:
                post = generate_gbp_post(site, rules, seed)
                summary["Google Business Profile \u2192 new post"] = ["publish"]
                diff_parts.append(f"--- proposed GBP post ---\n{post['summary']}\n"
                                  f"CTA: {post['cta_type']} -> {post['cta_url']}")

    social = cfg.get("social", {})
    fb = social.get("facebook", {})
    if fb.get("enabled"):
        cap = generate_social_caption("facebook", site, rules, seed)
        summary["Facebook Page \u2192 new post + image"] = ["publish"]
        diff_parts.append(f"--- proposed Facebook post (with a generated image) ---\n{cap}")

    ig = social.get("instagram", {})
    if ig.get("enabled"):
        cap = generate_social_caption("instagram", site, rules, seed)
        summary["Instagram \u2192 new post + image"] = ["publish"]
        diff_parts.append(f"--- proposed Instagram post (with a generated image) ---\n{cap}")

    li = social.get("linkedin", {})
    if li.get("enabled"):
        cap = generate_social_caption("linkedin", site, rules, seed)
        summary["LinkedIn Page \u2192 new post"] = ["publish"]
        diff_parts.append(f"--- proposed LinkedIn post (text only, no image yet) ---\n{cap}")

    real_publish = {p for p, c in (("facebook", fb), ("instagram", ig), ("linkedin", li))
                    if c.get("enabled")}
    draft_freq_days = cfg.get("content_rules", {}).get("draft_caption_frequency_days", 7)
    for platform in social.get("draft_only", []):
        if platform in real_publish:
            continue  # already publishing for real above, don't also draft it
        last = site_state.get(f"draft_{platform}_at")
        due = True
        if last:
            due = (datetime.now() - datetime.fromisoformat(last)) >= \
                  timedelta(days=draft_freq_days)
        if not due:
            continue  # drafted recently — don't re-send the same request
        cap = generate_social_caption(platform, site, rules, seed)
        summary[f"{platform.capitalize()} (draft only \u2014 paste manually)"] = ["draft"]
        diff_parts.append(f"--- {platform} caption (copy/paste, no auto-publish) ---\n{cap}")
        site_state[f"draft_{platform}_at"] = datetime.now().isoformat()

    blog = cfg.get("blog", {})
    if blog.get("enabled"):
        last = site_state.get("blog_last_at")
        due = True
        if last:
            due = (datetime.now() - datetime.fromisoformat(last)) >= \
                  timedelta(days=blog.get("frequency_days", 14))
        if due:
            post = generate_blog_post(site, rules, seed)
            summary["New blog post \u2192 published via your FTP/SFTP connection"] = ["publish"]
            diff_parts.append(
                f"--- proposed blog post ---\nURL: /blog/{post['slug']}.html\n"
                f"Title: {post['title']}\nMeta: {post['meta_description']}\n\n"
                f"{post['body_html']}")

    _save_state(state)
    return {"summary": summary, "diff": "\n\n".join(diff_parts)}


# ----------------------------------------------------------------------
# Apply — only called after a human clicks Approve.
# ----------------------------------------------------------------------

def _gbp_access_token(gbp_cfg: dict) -> str:
    resp = requests.post("https://oauth2.googleapis.com/token", data={
        "client_id": gbp_cfg["client_id"],
        "client_secret": gbp_cfg["client_secret"],
        "refresh_token": gbp_cfg["refresh_token"],
        "grant_type": "refresh_token",
    }, timeout=20)
    resp.raise_for_status()
    return resp.json()["access_token"]


def _pick_topic(site: dict, seed: int) -> str:
    kws = site.get("target_keywords", []) or ["our services"]
    return kws[random.Random(seed).randrange(len(kws))]


def _publish_gbp(site: dict, cfg: dict, results: dict):
    gbp = cfg["google_business_profile"]
    rules = cfg.get("content_rules", {})
    state = _load_state()
    site_state = state.setdefault(site["name"], {})
    token = _gbp_access_token(gbp)
    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    loc = gbp["location_id"]

    if gbp.get("update_description"):
        desc = generate_gbp_description(site, rules)
        r = requests.patch(
            f"https://mybusinessbusinessinformation.googleapis.com/v1/{loc}"
            f"?updateMask=profile.description",
            headers=headers, json={"profile": {"description": desc}}, timeout=20)
        if r.ok:
            site_state["gbp_description"] = desc
            results["gbp_description"] = "updated"
        else:
            results["gbp_description"] = f"FAILED {r.status_code}: {r.text[:300]}"

    if gbp.get("post_new_update"):
        seed = int(datetime.now().strftime("%Y%j"))
        post = generate_gbp_post(site, rules, seed)
        loc_name = (site.get("target_locations") or ["your area"])[0]
        body = {
            "languageCode": "en-US",
            "summary": post["summary"],
            "callToAction": {"actionType": post["cta_type"], "url": post["cta_url"]},
            "topicType": "STANDARD",
        }
        img_url = _publish_image_to_site(
            site, generate_post_image(site, _pick_topic(site, seed), loc_name),
            f"gbp-{seed}.png")
        if img_url:
            body["media"] = [{"mediaFormat": "PHOTO", "sourceUrl": img_url}]
        r = requests.post(f"https://mybusiness.googleapis.com/v4/{loc}/localPosts",
                          headers=headers, json=body, timeout=20)
        if r.ok:
            site_state["gbp_last_post_at"] = datetime.now().isoformat()
            results["gbp_post"] = "published" + (" with image" if img_url else " (no image — FTP not configured)")
        else:
            results["gbp_post"] = f"FAILED {r.status_code}: {r.text[:300]}"

    _save_state(state)


def _publish_facebook(site: dict, cfg: dict, results: dict):
    fb = cfg["social"]["facebook"]
    rules = cfg.get("content_rules", {})
    seed = int(datetime.now().strftime("%Y%j"))
    caption = generate_social_caption("facebook", site, rules, seed)
    loc_name = (site.get("target_locations") or ["your area"])[0]
    image_bytes = generate_post_image(site, _pick_topic(site, seed), loc_name)
    r = requests.post(
        f"https://graph.facebook.com/v19.0/{fb['page_id']}/photos",
        data={"message": caption, "access_token": fb["page_access_token"]},
        files={"source": ("post.png", image_bytes, "image/png")}, timeout=30)
    results["facebook_post"] = ("published with image" if r.ok
                                else f"FAILED {r.status_code}: {r.text[:300]}")


def _publish_instagram(site: dict, cfg: dict, results: dict):
    ig = cfg["social"]["instagram"]
    rules = cfg.get("content_rules", {})
    seed = int(datetime.now().strftime("%Y%j"))
    caption = generate_social_caption("instagram", site, rules, seed)
    loc_name = (site.get("target_locations") or ["your area"])[0]
    image_bytes = generate_post_image(site, _pick_topic(site, seed), loc_name)
    img_url = _publish_image_to_site(site, image_bytes, f"ig-{seed}.png")
    if not img_url:
        results["instagram_post"] = ("FAILED: couldn't host the image — set up "
                                     "your Auto-fix FTP/SFTP connection first, "
                                     "Instagram requires a public image URL")
        return
    headers = {"Authorization": f"Bearer {ig['access_token']}"}
    r1 = requests.post(f"https://graph.facebook.com/v19.0/{ig['ig_user_id']}/media",
                       data={"image_url": img_url, "caption": caption}, headers=headers, timeout=30)
    if not r1.ok:
        results["instagram_post"] = f"FAILED (create) {r1.status_code}: {r1.text[:300]}"
        return
    creation_id = r1.json().get("id")
    r2 = requests.post(f"https://graph.facebook.com/v19.0/{ig['ig_user_id']}/media_publish",
                       data={"creation_id": creation_id}, headers=headers, timeout=30)
    results["instagram_post"] = ("published with image" if r2.ok
                                 else f"FAILED (publish) {r2.status_code}: {r2.text[:300]}")


def _publish_linkedin(site: dict, cfg: dict, results: dict):
    li = cfg["social"]["linkedin"]
    rules = cfg.get("content_rules", {})
    seed = int(datetime.now().strftime("%Y%j"))
    caption = generate_social_caption("linkedin", site, rules, seed)
    headers = {"Authorization": f"Bearer {li['access_token']}",
              "Content-Type": "application/json",
              "X-Restli-Protocol-Version": "2.0.0"}
    body = {
        "author": li["organization_urn"],
        "commentary": caption,
        "visibility": "PUBLIC",
        "distribution": {"feedDistribution": "MAIN_FEED"},
        "lifecycleState": "PUBLISHED",
    }
    r = requests.post("https://api.linkedin.com/rest/posts", headers=headers,
                      json=body, timeout=30)
    results["linkedin_post"] = ("published (text only)" if r.ok
                                else f"FAILED {r.status_code}: {r.text[:300]}")


def _publish_blog(site: dict, cfg: dict, results: dict):
    from . import remediate
    rules = cfg.get("content_rules", {})
    state = _load_state()
    site_state = state.setdefault(site["name"], {})
    seed = int(datetime.now().strftime("%Y%j"))
    post = generate_blog_post(site, rules, seed)
    shell = _get_site_shell(site)
    html = _blog_page_html(site, post, shell)
    rcfg = remediate.load_cfg()
    conn = remediate.connect(rcfg)
    conn.write(f"blog/{post['slug']}.html", html.encode())
    site_state["blog_last_at"] = datetime.now().isoformat()
    site_state.setdefault("blog_posts", []).append(post["slug"])
    _save_state(state)
    styled = "styled to match your site" if shell.get("ok") else "plain fallback style"
    results["blog_post"] = f"published /blog/{post['slug']}.html ({styled})"


def apply(site: dict) -> dict:
    cfg = load_cfg()
    results: dict[str, str] = {}
    if cfg.get("google_business_profile", {}).get("enabled"):
        try:
            _publish_gbp(site, cfg, results)
        except Exception as e:
            results["gbp"] = f"FAILED: {e}"
    if cfg.get("social", {}).get("facebook", {}).get("enabled"):
        try:
            _publish_facebook(site, cfg, results)
        except Exception as e:
            results["facebook"] = f"FAILED: {e}"
    if cfg.get("social", {}).get("instagram", {}).get("enabled"):
        try:
            _publish_instagram(site, cfg, results)
        except Exception as e:
            results["instagram"] = f"FAILED: {e}"
    if cfg.get("social", {}).get("linkedin", {}).get("enabled"):
        try:
            _publish_linkedin(site, cfg, results)
        except Exception as e:
            results["linkedin"] = f"FAILED: {e}"
    if cfg.get("blog", {}).get("enabled"):
        try:
            _publish_blog(site, cfg, results)
        except Exception as e:
            results["blog"] = f"FAILED: {e}"
    draft_only = cfg.get("social", {}).get("draft_only", [])
    if draft_only:
        results["manual_paste_needed"] = (
            f"{', '.join(draft_only)} \u2014 use the caption text from the "
            f"approval email/page, no API call made")
    return results


def main():
    import yaml as _y  # noqa: local import for the CLI-only path
    ap = argparse.ArgumentParser(description="Draft/publish GBP + social content.")
    ap.add_argument("--apply", action="store_true")
    ap.add_argument("--site", default=None, help="site name from config.yaml "
                    "(defaults to the first site)")
    args = ap.parse_args()
    main_cfg = _y.safe_load((APP_DIR / "config.yaml").read_text(encoding="utf-8"))
    site = main_cfg["sites"][0]
    if args.site:
        site = next(s for s in main_cfg["sites"] if s["name"] == args.site)
    if not args.apply:
        plan = propose(site)
        print(plan.get("note") or plan["diff"] or "Nothing due to propose.")
    else:
        print(json.dumps(apply(site), indent=2))


if __name__ == "__main__":
    main()
