"""Remediation engine — executes audit recommendations against your hosting.

SAFETY MODEL (read this):
  • DRY-RUN BY DEFAULT. Running without --apply only downloads your site,
    computes every fix, and writes a diff report. Nothing is uploaded.
  • FULL BACKUP FIRST. Before any --apply, every file that will change is
    saved to backups/<timestamp>/. --rollback restores it.
  • WHITELIST ONLY. Only deterministic fixes run (canonical, OG, viewport,
    meta description injection, JSON-LD schema, llms.txt, robots.txt,
    alt text from an approved CSV). It never rewrites your content,
    headings, or layout.
  • VERIFY AFTER. Re-run your audit after applying; the change feed shows
    exactly what improved.

CONNECTIONS (choose one in remediate.yaml):
  local:  a folder on this computer (download your site via your host's
          File Manager / backup tool first — safest way to start)
  ftp:    classic FTP/FTPS — works with virtually every cPanel/shared host
          (find credentials in your hosting panel under "FTP Accounts")
  sftp:   requires `pip install paramiko`

USAGE:
  python -m seo_tool.remediate                 # dry run: see the diff report
  python -m seo_tool.remediate --apply         # backup, then apply fixes
  python -m seo_tool.remediate --rollback backups/20260710_101500
"""
from __future__ import annotations
import argparse
import datetime
import difflib
import io
import json
import re
import shutil
import socket
from ftplib import FTP, FTP_TLS
from pathlib import Path
from urllib.parse import urljoin

import yaml
import requests

from . import geo
from . import business_facts
from . import indexnow

APP_DIR = Path(__file__).parent.parent
REM_CFG = APP_DIR / "remediate.yaml"
BACKUPS = APP_DIR / "backups"
PAGE_EXT = (".php", ".html", ".htm")

DEFAULT_CFG = """\
# Remediation config — how to reach your site files
connection: local            # local | ftp | sftp
local:
  path: "./site-copy"        # folder containing your downloaded site files
ftp:
  host: "ftp.yourhost.com"
  user: ""
  password: ""               # tip: prefer an FTP account limited to public_html
  use_tls: true              # FTPS if your host supports it (most do)
  remote_dir: "/public_html"
sftp:
  host: ""
  port: 22
  user: ""
  password: ""
  remote_dir: "/public_html"

fixes:                       # every fix can be switched off
  canonical: true
  og_tags: true
  viewport: true
  title: true                 # only fills a MISSING <title>, never rewrites one
  meta_description: true     # only injects when missing; uses page H1/title
  schema_homepage: true      # LocalBusiness/Organization JSON-LD on homepage
  schema_breadcrumb: true    # BreadcrumbList JSON-LD built from the real URL path
  multiple_h1: true          # demotes extra <h1> tags to <h2>, keeps the first
                              # untouched and all content/attributes preserved.
                              # One cosmetic note: if your CSS styles h1/h2 very
                              # differently, a demoted heading may look visually
                              # smaller — turn this off in Settings if that happens
  dead_links: false          # off here on purpose — use the per-link
                              # "Broken links" panel on the dashboard instead,
                              # which lets you decide link by link rather than
                              # all-or-nothing in one bundled approval
  linkify_email: true        # makes an existing visible email address clickable
                              # — safe to leave on, adds no new text
  tel_from_facts: true              # only fires if you filled in a real phone
                                     # number in Business Facts AND it's already
                                     # visible as text on the page
  stats_from_facts: true            # only fires if you filled in a real stat
                                     # sentence in Business Facts
  testimonial_schema_from_facts: true  # only fires if you added real testimonials
                                       # in Business Facts
  author_byline_from_facts: true      # only fires if you added a real name in
                                       # Business Facts
  faq_from_facts: true               # only fires if you added real Q&A in
                                      # Business Facts
  faq_schema_visible: true    # marks up FAQ content already visible on the
                               # page — no data entry needed, nothing invented
  lazy_images: true           # adds loading="lazy" to images (skips the
                               # first/hero image) — a real, safe speed fix
                               # that needs no server config
  cta_bar_from_facts: false   # adds a call/WhatsApp bar above the fold —
                               # only fires with a real phone number in
                               # Business Facts. Off by default since it
                               # changes visible page layout; review once
                               # before leaving it on
  llms_txt: true
  robots_txt: true           # ensures Sitemap line + allows AI crawlers
  sitemap_xml: true          # generates sitemap.xml only if one doesn't exist
  htaccess_speed: true        # adds browser-caching/compression rules to an
                               # EXISTING .htaccess (Apache/cPanel only; never
                               # creates a new .htaccess from scratch, since a
                               # malformed new one could break the whole site)
  alt_text_from_csv: true    # applies approved alt_text_review.csv if present
  alt_text_auto: false       # fills missing alt from filename (dumb but honest);
                              # off by default — turn on once you've checked a
                              # sample of your filenames make sense as alt text

limits:
  max_files: 200             # refuse to touch more (sanity brake)
"""


def _load_crawled_urls() -> set:
    """Reads the list of real page URLs the last audit actually crawled
    over HTTP. Returns an empty set if no audit has run yet (in which
    case remediate falls back to touching every page file it finds, same
    as before)."""
    p = APP_DIR / "reports" / "crawled_urls.json"
    if not p.exists():
        return set()
    try:
        return {u.rstrip("/") for u in json.loads(p.read_text(encoding="utf-8"))}
    except Exception:
        return set()


def _load_broken_urls() -> set:
    """Reads the broken-link list the last audit wrote out. Returns an
    empty set (fixer does nothing) if no audit has run yet."""
    p = APP_DIR / "reports" / "broken_links.json"
    if not p.exists():
        return set()
    try:
        return set(json.loads(p.read_text(encoding="utf-8")))
    except Exception:
        return set()


def load_cfg() -> dict:
    """Create remediate.yaml with safe defaults if missing, then return it."""
    if not REM_CFG.exists():
        REM_CFG.write_text(DEFAULT_CFG, encoding="utf-8")
    return yaml.safe_load(REM_CFG.read_text(encoding="utf-8"))


# ----------------------------------------------------------------------
# Connectors: local folder & FTP.  Each yields/reads/writes page files.
# ----------------------------------------------------------------------

class LocalConn:
    def __init__(self, cfg):
        self.root = Path(cfg["local"]["path"]).resolve()
        if not self.root.exists():
            raise SystemExit(f"Local path {self.root} does not exist. "
                             f"Download your site there first, or use ftp.")

    def list_pages(self):
        return [p.relative_to(self.root).as_posix()
                for p in self.root.rglob("*") if p.suffix.lower() in PAGE_EXT]

    def read(self, rel):
        return (self.root / rel).read_bytes()

    def write(self, rel, data: bytes):
        p = self.root / rel
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_bytes(data)

    def exists(self, rel):
        return (self.root / rel).exists()


class FtpConn:
    def __init__(self, cfg):
        c = cfg["ftp"]
        self.ftp = (FTP_TLS(c["host"], timeout=20) if c.get("use_tls")
                   else FTP(c["host"], timeout=20))
        self.ftp.login(c["user"], c["password"])
        if c.get("use_tls"):
            self.ftp.prot_p()
        self.base = c.get("remote_dir", "/").rstrip("/")

    def list_pages(self):
        pages = []

        def walk(d, depth=0):
            if depth > 4:
                return
            try:
                for name, facts in self.ftp.mlsd(d):
                    if name in (".", ".."):
                        continue
                    full = f"{d}/{name}"
                    if facts.get("type") == "dir":
                        walk(full, depth + 1)
                    elif name.lower().endswith(PAGE_EXT):
                        pages.append(full[len(self.base) + 1:])
            except Exception:
                pass
        walk(self.base)
        return pages

    def read(self, rel):
        buf = io.BytesIO()
        self.ftp.retrbinary(f"RETR {self.base}/{rel}", buf.write)
        return buf.getvalue()

    def write(self, rel, data: bytes):
        if "/" in rel:
            self._ensure_dir(rel.rsplit("/", 1)[0])
        self.ftp.storbinary(f"STOR {self.base}/{rel}", io.BytesIO(data))

    def _ensure_dir(self, reldir):
        parts, path = reldir.split("/"), self.base
        for p in parts:
            path += "/" + p
            try:
                self.ftp.mkd(path)
            except Exception:
                pass  # already exists

    def exists(self, rel):
        try:
            self.ftp.size(f"{self.base}/{rel}")
            return True
        except Exception:
            return False


def connect(cfg):
    kind = cfg.get("connection", "local")
    if kind == "local":
        return LocalConn(cfg)
    if kind == "ftp":
        return FtpConn(cfg)
    if kind == "sftp":
        import paramiko  # optional dependency

        class SftpConn:
            def __init__(s, c):
                sock = socket.create_connection((c["host"], c.get("port", 22)), timeout=20)
                t = paramiko.Transport(sock)
                t.connect(username=c["user"], password=c["password"])
                s.sftp = paramiko.SFTPClient.from_transport(t)
                s.base = c.get("remote_dir", "/").rstrip("/")

            def list_pages(s):
                pages = []

                def walk(d, depth=0):
                    if depth > 4:
                        return
                    for e in s.sftp.listdir_attr(d):
                        full = f"{d}/{e.filename}"
                        import stat as st
                        if st.S_ISDIR(e.st_mode):
                            walk(full, depth + 1)
                        elif e.filename.lower().endswith(PAGE_EXT):
                            pages.append(full[len(s.base) + 1:])
                walk(s.base)
                return pages

            def read(s, rel):
                with s.sftp.open(f"{s.base}/{rel}", "rb") as f:
                    return f.read()

            def write(s, rel, data):
                if "/" in rel:
                    parts, path = rel.rsplit("/", 1)[0].split("/"), s.base
                    for p in parts:
                        path += "/" + p
                        try:
                            s.sftp.mkdir(path)
                        except Exception:
                            pass
                with s.sftp.open(f"{s.base}/{rel}", "wb") as f:
                    f.write(data)

            def exists(s, rel):
                try:
                    s.sftp.stat(f"{s.base}/{rel}")
                    return True
                except Exception:
                    return False
        return SftpConn(cfg["sftp"])
    raise SystemExit(f"Unknown connection type: {kind}")


# ----------------------------------------------------------------------
# Fix functions: each takes (html, ctx) -> (new_html, list_of_fix_names)
# Injection point is right after <head>; all fixes are idempotent
# (running twice changes nothing).
# ----------------------------------------------------------------------

def _inject_into_head(html: str, snippet: str) -> str:
    m = re.search(r"<head[^>]*>", html, re.I)
    if not m:
        return html
    return html[:m.end()] + "\n" + snippet + html[m.end():]


def _page_url(site_url: str, rel: str) -> str:
    rel = "/" + rel.lstrip("/")
    if rel.endswith(("/index.php", "/index.html")):
        rel = rel.rsplit("/", 1)[0] + "/"
    return site_url.rstrip("/") + rel


def fix_canonical(html, ctx):
    if re.search(r'<link[^>]+rel=["\']canonical', html, re.I):
        return html, []
    new = _inject_into_head(html, f'<link rel="canonical" href="{ctx["url"]}">')
    return (new, ["canonical"]) if new != html else (html, [])


def fix_multiple_h1(html, ctx):
    """Multiple <h1> tags on one page confuses search engines about what
    the page is actually about. This never deletes content — it keeps the
    first H1 exactly as-is and demotes every extra one to H2, preserving
    all attributes and text. One cosmetic caveat: if your site's CSS
    styles h1/h2 very differently, a demoted heading may look visually
    smaller — that's why this fixer is off by default until you review
    one result."""
    matches = list(re.finditer(r"<h1(\s[^>]*)?>.*?</h1>", html, re.I | re.S))
    if len(matches) <= 1:
        return html, []
    new_html = html
    offset = 0
    for m in matches[1:]:
        start, end = m.start() + offset, m.end() + offset
        segment = new_html[start:end]
        demoted = re.sub(r"^<h1", "<h2", segment, count=1, flags=re.I)
        demoted = re.sub(r"</h1>\s*$", "</h2>", demoted, count=1, flags=re.I)
        new_html = new_html[:start] + demoted + new_html[end:]
        offset += len(demoted) - len(segment)
    return new_html, [f"multiple_h1 (kept 1, demoted {len(matches) - 1} to H2)"]


def fix_viewport(html, ctx):
    if re.search(r'<meta[^>]+name=["\']viewport', html, re.I):
        return html, []
    new = _inject_into_head(
        html, '<meta name="viewport" content="width=device-width, initial-scale=1">')
    return (new, ["viewport"]) if new != html else (html, [])


def _title_of(html):
    m = re.search(r"<title[^>]*>(.*?)</title>", html, re.I | re.S)
    if m:
        return re.sub(r"\s+", " ", m.group(1)).strip()
    m = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.I | re.S)
    return re.sub(r"<[^>]+>|\s+", " ", m.group(1)).strip() if m else ""


def fix_meta_description(html, ctx):
    if re.search(r'<meta[^>]+name=["\']description', html, re.I):
        return html, []
    t = _title_of(html) or ctx["site"]["name"]
    desc = (f"{t} — {ctx['site']['name']}. Professional service in "
            f"{(ctx['site'].get('target_locations') or ['your area'])[0]}. "
            f"Contact us for a free assessment.")[:158]
    new = _inject_into_head(html, f'<meta name="description" content="{desc}">')
    return (new, ["meta_description"]) if new != html else (html, [])


def fix_og(html, ctx):
    if re.search(r'<meta[^>]+property=["\']og:title', html, re.I):
        return html, []
    t = _title_of(html) or ctx["site"]["name"]
    snippet = (f'<meta property="og:type" content="website">\n'
               f'<meta property="og:site_name" content="{ctx["site"]["name"]}">\n'
               f'<meta property="og:title" content="{t}">\n'
               f'<meta property="og:url" content="{ctx["url"]}">')
    new = _inject_into_head(html, snippet)
    return (new, ["og_tags"]) if new != html else (html, [])


def fix_schema_homepage(html, ctx):
    if not ctx["is_homepage"]:
        return html, []
    if '"@context"' in html and "schema.org" in html:
        return html, []
    schema = geo.generate_org_schema(ctx["site"])
    new = _inject_into_head(
        html, f'<script type="application/ld+json">{schema}</script>')
    return (new, ["schema_homepage"]) if new != html else (html, [])


def fix_dead_links(html, ctx):
    """Removes the <a> wrapper (keeping the visible text) around links that
    point at a page confirmed 404 during the last audit. This never
    deletes content or guesses what a missing page should say — it only
    stops sending visitors and search engines into a dead end. The list of
    what's actually broken comes from your last real crawl, refreshed
    every audit, so it's always based on current facts, not a guess."""
    broken = ctx.get("broken_urls") or set()
    if not broken:
        return html, []
    base = ctx["site"]["url"].rstrip("/")
    count = 0

    def repl(m):
        nonlocal count
        href, inner = m.group(1), m.group(2)
        target = urljoin(base + "/", href).split("#")[0].rstrip("/")
        if target in broken or (target + "/") in broken:
            count += 1
            return inner
        return m.group(0)

    new = re.sub(r'<a\s[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
                repl, html, flags=re.I | re.S)
    return new, ([f"dead_links (unlinked {count})"] if count else [])


def fix_linkify_email(html, ctx):
    """Turns a plain-text email address already visible on the page into a
    working mailto: link. Doesn't add any new text — only makes existing
    text clickable. Always safe: nothing is invented."""
    pattern = re.compile(r'(?<![">:])\b([\w.+-]+@[\w-]+\.[\w.-]+)\b(?!["\'])')
    changed = []

    def repl(m):
        addr = m.group(1)
        changed.append(addr)
        return f'<a href="mailto:{addr}">{addr}</a>'

    # Skip anything already inside a tag or an existing <a>/mailto:
    if 'mailto:' in html:
        already = set(re.findall(r'mailto:([\w.+-]+@[\w-]+\.[\w.-]+)', html))
    else:
        already = set()
    new = pattern.sub(lambda m: m.group(0) if m.group(1) in already else repl(m), html)
    return new, ([f"linkify_email x{len(changed)}"] if changed else [])


def fix_tel_from_facts(html, ctx):
    """Only linkifies a phone number that (a) you've confirmed is real in
    Business Facts, AND (b) already appears as plain text on this exact
    page — so it never invents a phone number's placement, it only makes
    an existing one clickable."""
    phone = (ctx.get("facts") or {}).get("phone", "").strip()
    if not phone or f'tel:{phone}' in html:
        return html, []
    digits_only = re.sub(r"\D", "", phone)
    # Match the number on the page loosely (spaces/dashes may differ from config)
    pattern = re.compile(re.escape(phone[0]) + r"?[\d\s().-]{7,}")
    for m in pattern.finditer(html):
        if re.sub(r"\D", "", m.group(0)) == digits_only:
            new = html[:m.start()] + f'<a href="tel:{phone}">{m.group(0)}</a>' + html[m.end():]
            return new, ["tel_link"]
    return html, []


def fix_stats_from_facts(html, ctx):
    """Inserts your own supplied, real sentence near the top of the main
    content — never generates a number itself."""
    blurb = (ctx.get("facts") or {}).get("stats_blurb", "").strip()
    if not blurb or blurb in html:
        return html, []
    m = re.search(r"(<h1(?:\s[^>]*)?>.*?</h1>)", html, re.I | re.S)
    if not m:
        return html, []
    insert = f'\n<p class="seo-ai-stat">{blurb}</p>\n'
    new = html[:m.end()] + insert + html[m.end():]
    return new, ["stats_blurb"]


def fix_testimonial_schema_from_facts(html, ctx):
    """Review/AggregateRating schema built only from testimonials you
    typed in yourself — you're vouching these are real and already
    visible on your site, which is what keeps this compliant."""
    facts = ctx.get("facts") or {}
    items = facts.get("testimonials") or []
    if not items or '"Review"' in html or "AggregateRating" in html:
        return html, []
    reviews = [{
        "@type": "Review",
        "author": {"@type": "Person", "name": t.get("name", "")},
        "reviewBody": t.get("quote", ""),
        **({"itemReviewed": {"@type": "Organization", "name": t.get("org")}}
           if t.get("org") else {}),
    } for t in items if t.get("quote")]
    if not reviews:
        return html, []
    schema = json.dumps({
        "@context": "https://schema.org", "@type": "Organization",
        "name": ctx["site"]["name"], "review": reviews,
        "aggregateRating": {"@type": "AggregateRating", "ratingValue": "5",
                            "reviewCount": str(len(reviews))},
    })
    new = _inject_into_head(
        html, f'<script type="application/ld+json">{schema}</script>')
    return (new, [f"testimonial_schema x{len(reviews)}"]) if new != html else (html, [])


def fix_author_byline_from_facts(html, ctx):
    """A byline using only the real name/credential you supplied."""
    facts = ctx.get("facts") or {}
    author = facts.get("author") or {}
    name = author.get("name", "").strip()
    if not name or name in html:
        return html, []
    cred = author.get("credential", "").strip()
    byline = f'<p class="seo-ai-byline">By {name}{f", {cred}" if cred else ""}</p>'
    m = re.search(r"(<h1(?:\s[^>]*)?>.*?</h1>)", html, re.I | re.S)
    if not m:
        return html, []
    return html[:m.end()] + "\n" + byline + "\n" + html[m.end():], ["author_byline"]


def fix_faq_from_facts(html, ctx):
    """Publishes a visible FAQ section AND matching FAQPage schema
    together, from real Q&A you supplied — visible content and schema
    always match here, which is the actual requirement, not an
    afterthought."""
    facts = ctx.get("facts") or {}
    items = [q for q in (facts.get("faq") or []) if q.get("q") and q.get("a")]
    if not items or "FAQPage" in html:
        return html, []
    visible = ['<section class="seo-ai-faq"><h2>Frequently Asked Questions</h2>']
    schema_items = []
    for q in items:
        visible.append(f"<h3>{q['q']}</h3><p>{q['a']}</p>")
        schema_items.append({"@type": "Question", "name": q["q"],
                             "acceptedAnswer": {"@type": "Answer", "text": q["a"]}})
    visible.append("</section>")
    schema = json.dumps({"@context": "https://schema.org", "@type": "FAQPage",
                        "mainEntity": schema_items})
    new = re.sub(r"</body>",
                f'{"".join(visible)}\n<script type="application/ld+json">'
                f'{schema}</script>\n</body>', html, count=1, flags=re.I)
    return (new, [f"faq x{len(items)}"]) if new != html else (html, [])


def fix_lazy_images(html, ctx):
    """Adds loading="lazy" to <img> tags, skipping the FIRST image on the
    page (usually the above-the-fold hero, where lazy-loading would slow
    the Largest Contentful Paint instead of helping). Purely an HTML
    attribute — no server config needed, unlike most 'speed' fixes."""
    seen = {"n": 0}
    changed = []

    def repl(m):
        tag = m.group(0)
        seen["n"] += 1
        if seen["n"] == 1 or "loading=" in tag.lower():
            return tag
        changed.append(1)
        return tag[:-1] + ' loading="lazy">'

    new = re.sub(r"<img\b[^>]*>", repl, html, flags=re.I)
    return new, ([f"lazy_images x{len(changed)}"] if changed else [])


def _visible_faqs(html):
    """Finds FAQs already VISIBLE on the page: a heading ending in '?'
    followed by paragraph text. Only ever emits schema for Q&As a human
    can already see — no data entry needed, and nothing invented."""
    from bs4 import BeautifulSoup
    soup = BeautifulSoup(html, "html.parser")
    faqs = []
    for h in soup.find_all(["h2", "h3"]):
        q = h.get_text(" ", strip=True)
        if not q.endswith("?"):
            continue
        answer, node = [], h.next_sibling
        while node is not None:
            name = getattr(node, "name", None)
            if name in ("h1", "h2", "h3"):
                break
            if name in ("p", "ul", "ol", "div"):
                txt = node.get_text(" ", strip=True)
                if txt:
                    answer.append(txt)
                if sum(len(a) for a in answer) > 400:
                    break
            node = node.next_sibling
        a = " ".join(answer).strip()
        if len(a) >= 40:
            faqs.append({"q": q, "a": a[:900]})
    return faqs[:10]


def fix_faq_schema_from_visible(html, ctx):
    """Emits FAQPage schema built from Q&A already visible on THIS page —
    the complement to fix_faq_from_facts (which needs data entry). This one
    needs none: if your page already has question headings with answers
    underneath, this just marks them up properly. If both this and the
    Business Facts FAQ apply, whichever runs first wins (checked via the
    FAQPage marker) — no duplicate schema either way."""
    if '"FAQPage"' in html:
        return html, []
    faqs = _visible_faqs(html)
    if len(faqs) < 2:
        return html, []  # nothing substantial visible — never invent
    schema = {"@context": "https://schema.org", "@type": "FAQPage",
             "mainEntity": [{"@type": "Question", "name": f["q"],
                            "acceptedAnswer": {"@type": "Answer", "text": f["a"]}}
                           for f in faqs]}
    block = ('<script type="application/ld+json">'
            + json.dumps(schema, ensure_ascii=False) + "</script>")
    out = _inject_into_head(html, block)
    if out == html:
        return html, []
    return out, [f"faq_schema_visible x{len(faqs)}"]


def fix_cta_bar_from_facts(html, ctx):
    """Inserts a single above-the-fold call/WhatsApp bar right after
    <body> — but ONLY if you've configured a real phone number in Business
    Facts. No number, no bar; nothing invented. Uses your real stat/benefit
    line if you've set one, otherwise a generic (still true) call to action."""
    marker = "<!-- seo-ai-cta -->"
    if marker in html:
        return html, []
    facts = ctx.get("facts") or {}
    phone = facts.get("phone", "").strip()
    digits = re.sub(r"\D", "", phone)
    if len(digits) < 10:
        return html, []
    benefit = facts.get("stats_blurb", "").strip()
    if not benefit:
        loc = (ctx["site"].get("target_locations") or [""])[0]
        kw = (ctx["site"].get("target_keywords") or [""])[0]
        benefit = "Get in touch" + (f" \u2014 {kw} in {loc}" if kw and loc else "")
    wa = f"https://wa.me/{digits}"
    bar = (f'{marker}\n<div style="background:#12151a;color:#eae6dc;'
          f'padding:.7rem 1rem;display:flex;gap:1rem;align-items:center;'
          f'justify-content:center;flex-wrap:wrap;font:500 15px/1.4 '
          f'system-ui,sans-serif">'
          f'<span>{benefit}</span>'
          f'<a href="tel:+{digits}" style="background:#c9a35a;color:#12151a;'
          f'padding:.5rem 1rem;border-radius:8px;text-decoration:none;'
          f'font-weight:600">Call {phone}</a>'
          f'<a href="{wa}" style="color:#c9a35a;text-decoration:none;'
          f'font-weight:600">WhatsApp</a></div>')
    out, n = re.subn(r"(<body[^>]*>)", lambda m: m.group(1) + "\n" + bar,
                     html, count=1, flags=re.I)
    if not n:
        return html, []
    return out, ["cta_bar"]


def fix_alt_text(html, ctx):
    alts = ctx.get("alt_map") or {}
    if not alts:
        return html, []
    changed = []

    def repl(m):
        tag = m.group(0)
        if re.search(r'alt\s*=\s*["\'][^"\']+["\']', tag):
            return tag
        src = re.search(r'src\s*=\s*["\']([^"\']+)["\']', tag)
        alt = alts.get(src.group(1).split("/")[-1]) if src else None
        if not alt:
            return tag
        changed.append(src.group(1).split("/")[-1])
        tag = re.sub(r'\salt\s*=\s*["\']\s*["\']', "", tag)
        return tag[:-1] + f' alt="{alt.replace(chr(34), "&quot;")}">'

    new = re.sub(r"<img\b[^>]*>", repl, html, flags=re.I)
    return new, ([f"alt_text x{len(changed)}"] if changed else [])


def fix_title(html, ctx):
    """Only fills in a <title> when there truly isn't one — we never
    rewrite existing title wording, since that's a content judgment call,
    not a deterministic fix."""
    if re.search(r"<title[^>]*>\s*\S", html, re.I | re.S):
        return html, []
    h1 = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.I | re.S)
    base = re.sub(r"<[^>]+>|\s+", " ", h1.group(1)).strip() if h1 else ""
    kw = (ctx["site"].get("target_keywords") or [""])[0]
    loc = (ctx["site"].get("target_locations") or [""])[0]
    parts = [p for p in (base, kw, ctx["site"]["name"], loc) if p]
    title = " | ".join(dict.fromkeys(parts))[:60] or ctx["site"]["name"]
    new = _inject_into_head(html, f"<title>{title}</title>")
    return (new, ["title"]) if new != html else (html, [])


def fix_schema_breadcrumb(html, ctx):
    """Breadcrumb JSON-LD built from the real URL path — never invented
    content, so it's safe to publish without a manual-review step."""
    if ctx["is_homepage"] or ('"BreadcrumbList"' in html):
        return html, []
    segments = [s for s in ctx["url"].split(ctx["site"]["url"].rstrip("/") + "/")[-1]
               .split("/") if s]
    if not segments:
        return html, []
    items, url_so_far = [], ctx["site"]["url"].rstrip("/")
    items.append({"@type": "ListItem", "position": 1, "name": "Home", "item": url_so_far})
    for i, seg in enumerate(segments, start=2):
        url_so_far += "/" + seg
        name = seg.rsplit(".", 1)[0].replace("-", " ").replace("_", " ").title()
        items.append({"@type": "ListItem", "position": i, "name": name, "item": url_so_far})
    schema = json.dumps({"@context": "https://schema.org",
                         "@type": "BreadcrumbList", "itemListElement": items})
    new = _inject_into_head(
        html, f'<script type="application/ld+json">{schema}</script>')
    return (new, ["schema_breadcrumb"]) if new != html else (html, [])


def fix_alt_text_auto(html, ctx):
    """Fills only *missing* alt attributes, guessed from the image filename
    (e.g. team-photo-2024.jpg -> "Team photo 2024"). Deliberately dumb and
    literal — no AI guessing at what's actually in the picture, so it never
    states something false about the image. Skips anything a human already
    reviewed via alt_text_review.csv (that mapping wins if both are on)."""
    reviewed = set((ctx.get("alt_map") or {}).keys())
    changed = []

    def repl(m):
        tag = m.group(0)
        if re.search(r'alt\s*=\s*["\'][^"\']+["\']', tag):
            return tag
        src = re.search(r'src\s*=\s*["\']([^"\']+)["\']', tag)
        if not src:
            return tag
        fname = src.group(1).split("/")[-1]
        if fname in reviewed:
            return tag  # let the human-reviewed CSV fixer handle this one
        guess = re.sub(r"\.\w+$", "", fname)
        guess = re.sub(r"[-_]+", " ", guess).strip()
        guess = re.sub(r"\d{3,}", "", guess).strip()  # drop long id/date numbers
        if len(guess) < 3:
            return tag  # filename too generic (e.g. "img1") — leave for manual review
        changed.append(fname)
        tag = re.sub(r'\salt\s*=\s*["\']\s*["\']', "", tag)
        return tag[:-1] + f' alt="{guess.capitalize()}">'

    new = re.sub(r"<img\b[^>]*>", repl, html, flags=re.I)
    return new, ([f"alt_text_auto x{len(changed)}"] if changed else [])


def test_connection(cfg: dict) -> dict:
    """Tries to actually log in and list files — not just a ping.
    Used by the dashboard's Test connection button."""
    kind = cfg.get("connection", "local")
    try:
        conn = connect(cfg)
        pages = conn.list_pages()
        where = {"local": cfg.get("local", {}).get("path", ""),
                 "ftp": f"{cfg['ftp'].get('host','')}{cfg['ftp'].get('remote_dir','')}",
                 "sftp": f"{cfg['sftp'].get('host','')}{cfg['sftp'].get('remote_dir','')}"
                }.get(kind, "")
        close = getattr(conn, "ftp", None) or getattr(conn, "sftp", None)
        try:
            if hasattr(close, "quit"):
                close.quit()
            elif hasattr(close, "close"):
                close.close()
        except Exception:
            pass
        return {"ok": True,
               "message": f"Connected to {where} \u2014 found {len(pages)} "
                          f"page(s) there."}
    except (Exception, SystemExit) as e:
        return {"ok": False, "message": f"{type(e).__name__}: {e}"}


_AUTO_FIXABLE_ISSUE_PATTERNS = [
    (re.compile(r"no canonical url", re.I), "auto-fixable"),
    (re.compile(r"missing meta description", re.I), "auto-fixable"),
    (re.compile(r"no viewport meta", re.I), "auto-fixable"),
    (re.compile(r"missing <title>", re.I), "auto-fixable"),
    (re.compile(r"no open graph tags", re.I), "auto-fixable"),
    (re.compile(r"\d+ h1 tags found", re.I), "auto-fixable"),
    (re.compile(r"images missing alt text", re.I),
     "partly auto-fixable \u2014 turn on 'alt_text_auto' in Settings, or upload a reviewed CSV"),
]
_HOSTING_ISSUE_PATTERNS = [
    re.compile(r"slow response", re.I),
    re.compile(r"not served over https", re.I),
]
_BROKEN_LINK_PATTERN = re.compile(r"^HTTP [4-5]\d\d$")


def classify_issue(issue_text: str) -> str:
    """Labels an audit issue so it's obvious in the UI/report whether the
    tool will resolve it once approved, or whether it genuinely needs a
    human decision — see the 'Deliberately not automated' section in
    DEPLOY.md for the reasoning behind each 'needs manual' case."""
    text = issue_text.strip()
    if _BROKEN_LINK_PATTERN.match(text) or "broken internal link" in text.lower():
        return "your call \u2014 restore the page or remove links to it"
    for pattern, label in _AUTO_FIXABLE_ISSUE_PATTERNS:
        if pattern.search(text):
            return label
    for pattern in _HOSTING_ISSUE_PATTERNS:
        if pattern.search(text):
            return ("hosting issue, not a code fix \u2014 see the ready-to-send "
                   "message for your host on the dashboard")
    return "needs manual content work"


def _submit_indexnow(site: dict, conn, applied_summary: dict, say):
    """Handles all three key-file modes, then pings Bing/Yandex with the
    real changed URLs. Never raises — a failed ping must never break the
    apply flow that already succeeded."""
    try:
        cfg = indexnow.load_cfg()
        if not cfg.get("enabled"):
            return
        key = indexnow.ensure_key(cfg)
        mode = cfg.get("mode", "manual")

        if mode == "auto" and not cfg.get("key_uploaded"):
            conn.write(indexnow.key_filename(key), indexnow.key_file_contents(key).encode())
            cfg["key_uploaded"] = True
            say(f"  IndexNow: uploaded your verification key file to "
               f"{indexnow.key_filename(key)}")
        elif mode == "manual" and not cfg.get("key_uploaded"):
            say(f"  IndexNow is on, but its key file hasn't been uploaded "
               f"yet \u2014 see Settings \u2192 IndexNow for the one-time "
               f"manual step. Skipping the ping until then.")
            indexnow.save_cfg(cfg)
            return
        # mode == "approval": key file is proposed as a normal fix elsewhere
        # and only exists once you've approved it — nothing to do here but
        # check cfg["key_uploaded"], set once that approval was applied.

        indexnow.save_cfg(cfg)
        if not cfg.get("key_uploaded"):
            return

        urls = indexnow.to_absolute(site["url"], list(applied_summary.keys()))
        if urls:
            indexnow.submit(site["url"], urls, key, log=say)
    except Exception as e:
        say(f"  IndexNow ping skipped (not critical): {e}")


PAGE_FIXES = {  # config key -> function
    "canonical": fix_canonical,
    "viewport": fix_viewport,
    "title": fix_title,
    "meta_description": fix_meta_description,
    "og_tags": fix_og,
    "schema_homepage": fix_schema_homepage,
    "schema_breadcrumb": fix_schema_breadcrumb,
    "multiple_h1": fix_multiple_h1,
    "dead_links": fix_dead_links,
    "linkify_email": fix_linkify_email,
    "tel_from_facts": fix_tel_from_facts,
    "stats_from_facts": fix_stats_from_facts,
    "testimonial_schema_from_facts": fix_testimonial_schema_from_facts,
    "author_byline_from_facts": fix_author_byline_from_facts,
    "faq_from_facts": fix_faq_from_facts,
    "faq_schema_visible": fix_faq_schema_from_visible,
    "lazy_images": fix_lazy_images,
    "cta_bar_from_facts": fix_cta_bar_from_facts,
    "alt_text_from_csv": fix_alt_text,
    "alt_text_auto": fix_alt_text_auto,
}


HTACCESS_MARKER = "# BEGIN SEO-AI-Optimizer speed rules"
HTACCESS_BLOCK = """
# BEGIN SEO-AI-Optimizer speed rules
# Browser caching + compression. Every directive is wrapped in IfModule,
# so nothing breaks on hosts where a module is disabled. Apache/cPanel only
# — has no effect (and does no harm) on Nginx or other server software.
<IfModule mod_expires.c>
  ExpiresActive On
  ExpiresByType image/jpeg "access plus 6 months"
  ExpiresByType image/png "access plus 6 months"
  ExpiresByType image/webp "access plus 6 months"
  ExpiresByType image/svg+xml "access plus 6 months"
  ExpiresByType text/css "access plus 1 month"
  ExpiresByType application/javascript "access plus 1 month"
  ExpiresByType font/woff2 "access plus 6 months"
</IfModule>
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule>
# END SEO-AI-Optimizer speed rules
"""


def build_robots(existing: str, site_url: str) -> str:
    lines = existing.splitlines() if existing else []
    text = "\n".join(lines)
    if "sitemap:" not in text.lower():
        lines.append(f"Sitemap: {site_url.rstrip('/')}/sitemap.xml")
    for bot in ("GPTBot", "ClaudeBot", "OAI-SearchBot", "PerplexityBot",
                "Google-Extended"):
        if bot.lower() not in text.lower():
            lines += [f"User-agent: {bot}", "Allow: /"]
    return "\n".join(lines).strip() + "\n"


# ----------------------------------------------------------------------
# Orchestration
# ----------------------------------------------------------------------

def load_alt_map() -> dict:
    import csv
    p = APP_DIR / "alt_text_review.csv"
    if not p.exists():
        return {}
    out = {}
    with p.open(encoding="utf-8") as f:
        for row in csv.DictReader(f):
            if row.get("suggested_alt", "").strip():
                out[row["image_src"].split("/")[-1]] = row["suggested_alt"].strip()
    return out


def _verify_fix_is_live(url: str, fixed: list, new_html: str) -> str | None:
    """After uploading, actually re-fetch the public URL and check the fix
    genuinely shows up there — not just that the FTP write succeeded. If
    it doesn't, that's the #1 real-world cause of 'I approved this fix but
    the report still shows it': the FTP folder isn't the same folder the
    website is actually served from. Returns a warning string, or None if
    verified (or not checkable)."""
    markers = []
    if any("canonical" in f for f in fixed):
        m = re.search(r'<link rel="canonical"[^>]*>', new_html)
        if m:
            markers.append(("canonical tag", m.group(0)))
    if any(f == "title" for f in fixed):
        m = re.search(r"<title>.*?</title>", new_html, re.S)
        if m:
            markers.append(("title tag", m.group(0)))
    if any("meta_description" in f for f in fixed):
        m = re.search(r'<meta name="description"[^>]*>', new_html)
        if m:
            markers.append(("meta description", m.group(0)))
    if not markers:
        return None  # nothing we confidently know how to spot-check
    try:
        live = requests.get(url, timeout=15,
                            headers={"User-Agent": "SEO-AI-Optimizer/1.0"}).text
    except Exception as e:
        return (f"Could not double-check {url} over the internet ({e}) — "
               f"the upload itself succeeded, but I couldn't confirm it's "
               f"visible live.")
    missing = [name for name, marker in markers if marker not in live]
    if missing:
        return (f"Uploaded successfully, but re-checking {url} live shows "
               f"the {', '.join(missing)} is/are still missing. This "
               f"usually means your FTP 'Remote folder' isn't the same "
               f"folder your website actually serves pages from — check "
               f"with your host, or try a different Remote folder path in "
               f"Settings \u2192 Auto-fix connection.")
    return None


def apply_selected_dead_links(selected_urls: set, log=print) -> dict:
    """Removes only the specific broken links a human checked in the
    'Broken links' review panel — never the whole fix bundle, never a link
    they didn't explicitly select."""
    rcfg = load_cfg()
    site = yaml.safe_load((APP_DIR / "config.yaml").read_text(encoding="utf-8"))["sites"][0]
    conn = connect(rcfg)
    pages = conn.list_pages()
    stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    bdir = BACKUPS / stamp
    results = {}
    for rel in sorted(pages):
        raw = conn.read(rel)
        html = raw.decode("utf-8", errors="ignore")
        ctx = {"site": site, "broken_urls": selected_urls}
        new, fixed = fix_dead_links(html, ctx)
        if fixed:
            bdir.mkdir(parents=True, exist_ok=True)
            (bdir / rel.replace("/", "__")).write_bytes(raw)
            conn.write(rel, new.encode("utf-8"))
            results[rel] = fixed
            log(f"  \u2713 {rel} \u2014 removed the link(s) you selected")
    return results


def execute(apply: bool, quiet: bool = False, log=None) -> dict:
    """Core engine. Returns {'summary': {file:[fixes]}, 'diff': str,
    'backup': str|None}. Raises SystemExit on config problems.

    `log`, if given, receives every step in plain language — this is what
    lets the web dashboard's Activity panel show real progress instead of
    just a start/end message. Falls back to print() for CLI use, or to
    silence if quiet=True and no log given."""
    say = log if log is not None else ((lambda *a: None) if quiet else print)
    if not REM_CFG.exists():
        REM_CFG.write_text(DEFAULT_CFG, encoding="utf-8")
        say(f"No connection set up yet — created {REM_CFG.name} with blank "
           f"defaults. Fill in your FTP/SFTP details on the dashboard, then "
           f"run an audit again.")
        return {"summary": {}, "diff": "", "backup": None,
                "note": "remediate.yaml created; configure it first"}
    rcfg = yaml.safe_load(REM_CFG.read_text(encoding="utf-8"))
    main_cfg = yaml.safe_load((APP_DIR / "config.yaml").read_text(encoding="utf-8"))
    site = main_cfg["sites"][0]
    fixes_on = rcfg.get("fixes", {})

    say("Connecting to your server to check for fixable issues…")
    conn = connect(rcfg)
    all_pages = conn.list_pages()
    crawled = _load_crawled_urls()
    if crawled:
        pages = [rel for rel in all_pages
                if _page_url(site["url"], rel).rstrip("/") in crawled]
        skipped_not_crawled = len(all_pages) - len(pages)
        if skipped_not_crawled:
            say(f"Your server has {len(all_pages)} .php/.html file(s), but "
               f"your last audit only found and checked {len(pages)} of "
               f"them as real, live pages. Skipping the other "
               f"{skipped_not_crawled} (backend scripts, template include "
               f"files, orphaned/duplicate pages) \u2014 fixing those "
               f"wouldn't do anything real and could risk breaking pages "
               f"that include them.")
    else:
        pages = all_pages  # no audit run yet — fall back to the old behavior
    if len(pages) > rcfg.get("limits", {}).get("max_files", 200):
        raise SystemExit(f"Refusing: {len(pages)} files exceeds max_files limit.")
    say(f"Connected. Checking {len(pages)} page(s) one by one "
       f"{'(this will upload real changes)' if apply else '(just checking, nothing is uploaded yet)'}…")

    stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    bdir = BACKUPS / stamp
    diff_report, applied_summary = [], {}
    alt_map = load_alt_map() if fixes_on.get("alt_text_from_csv") else {}
    broken_urls = _load_broken_urls() if fixes_on.get("dead_links") else set()
    facts = business_facts.load_cfg()
    checked, skipped_not_a_page, no_head_in_source = 0, [], []

    for rel in sorted(pages):
        raw = conn.read(rel)
        try:
            html = raw.decode("utf-8")
        except UnicodeDecodeError:
            html = raw.decode("utf-8", errors="ignore")

        looks_like_full_page = bool(re.search(r"<html[\s>]", html, re.I)
                                    and re.search(r"<head[\s>]", html, re.I))

        # A file matching a real, crawled, live URL IS a real page — that's
        # stronger evidence than a regex on its raw source. Many real PHP
        # sites build <head> via an include() rather than writing it
        # literally in every page file, which would otherwise make this
        # check wrongly skip genuine pages (including your homepage).
        # The raw-source check is only the deciding factor when there's no
        # crawl history yet to lean on (first run).
        if not crawled and not looks_like_full_page:
            skipped_not_a_page.append(rel)
            checked += 1
            continue
        if crawled and not looks_like_full_page:
            no_head_in_source.append(rel)  # proceed, but flag it below

        url = _page_url(site["url"], rel)
        ctx = {"site": site, "url": url, "alt_map": alt_map,
               "broken_urls": broken_urls, "facts": facts,
               "is_homepage": rel in ("index.php", "index.html", "index.htm")}
        new, fixed = html, []
        for key, fn in PAGE_FIXES.items():
            if fixes_on.get(key):
                new, f = fn(new, ctx)
                fixed += f
        checked += 1
        if fixed:
            applied_summary[rel] = fixed
            diff = "\n".join(difflib.unified_diff(
                html.splitlines(), new.splitlines(),
                fromfile=f"a/{rel}", tofile=f"b/{rel}", lineterm="", n=1))
            diff_report.append(diff)
            if apply:
                bdir.mkdir(parents=True, exist_ok=True)
                (bdir / rel.replace("/", "__")).write_bytes(raw)
                conn.write(rel, new.encode("utf-8"))
                say(f"  [{checked}/{len(pages)}] \u2713 {rel} \u2014 backed up the "
                   f"original, then uploaded the fix: {', '.join(fixed)}")
                if rcfg.get("connection") != "local":
                    warning = _verify_fix_is_live(url, fixed, new)
                    if warning:
                        say(f"      \u26a0\ufe0f {warning}")
            else:
                say(f"  [{checked}/{len(pages)}] Found a fixable issue on {rel}: "
                   f"{', '.join(fixed)}")
        else:
            say(f"  [{checked}/{len(pages)}] {rel} \u2014 nothing to fix here")

    # ---- site-root files ----
    if fixes_on.get("llms_txt"):
        gen = APP_DIR / "reports" / "generated"
        src = next(iter(gen.glob("*-llms.txt")), None) if gen.exists() else None
        if src and not conn.exists("llms.txt"):
            if apply:
                conn.write("llms.txt", src.read_bytes())
                say("  \u2713 Uploaded llms.txt to your server root")
            else:
                say("  Found a missing llms.txt \u2014 will upload it once approved")
            applied_summary["llms.txt"] = ["uploaded"]
        elif not src:
            say("  Skipped llms.txt: nothing generated yet \u2014 run a normal "
               "audit first so this tool has content to build it from.")

    if fixes_on.get("robots_txt"):
        existing = ""
        if conn.exists("robots.txt"):
            existing = conn.read("robots.txt").decode("utf-8", errors="ignore")
        new_robots = build_robots(existing, site["url"])
        if new_robots.strip() != existing.strip():
            if apply:
                if existing:
                    bdir.mkdir(parents=True, exist_ok=True)
                    (bdir / "robots.txt").write_text(existing, encoding="utf-8")
                conn.write("robots.txt", new_robots.encode())
                say("  \u2713 Updated robots.txt on your server")
            else:
                say("  Found robots.txt needs an update \u2014 will apply once approved")
            applied_summary["robots.txt"] = ["sitemap line + AI crawler rules"]

    if fixes_on.get("sitemap_xml") and not conn.exists("sitemap.xml"):
        urls = sorted({_page_url(site["url"], rel) for rel in pages})
        body = "\n".join(f"  <url><loc>{u}</loc></url>" for u in urls)
        xml = ('<?xml version="1.0" encoding="UTF-8"?>\n'
              '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
              f"{body}\n</urlset>\n")
        if apply:
            conn.write("sitemap.xml", xml.encode())
            say(f"  \u2713 Generated and uploaded sitemap.xml ({len(urls)} URLs)")
        else:
            say(f"  No sitemap.xml found \u2014 will generate one with {len(urls)} "
               f"URLs once approved")
        applied_summary["sitemap.xml"] = [f"generated ({len(urls)} URLs)"]

    if fixes_on.get("htaccess_speed") and conn.exists(".htaccess"):
        existing_ht = conn.read(".htaccess").decode("utf-8", errors="ignore")
        if HTACCESS_MARKER not in existing_ht:
            new_ht = existing_ht.rstrip() + "\n" + HTACCESS_BLOCK
            if apply:
                bdir.mkdir(parents=True, exist_ok=True)
                (bdir / ".htaccess").write_text(existing_ht, encoding="utf-8")
                conn.write(".htaccess", new_ht.encode())
                say("  \u2713 Added browser-caching/compression rules to .htaccess "
                   "(Apache only \u2014 harmless no-op elsewhere)")
            else:
                say("  Found no speed rules in .htaccess \u2014 will add them "
                   "once approved (Apache/cPanel hosts only)")
            applied_summary[".htaccess"] = ["speed rules (caching + compression)"]

    inx_cfg = indexnow.load_cfg()
    if (inx_cfg.get("enabled") and inx_cfg.get("mode") == "approval"
            and not inx_cfg.get("key_uploaded")):
        key = indexnow.ensure_key(inx_cfg)
        fname = indexnow.key_filename(key)
        if not conn.exists(fname):
            if apply:
                conn.write(fname, indexnow.key_file_contents(key).encode())
                inx_cfg["key_uploaded"] = True
                indexnow.save_cfg(inx_cfg)
                say(f"  \u2713 Uploaded your IndexNow verification key file ({fname})")
            else:
                say(f"  IndexNow key file not uploaded yet \u2014 will upload "
                   f"{fname} once approved")
            applied_summary[fname] = ["indexnow key file"]
        else:
            inx_cfg["key_uploaded"] = True
            indexnow.save_cfg(inx_cfg)

    # ---- reports ----
    out = APP_DIR / "reports"
    out.mkdir(exist_ok=True)
    full_diff = "\n\n".join(diff_report)
    (out / f"remediation_{stamp}.diff").write_text(full_diff, encoding="utf-8")
    (out / f"remediation_{stamp}.json").write_text(
        json.dumps({"applied": apply, "summary": applied_summary}, indent=2),
        encoding="utf-8")

    if skipped_not_a_page:
        say(f"Skipped {len(skipped_not_a_page)} file(s) that aren't real, "
           f"complete pages (template includes like header/footer, or "
           f"backend scripts like mail handlers) \u2014 fixing those would "
           f"do nothing or risk corrupting the real pages that include "
           f"them: {', '.join(skipped_not_a_page[:8])}"
           f"{'…' if len(skipped_not_a_page) > 8 else ''}")

    if no_head_in_source:
        say(f"\u26a0\ufe0f {len(no_head_in_source)} real page(s) don't have "
           f"their own <head> in the file \u2014 they likely build it via a "
           f"PHP include (e.g. `include('header.php')`), which is common "
           f"and fine, but means canonical/title/meta/schema fixes can't "
           f"be safely written into a SHARED header file (that would put "
           f"the same title on every page that uses it). Page-body fixes "
           f"like the H1 count still work normally on these. Affected: "
           f"{', '.join(no_head_in_source[:8])}"
           f"{'…' if len(no_head_in_source) > 8 else ''} \u2014 making "
           f"per-page title/meta possible here needs a small one-time "
           f"code change in your shared header file (e.g. a PHP variable "
           f"it reads per page) \u2014 ask if you'd like exact steps for "
           f"your specific header file.")

    if apply and applied_summary:
        _submit_indexnow(site, conn, applied_summary, say)

    if apply:
        say(f"Done \u2014 uploaded real fixes to {len(applied_summary)} "
           f"item(s) on your live server. A backup of every original file "
           f"was saved first, in case anything needs to be undone.")
    elif applied_summary:
        say(f"Done checking \u2014 found {len(applied_summary)} item(s) that can "
           f"be fixed. Nothing has been uploaded yet; you'll get an email "
           f"(or see it on the dashboard) to approve before anything changes "
           f"on your live site.")
    else:
        say("Done checking \u2014 nothing needs fixing right now on the items "
           "this tool covers automatically.")
    return {"summary": applied_summary, "diff": full_diff,
           "backup": str(bdir) if apply and bdir.exists() else None}


def run(apply: bool):
    execute(apply)


def rollback(bdir: str):
    b = Path(bdir)
    if not b.exists():
        raise SystemExit(f"Backup dir {b} not found.")
    rcfg = yaml.safe_load(REM_CFG.read_text(encoding="utf-8"))
    conn = connect(rcfg)
    for f in b.iterdir():
        rel = f.name.replace("__", "/")
        conn.write(rel, f.read_bytes())
        print(f"  restored {rel}")
    print("Rollback complete. Re-run your audit to confirm.")


def main():
    ap = argparse.ArgumentParser(description="Execute audit recommendations "
                                             "against your hosting (safely).")
    ap.add_argument("--apply", action="store_true",
                    help="actually upload fixes (default is dry run)")
    ap.add_argument("--rollback", metavar="BACKUP_DIR",
                    help="restore files from a backup directory")
    args = ap.parse_args()
    if args.rollback:
        rollback(args.rollback)
    else:
        run(apply=args.apply)


if __name__ == "__main__":
    main()
