"""SEO + AI Search Optimizer — Desktop & Mobile App.

Run:  python app.py
Then it opens in your browser. From a phone on the same WiFi, visit
http://<your-computer-ip>:8765 and use "Add to Home Screen" to install it.

Package as a desktop .exe with PyInstaller — see build_desktop.md.
"""
from __future__ import annotations
import json
import os
import socket
import threading
import webbrowser
from datetime import datetime
from pathlib import Path

from dotenv import load_dotenv
load_dotenv()  # loads a local .env file if present; harmless if it isn't
                # (on Render, real environment variables are used instead)

from flask import (Flask, jsonify, request, send_file,
                   render_template_string, Response)

from seo_tool import runner, report as report_mod, approvals, remediate, content_updates, business_facts, indexnow
from seo_tool.db import get_db

APP_DIR = Path(__file__).parent
CONFIG_PATH = APP_DIR / "config.yaml"
app = Flask(__name__)

PUBLIC_URL = os.environ.get("PUBLIC_URL", "").rstrip("/")  # e.g. https://seo.mycompany.com
APP_PASSWORD = os.environ.get("APP_PASSWORD", "")


@app.before_request
def _guard():
    """Password-protect everything when APP_PASSWORD is set (cloud mode).
    Approval links are exempt — they carry their own signed token."""
    if not APP_PASSWORD:
        return
    p = request.path
    if p.startswith(("/approve/", "/manifest.json", "/icon.svg")):
        return
    if p == "/webhook/deploy":
        # webhook authenticates via its own secret header
        if request.headers.get("X-Webhook-Secret") == os.environ.get(
                "WEBHOOK_SECRET", APP_PASSWORD):
            return
        return Response("bad webhook secret", 403)
    a = request.authorization
    if a and a.password == APP_PASSWORD:
        return
    return Response("Login required", 401,
                    {"WWW-Authenticate": 'Basic realm="SEO Optimizer"'})

# ----------------------------- state -----------------------------
STATE = {
    "running": False,
    "log": [],
    "last_result": None,
    "scheduler_on": False,
    "next_run": None,
}
_sched_timer: threading.Timer | None = None


def log(msg: str):
    STATE["log"].append(f"{datetime.now().strftime('%H:%M:%S')}  {msg}")
    STATE["log"] = STATE["log"][-200:]


# ----------------------------- audit job -----------------------------
def _web_root_warning() -> str | None:
    """If this tool's own folder sits inside the site's web root, its
    backups, reports, database, and hosting password become downloadable
    by anyone who guesses the URL. That's the one mistake that turns a
    helpful tool into a breach, so it's checked every audit, not buried
    in a setup doc nobody re-reads."""
    try:
        rcfg = remediate.load_cfg()
        root = (rcfg.get("local") or {}).get("path", "")
        if not root or rcfg.get("connection") != "local":
            return None
        root_path = Path(root).resolve()
        here = APP_DIR.resolve()
        if root_path in here.parents or root_path == here:
            return (f"This tool is installed inside {root_path} \u2014 your "
                   f"website's own public folder. Your backups, reports, "
                   f"database, and hosting password could be downloaded by "
                   f"anyone who guesses the URL. Move this whole folder "
                   f"outside your site's public web folder (outside "
                   f"public_html, or one level up).")
    except Exception:
        return None
    return None


def _audit_job():
    STATE["running"] = True
    log("\u2500\u2500\u2500 New audit run \u2500\u2500\u2500")
    warn = _web_root_warning()
    if warn:
        log(f"\u26a0\ufe0f SECURITY: {warn}")
    try:
        cfg = runner.load_config(str(CONFIG_PATH))
        cfg["reports"]["output_dir"] = str(APP_DIR / "reports")
        for site in cfg["sites"]:
            log(f"Step 1/4 \u2014 Starting audit of {site['url']}…")
            r = runner.run_site_audit(site, cfg, log=log)
            prev = report_mod.load_previous(cfg["reports"]["output_dir"], site["name"])
            files = report_mod.save_reports(r, cfg["reports"]["output_dir"],
                                            cfg["reports"]["formats"],
                                            cfg["reports"].get("keep_history", 30))
            if prev:
                r["diff"] = report_mod.diff_reports(prev, r)
            STATE["last_result"] = _summarize(r, files)
            log(f"Step 1/4 done \u2014 overall score {r['overall_score']}/100")
            log("Step 2/4 \u2014 Checking for auto-fixable issues on your server…")
            _propose_fixes(site)
            log("Step 3/4 \u2014 Checking Google Business Profile / social / blog updates…")
            _propose_content(site)
            log("Step 4/4 \u2014 All done for this run. Nothing further happens "
               "automatically unless you approve a pending item above.")
    except Exception as e:  # surface errors to the UI instead of dying
        log(f"Error: {e}")
    finally:
        STATE["running"] = False


def _summarize(r: dict, files: list[str]) -> dict:
    recs, issues = [], []
    for sec in ("geo", "lead_gen"):
        for p in r.get(sec, {}).get("pages", []):
            recs.extend(p.get("recommendations", []))
    for p in r.get("technical", {}).get("pages", []):
        for i in p.get("issues", []):
            issues.append({"url": p["url"], "issue": i,
                           "tag": remediate.classify_issue(i)})
    html_report = next((f for f in files if f.endswith(".html")), None)
    return {
        "site": r["site"]["name"],
        "url": r["site"]["url"],
        "when": r["generated_at"],
        "overall": r["overall_score"],
        "scores": {
            "Technical SEO": r["technical"]["avg_score"],
            "AI readiness": r["geo"]["avg_score"],
            "Content": r["content"]["avg_score"],
            "Lead generation": r["lead_gen"]["avg_score"],
        },
        "diff": r.get("diff"),
        "recommendations": list(dict.fromkeys(recs))[:10],
        "issues": issues[:15],
        "pages_crawled": r.get("pages_crawled", 0),
        "html_report": html_report,
        "llms_txt": r.get("generated_llms_txt"),
        "org_schema": r.get("generated_org_schema"),
        "changes": r.get("changes"),
        "citations": r.get("citations"),
    }


# ----------------------------- scheduler -----------------------------
def _schedule_next(hours: float):
    global _sched_timer
    if not STATE["scheduler_on"]:
        return
    _sched_timer = threading.Timer(hours * 3600, _scheduled_run, args=[hours])
    _sched_timer.daemon = True
    _sched_timer.start()
    STATE["next_run"] = datetime.now().timestamp() + hours * 3600


def _scheduled_run(hours: float):
    if not STATE["running"]:
        threading.Thread(target=_audit_job, daemon=True).start()
    _schedule_next(hours)


# ----------------------------- API -----------------------------
@app.get("/api/config")
def get_config():
    cfg = runner.load_config(str(CONFIG_PATH))
    site = cfg["sites"][0]
    return jsonify({
        "url": site["url"], "name": site["name"],
        "business_type": site.get("business_type", "Organization"),
        "keywords": ", ".join(site.get("target_keywords", [])),
        "locations": ", ".join(site.get("target_locations", [])),
        "max_pages": cfg["crawl"]["max_pages"],
        "interval_hours": cfg["scheduler"]["audit_interval_hours"],
        "modules": cfg["modules"],
    })


@app.post("/api/config")
def set_config():
    d = request.json
    cfg = runner.load_config(str(CONFIG_PATH))
    site = cfg["sites"][0]
    site["url"] = d["url"].strip().rstrip("/")
    if not site["url"].startswith("http"):
        site["url"] = "https://" + site["url"]
    site["name"] = d["name"].strip() or "My Site"
    site["business_type"] = d["business_type"]
    site["target_keywords"] = [k.strip() for k in d["keywords"].split(",") if k.strip()]
    site["target_locations"] = [k.strip() for k in d["locations"].split(",") if k.strip()]
    cfg["crawl"]["max_pages"] = max(1, min(100, int(d["max_pages"])))
    cfg["scheduler"]["audit_interval_hours"] = max(1, int(d["interval_hours"]))
    for k, v in d.get("modules", {}).items():
        if k in cfg["modules"]:
            cfg["modules"][k] = bool(v)
    import yaml
    CONFIG_PATH.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
    return jsonify({"ok": True})


@app.post("/api/run")
def run_now():
    if STATE["running"]:
        return jsonify({"ok": False, "error": "An audit is already running"})
    threading.Thread(target=_audit_job, daemon=True).start()
    return jsonify({"ok": True})


@app.post("/api/scheduler")
def toggle_scheduler():
    global _sched_timer
    on = bool(request.json.get("on"))
    STATE["scheduler_on"] = on
    if _sched_timer:
        _sched_timer.cancel()
        _sched_timer = None
    if on:
        cfg = runner.load_config(str(CONFIG_PATH))
        _schedule_next(cfg["scheduler"]["audit_interval_hours"])
    else:
        STATE["next_run"] = None
    return jsonify({"ok": True, "on": on})


@app.get("/api/status")
def status():
    return jsonify({
        "running": STATE["running"],
        "log": STATE["log"][-40:],
        "result": STATE["last_result"],
        "scheduler_on": STATE["scheduler_on"],
        "next_run": STATE["next_run"],
    })


@app.post("/webhook/deploy")
def deploy_webhook():
    """CI systems ping this on every release so regressions get blamed
    on the right deploy. Example:
      curl -X POST http://host:8765/webhook/deploy \
        -H 'Content-Type: application/json' \
        -d '{"ref":"v2.4.1","source":"github","note":"homepage redesign"}'
    GitHub Actions: add a step with
      run: curl -X POST $SEO_TOOL_URL/webhook/deploy -H 'Content-Type: application/json' \
           -d '{"ref":"${{ github.sha }}","source":"github"}'
    """
    from seo_tool.db import get_db
    d = request.json or {}
    cfg = runner.load_config(str(CONFIG_PATH))
    site_name = d.get("site") or cfg["sites"][0]["name"]
    dep_id = get_db().record_deploy(site_name, str(d.get("ref", "unknown")),
                                    str(d.get("source", "manual")),
                                    str(d.get("note", "")))
    log(f"Deploy recorded: {d.get('ref','?')} from {d.get('source','manual')}")
    return jsonify({"ok": True, "deploy_id": dep_id})


@app.get("/api/changes")
def changes_feed():
    from seo_tool.db import get_db
    cfg = runner.load_config(str(CONFIG_PATH))
    return jsonify(get_db().recent_changes(cfg["sites"][0]["name"], limit=50))


@app.post("/api/citations/run")
def citations_run():
    if STATE["running"]:
        return jsonify({"ok": False, "error": "An audit is already running"})

    def job():
        STATE["running"] = True
        try:
            from seo_tool.db import get_db
            from seo_tool import citations
            cfg = runner.load_config(str(CONFIG_PATH))
            site = cfg["sites"][0]
            log("Running AI citation checks…")
            res = citations.run_citation_checks(get_db(), site,
                                                cfg.get("citation_tracking", {}),
                                                log)
            log(f"Citation checks done ({res['ran']} answers analyzed).")
            if STATE["last_result"]:
                STATE["last_result"]["citations"] = \
                    get_db().citation_summary(site["name"])
        finally:
            STATE["running"] = False

    threading.Thread(target=job, daemon=True).start()
    return jsonify({"ok": True})


@app.get("/api/citations")
def citations_get():
    from seo_tool.db import get_db
    cfg = runner.load_config(str(CONFIG_PATH))
    return jsonify(get_db().citation_summary(cfg["sites"][0]["name"]))


@app.post("/api/citations/keys")
def citations_keys():
    import yaml
    d = request.json or {}
    cfg = runner.load_config(str(CONFIG_PATH))
    cit = cfg.setdefault("citation_tracking", {})
    keys = cit.setdefault("api_keys", {})
    for k in ("openai", "anthropic", "perplexity"):
        if k in d:
            keys[k] = d[k].strip()
    CONFIG_PATH.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
    return jsonify({"ok": True,
                    "enabled": [k for k, v in keys.items() if v]})


@app.get("/api/remediate-settings")
def remediate_settings_get():
    cfg = remediate.load_cfg()
    ftp = cfg.get("ftp", {})
    sftp = cfg.get("sftp", {})
    return jsonify({
        "connection": cfg.get("connection", "ftp"),
        "max_files": cfg.get("limits", {}).get("max_files", 200),
        "fixes": cfg.get("fixes", {}),
        "ftp": {"host": ftp.get("host", ""), "user": ftp.get("user", ""),
                "has_password": bool(ftp.get("password")),
                "use_tls": bool(ftp.get("use_tls", True)),
                "remote_dir": ftp.get("remote_dir", "/public_html")},
        "sftp": {"host": sftp.get("host", ""), "port": sftp.get("port", 22),
                 "user": sftp.get("user", ""),
                 "has_password": bool(sftp.get("password")),
                 "remote_dir": sftp.get("remote_dir", "/public_html")},
        "local": {"path": cfg.get("local", {}).get("path", "./site-copy")},
    })


def _merge_remediate_cfg(cfg: dict, d: dict) -> dict:
    """Applies dashboard form values onto an existing remediate config.
    Blank secret fields keep whatever was already saved."""
    cfg["connection"] = d.get("connection", cfg.get("connection", "ftp"))
    try:
        cfg.setdefault("limits", {})["max_files"] = max(1, int(d.get("max_files") or 200))
    except (TypeError, ValueError):
        cfg.setdefault("limits", {})["max_files"] = 200
    for k, v in (d.get("fixes") or {}).items():
        if k in cfg.get("fixes", {}):
            cfg["fixes"][k] = bool(v)

    fd = d.get("ftp", {})
    ftp = cfg.setdefault("ftp", {})
    ftp["host"] = (fd.get("host") or ftp.get("host", "")).strip()
    ftp["user"] = (fd.get("user") or ftp.get("user", "")).strip()
    ftp["use_tls"] = bool(fd.get("use_tls", True))
    ftp["remote_dir"] = (fd.get("remote_dir") or ftp.get("remote_dir", "/public_html")).strip()
    if fd.get("password"):
        ftp["password"] = fd["password"]

    sd = d.get("sftp", {})
    sftp = cfg.setdefault("sftp", {})
    sftp["host"] = (sd.get("host") or sftp.get("host", "")).strip()
    sftp["user"] = (sd.get("user") or sftp.get("user", "")).strip()
    sftp["remote_dir"] = (sd.get("remote_dir") or sftp.get("remote_dir", "/public_html")).strip()
    try:
        sftp["port"] = int(sd.get("port") or sftp.get("port", 22))
    except (TypeError, ValueError):
        sftp["port"] = 22
    if sd.get("password"):
        sftp["password"] = sd["password"]

    ld = d.get("local", {})
    cfg.setdefault("local", {})["path"] = (ld.get("path") or
                                           cfg.get("local", {}).get("path", "./site-copy")).strip()
    return cfg


@app.post("/api/remediate-settings")
def remediate_settings_set():
    import yaml
    d = request.json or {}
    cfg = _merge_remediate_cfg(remediate.load_cfg(), d)
    remediate.REM_CFG.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
    log("Auto-fix connection settings saved from the dashboard.")
    return jsonify({"ok": True})


@app.post("/api/remediate-settings/test")
def remediate_settings_test():
    """Tests whatever is currently in the form (not yet saved) — blank
    secret fields fall back to whatever's already saved on disk."""
    d = request.json or {}
    cfg = _merge_remediate_cfg(remediate.load_cfg(), d)
    result = remediate.test_connection(cfg)
    log(("FTP/SFTP test: " if cfg.get("connection") != "local" else "Folder test: ")
        + result["message"])
    return jsonify(result)


ENV_PATH = APP_DIR / ".env"


def _ensure_env_file():
    if not ENV_PATH.exists():
        ENV_PATH.write_text("", encoding="utf-8")


@app.get("/api/hosting-message")
def hosting_message():
    r = STATE.get("last_result")
    if not r:
        return jsonify({"has_issues": False, "message": ""})
    hosting_issues = [i["issue"] for i in r.get("issues", [])
                      if remediate.classify_issue(i["issue"]).startswith("hosting issue")]
    hosting_issues = list(dict.fromkeys(hosting_issues))  # de-dupe, keep order
    if not hosting_issues:
        return jsonify({"has_issues": False, "message": ""})
    try:
        site = runner.load_config(str(CONFIG_PATH))["sites"][0]
        site_url = site["url"]
    except Exception:
        site_url = "my website"
    bullets = "\n".join(f"- {i}" for i in hosting_issues)
    message = (f"Hi,\n\nCould you help look into a couple of performance/security items "
              f"on my hosting for {site_url}?\n\n{bullets}\n\n"
              f"Specifically, could you check:\n"
              f"- Is PHP OPcache enabled for my account?\n"
              f"- Is there a caching layer available (e.g. Varnish, LiteSpeed Cache) "
              f"I could turn on?\n"
              f"- Is my current hosting plan's CPU/resource allocation adequate, or "
              f"would upgrading help?\n"
              f"- If HTTPS isn't fully enabled, could you help set up a free SSL "
              f"certificate (e.g. Let's Encrypt) for the domain?\n\n"
              f"Thanks!")
    return jsonify({"has_issues": True, "message": message,
                    "n_issues": len(hosting_issues)})


@app.get("/api/business-facts")
def business_facts_get():
    return jsonify(business_facts.load_cfg())


@app.post("/api/business-facts")
def business_facts_set():
    d = request.json or {}
    cfg = business_facts.load_cfg()
    cfg["phone"] = (d.get("phone") or "").strip()
    cfg["stats_blurb"] = (d.get("stats_blurb") or "").strip()
    cfg["author"] = {"name": (d.get("author", {}).get("name") or "").strip(),
                     "credential": (d.get("author", {}).get("credential") or "").strip()}
    cfg["testimonials"] = [
        {"name": t.get("name", "").strip(), "org": t.get("org", "").strip(),
         "quote": t.get("quote", "").strip()}
        for t in (d.get("testimonials") or []) if t.get("quote", "").strip()
    ][:3]
    cfg["faq"] = [
        {"q": f.get("q", "").strip(), "a": f.get("a", "").strip()}
        for f in (d.get("faq") or []) if f.get("q", "").strip() and f.get("a", "").strip()
    ]
    business_facts.save_cfg(cfg)
    log("Business facts saved from the dashboard.")
    return jsonify({"ok": True})


@app.get("/api/indexnow")
def indexnow_get():
    cfg = indexnow.load_cfg()
    key = cfg.get("key", "")
    site_url = None
    try:
        c = runner.load_config(str(CONFIG_PATH))
        site_url = c["sites"][0]["url"]
    except Exception:
        pass
    out = {"enabled": bool(cfg.get("enabled", False)), "mode": cfg.get("mode", "manual"),
          "key_uploaded": bool(cfg.get("key_uploaded", False))}
    if key and site_url:
        out["key_filename"] = indexnow.key_filename(key)
        out["key_url"] = indexnow.key_url(site_url, key)
    return jsonify(out)


@app.post("/api/indexnow")
def indexnow_set():
    d = request.json or {}
    cfg = indexnow.load_cfg()
    cfg["enabled"] = bool(d.get("enabled"))
    cfg["mode"] = d.get("mode", cfg.get("mode", "manual"))
    if cfg["enabled"] and not cfg.get("key"):
        indexnow.ensure_key(cfg)
    indexnow.save_cfg(cfg)
    log("IndexNow settings saved from the dashboard.")
    return jsonify({"ok": True})


@app.get("/api/email-settings")
def email_settings_get():
    return jsonify({
        "smtp_host": os.environ.get("SMTP_HOST", ""),
        "smtp_port": os.environ.get("SMTP_PORT", "587"),
        "smtp_user": os.environ.get("SMTP_USER", ""),
        "has_password": bool(os.environ.get("SMTP_PASS")),
        "mail_from": os.environ.get("MAIL_FROM", ""),
        "mail_to": os.environ.get("MAIL_TO", ""),
        "has_slack": bool(os.environ.get("SLACK_WEBHOOK")),
    })


@app.post("/api/email-settings")
def email_settings_set():
    from dotenv import set_key
    d = request.json or {}
    _ensure_env_file()
    pairs = {
        "SMTP_HOST": d.get("smtp_host", ""),
        "SMTP_PORT": str(d.get("smtp_port") or 587),
        "SMTP_USER": d.get("smtp_user", ""),
        "MAIL_FROM": d.get("mail_from", ""),
        "MAIL_TO": d.get("mail_to", ""),
    }
    for k, v in pairs.items():
        if v:
            set_key(str(ENV_PATH), k, v)
            os.environ[k] = v
    if d.get("smtp_pass"):
        set_key(str(ENV_PATH), "SMTP_PASS", d["smtp_pass"])
        os.environ["SMTP_PASS"] = d["smtp_pass"]
    if d.get("slack_webhook"):
        set_key(str(ENV_PATH), "SLACK_WEBHOOK", d["slack_webhook"])
        os.environ["SLACK_WEBHOOK"] = d["slack_webhook"]
    log("Email/notification settings saved.")
    is_render = bool(os.environ.get("RENDER"))
    msg = ("Saved to .env and active immediately." if not is_render else
          "Saved, but on Render this only really takes effect if you also "
          "set these in Render's own Environment tab — env vars there "
          "override local files.")
    return jsonify({"ok": True, "message": msg})


@app.post("/api/email-settings/test")
def email_settings_test():
    if not os.environ.get("SMTP_HOST"):
        return jsonify({"ok": False,
                        "message": "No SMTP host configured yet — save settings first."})
    sent = approvals.notify("Test", {"test.html": ["test"]},
                            "http://example.invalid/approve/test", log=lambda m: None)
    if sent:
        return jsonify({"ok": True, "message": f"Test sent via {', '.join(sent)}. Check your inbox."})
    return jsonify({"ok": False,
                    "message": "Send failed — check the Activity log for the exact SMTP error."})


@app.get("/api/content-updates")
def content_updates_get():
    cfg = content_updates.load_cfg()
    gbp = cfg.get("google_business_profile", {})
    fb = cfg.get("social", {}).get("facebook", {})
    ig = cfg.get("social", {}).get("instagram", {})
    li = cfg.get("social", {}).get("linkedin", {})
    return jsonify({
        "enabled": bool(cfg.get("enabled", False)),
        "tone": cfg.get("content_rules", {}).get("tone", "professional"),
        "draft_only": cfg.get("social", {}).get("draft_only", []),
        "gbp": {
            "enabled": bool(gbp.get("enabled", False)),
            "location_id": gbp.get("location_id", ""),
            "has_client_id": bool(gbp.get("client_id")),
            "has_client_secret": bool(gbp.get("client_secret")),
            "has_refresh_token": bool(gbp.get("refresh_token")),
            "update_description": bool(gbp.get("update_description", True)),
            "post_new_update": bool(gbp.get("post_new_update", True)),
            "post_frequency_days": gbp.get("post_frequency_days", 7),
        },
        "facebook": {
            "enabled": bool(fb.get("enabled", False)),
            "page_id": fb.get("page_id", ""),
            "has_token": bool(fb.get("page_access_token")),
        },
        "instagram": {
            "enabled": bool(ig.get("enabled", False)),
            "ig_user_id": ig.get("ig_user_id", ""),
            "has_token": bool(ig.get("access_token")),
        },
        "linkedin": {
            "enabled": bool(li.get("enabled", False)),
            "organization_urn": li.get("organization_urn", ""),
            "has_token": bool(li.get("access_token")),
        },
        "blog": {
            "enabled": bool(cfg.get("blog", {}).get("enabled", False)),
            "frequency_days": cfg.get("blog", {}).get("frequency_days", 14),
        },
    })


@app.post("/api/content-updates")
def content_updates_set():
    import yaml
    d = request.json or {}
    cfg = content_updates.load_cfg()
    cfg["enabled"] = bool(d.get("enabled"))
    cfg.setdefault("content_rules", {})["tone"] = d.get("tone", "professional")

    gbp = cfg.setdefault("google_business_profile", {})
    gd = d.get("gbp", {})
    gbp["enabled"] = bool(gd.get("enabled"))
    gbp["location_id"] = (gd.get("location_id") or gbp.get("location_id", "")).strip()
    gbp["update_description"] = bool(gd.get("update_description", True))
    gbp["post_new_update"] = bool(gd.get("post_new_update", True))
    try:
        gbp["post_frequency_days"] = max(1, int(gd.get("post_frequency_days") or 7))
    except (TypeError, ValueError):
        gbp["post_frequency_days"] = 7
    for field in ("client_id", "client_secret", "refresh_token"):
        if gd.get(field):  # blank = "leave the saved value alone"
            gbp[field] = gd[field].strip()

    fb = cfg.setdefault("social", {}).setdefault("facebook", {})
    fd = d.get("facebook", {})
    fb["enabled"] = bool(fd.get("enabled"))
    fb["page_id"] = (fd.get("page_id") or fb.get("page_id", "")).strip()
    if fd.get("page_access_token"):
        fb["page_access_token"] = fd["page_access_token"].strip()

    ig = cfg["social"].setdefault("instagram", {})
    idata = d.get("instagram", {})
    ig["enabled"] = bool(idata.get("enabled"))
    ig["ig_user_id"] = (idata.get("ig_user_id") or ig.get("ig_user_id", "")).strip()
    if idata.get("access_token"):
        ig["access_token"] = idata["access_token"].strip()

    li = cfg["social"].setdefault("linkedin", {})
    ldata = d.get("linkedin", {})
    li["enabled"] = bool(ldata.get("enabled"))
    li["organization_urn"] = (ldata.get("organization_urn") or li.get("organization_urn", "")).strip()
    if ldata.get("access_token"):
        li["access_token"] = ldata["access_token"].strip()

    blog = cfg.setdefault("blog", {})
    bd = d.get("blog", {})
    blog["enabled"] = bool(bd.get("enabled"))
    try:
        blog["frequency_days"] = max(1, int(bd.get("frequency_days") or 14))
    except (TypeError, ValueError):
        blog["frequency_days"] = 14

    cfg["social"]["draft_only"] = [p for p in d.get("draft_only", [])
                                   if p in ("facebook", "instagram", "linkedin", "x")]

    content_updates.CU_CFG.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
    log("Content update settings saved from the dashboard.")
    return jsonify({"ok": True})


@app.get("/api/broken-links")
def broken_links_get():
    p = APP_DIR / "reports" / "broken_links.json"
    if not p.exists():
        return jsonify([])
    try:
        return jsonify(json.loads(p.read_text(encoding="utf-8")))
    except Exception:
        return jsonify([])


@app.post("/api/broken-links/apply")
def broken_links_apply():
    d = request.json or {}
    selected = set(d.get("urls") or [])
    if not selected:
        return jsonify({"ok": False, "message": "Nothing selected."})
    try:
        results = remediate.apply_selected_dead_links(selected, log=log)
        log(f"Removed {sum(len(v) for v in results.values())} broken link(s) "
           f"across {len(results)} page(s), as you selected.")
        return jsonify({"ok": True, "results": results})
    except Exception as e:
        log(f"Broken-link removal failed: {e}")
        return jsonify({"ok": False, "message": str(e)})


@app.get("/api/approvals/history")
def approvals_history():
    db = get_db()
    rows = db.q("SELECT id, site, created_at, status, summary, kind, note "
               "FROM approvals ORDER BY id DESC LIMIT 20")
    out = []
    for aid, site, created_at, status, summary, kind, note in rows:
        items = json.loads(summary)
        token = approvals.make_token(aid)  # fresh token, works regardless of status
        base = PUBLIC_URL or f"http://{_lan_ip()}:8765"
        out.append({"id": aid, "site": site, "created_at": created_at,
                    "status": status, "kind": kind or "remediation",
                    "n_items": len(items), "note": note or "",
                    "url": f"{base}/approve/{token}"})
    return jsonify(out)


@app.get("/api/approvals/pending")
def approvals_pending():
    db = get_db()
    rows = db.q("SELECT id, site, created_at, summary, kind FROM approvals "
               "WHERE status='pending' ORDER BY id DESC")
    out = []
    for aid, site, created_at, summary, kind in rows:
        items = json.loads(summary)
        token = approvals.make_token(aid)
        base = PUBLIC_URL or f"http://{_lan_ip()}:8765"
        out.append({"id": aid, "site": site, "created_at": created_at,
                    "kind": kind or "remediation", "n_items": len(items),
                    "url": f"{base}/approve/{token}"})
    return jsonify(out)


@app.post("/api/approvals/<int:aid>/resend")
def approvals_resend(aid):
    db = get_db()
    a = approvals.get_approval(db, aid)
    if not a or a["status"] != "pending":
        return jsonify({"ok": False, "message": "That approval is no longer pending."}), 400
    token = approvals.make_token(aid)
    base = PUBLIC_URL or f"http://{_lan_ip()}:8765"
    url = f"{base}/approve/{token}"
    sent = approvals.notify(a["site"], a["summary"], url, log, kind=a["kind"])
    if sent:
        msg = f"Resent via {', '.join(sent)}."
    else:
        msg = ("Email/Slack aren't reaching you right now, so nothing was "
              "actually delivered — use the link below directly instead.")
    return jsonify({"ok": bool(sent), "message": msg, "url": url})


def _propose_fixes(site: dict):
    """After an audit: dry-run the remediator; if fixes exist and none are
    already awaiting a decision, create an approval and send the link."""
    try:
        db = get_db()
        if approvals.has_pending(db, site["name"]):
            log("Fixes already awaiting your approval — not re-proposing.")
            return
        plan = remediate.execute(apply=False, log=log)
        if plan.get("note"):
            log(f"Auto-fix proposal skipped: {plan['note']}")
            return
        if not plan["summary"]:
            log("No auto-fixable issues found — site is clean on that front.")
            return
        aid = approvals.create_approval(db, site["name"],
                                        plan["summary"], plan["diff"])
        token = approvals.make_token(aid)
        base = PUBLIC_URL or f"http://{_lan_ip()}:8765"
        url = f"{base}/approve/{token}"
        log(f"Proposed fixes to {len(plan['summary'])} file(s) — approval #{aid} "
           f"(this is your TECHNICAL FIX email — canonical tags, meta, schema, etc.)")
        approvals.notify(site["name"], plan["summary"], url, log)
    except SystemExit as e:
        log(f"Auto-fix proposal skipped: {e}")
    except Exception as e:
        log(f"Auto-fix proposal error: {e}")


def _propose_content(site: dict):
    """After an audit: draft GBP/social content; if anything is due and
    nothing is already awaiting a decision, create an approval and notify."""
    try:
        db = get_db()
        if approvals.has_pending(db, site["name"], kind="content"):
            log("Content updates already awaiting your approval — not re-proposing.")
            return
        plan = content_updates.propose(site)
        if plan.get("note"):
            return  # content_updates.yaml disabled — silent, this is optional
        if not plan["summary"]:
            log("No content updates due yet.")
            return
        aid = approvals.create_approval(db, site["name"], plan["summary"],
                                        plan["diff"], kind="content")
        token = approvals.make_token(aid)
        base = PUBLIC_URL or f"http://{_lan_ip()}:8765"
        url = f"{base}/approve/{token}"
        log(f"Drafted {len(plan['summary'])} content update(s) — approval #{aid} "
           f"(this is a SEPARATE, different email — Google Business Profile/"
           f"social/blog, not code fixes. You may get both after one audit; "
           f"that's expected, not a repeat run.)")
        approvals.notify(site["name"], plan["summary"], url, log, kind="content")
    except Exception as e:
        log(f"Content proposal error: {e}")


APPROVE_PAGE = """<!doctype html><html><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Approve fixes — {site}</title><style>
body{{background:#12151a;color:#eae6dc;font:15px/1.5 -apple-system,Segoe UI,sans-serif;max-width:760px;margin:0 auto;padding:1.2rem}}
h1{{font-size:1.15rem}} .sum{{background:#1a1e26;border:1px solid #2a2f3a;border-radius:12px;padding:1rem;margin:1rem 0}}
pre{{background:#0e1114;border-radius:9px;padding:.8rem;font-size:.72rem;overflow-x:auto;max-height:420px;white-space:pre-wrap}}
button{{border:0;border-radius:10px;padding:.8rem 1.6rem;font-weight:700;font-size:1rem;cursor:pointer;margin-right:.8rem}}
.ok{{background:#c9a35a;color:#241a00}} .no{{background:#2a1214;color:#c1584f;border:1px solid #c1584f}}
.badge{{display:inline-block;padding:.2rem .6rem;border-radius:6px;background:#1a1e26;font-size:.8rem}}
.section{{background:#1a1e26;border:1px solid #2a2f3a;border-radius:10px;padding:1rem;margin:.8rem 0}}
.section h3{{margin:0 0 .5rem;font-size:.95rem;color:#c9a35a}}
.section .body{{white-space:pre-wrap;font-size:.88rem;background:#0e1114;border-radius:7px;padding:.7rem}}
.copybtn{{background:#2f6f5c;color:#f2efe4;font-size:.78rem;padding:.4rem .8rem;margin-top:.5rem;border-radius:7px}}
.copied{{color:#c9a35a;font-size:.78rem;margin-left:.5rem}}
</style></head><body>
<h1>Proposed {kindlabel} for {site}</h1>
<p class='badge'>Status: {status} &middot; proposed {created}</p>
<div class='sum'><b>{nfiles} item(s):</b><ul>{items}</ul></div>
{buttons}
{sections_or_diff}
<script>
function copySection(i){{
  const el = document.getElementById('sec-'+i);
  navigator.clipboard.writeText(el.textContent).then(()=>{{
    document.getElementById('copied-'+i).textContent = '\u2713 Copied';
    setTimeout(()=>document.getElementById('copied-'+i).textContent='', 2000);
  }});
}}
</script>
</body></html>"""


def _render_content_sections(diff_text: str) -> str:
    """Splits a content-update diff (built from '--- label ---' blocks) into
    separate, clearly labeled, copy-buttoned cards — instead of one dense
    diff blob nobody can tell platforms apart in."""
    import html as _h
    import re as _re
    if not diff_text.strip():
        return "<p>(nothing to show)</p>"
    parts = _re.split(r"\n\n(?=--- )", diff_text.strip())
    out = []
    for i, part in enumerate(parts):
        m = _re.match(r"--- (.*?) ---\n(.*)", part, _re.S)
        label, body = (m.group(1), m.group(2).strip()) if m else ("Details", part.strip())
        out.append(
            f"<div class='section'><h3>{_h.escape(label)}</h3>"
            f"<div class='body' id='sec-{i}'>{_h.escape(body)}</div>"
            f"<button class='copybtn' onclick='copySection({i})' type='button'>Copy this text</button>"
            f"<span class='copied' id='copied-{i}'></span></div>")
    return "\n".join(out)


@app.get("/approve/<token>")
def approve_view(token):
    aid = approvals.verify_token(token)
    if aid is None:
        return "Link invalid or expired.", 403
    a = approvals.get_approval(get_db(), aid)
    if a is None:
        return "Approval not found.", 404
    items = "".join(f"<li>{f}: {', '.join(x)}</li>"
                    for f, x in a["summary"].items())
    buttons = ""
    if a["status"] == "pending":
        buttons = (f"<form method='post' action='/approve/{token}/decision' "
                   f"style='display:inline'><button class='ok' name='d' "
                   f"value='approve'>Approve &amp; apply</button>"
                   f"<button class='no' name='d' value='reject'>Reject</button></form>")
    is_content = a["kind"] == "content"
    if is_content:
        sections_or_diff = _render_content_sections(a["diff"])
    else:
        import html as _h
        sections_or_diff = (f"<h3>Exact changes (unified diff)</h3>"
                           f"<pre>{_h.escape(a['diff'][:60000] or '(no diff)')}</pre>")
    import html as _h
    return render_template_string(APPROVE_PAGE.format(
        site=_h.escape(a["site"]), status=a["status"], created=a["created_at"],
        nfiles=len(a["summary"]), items=items, buttons=buttons,
        kindlabel=("content updates" if is_content else "fixes"),
        sections_or_diff=sections_or_diff))


@app.post("/approve/<token>/decision")
def approve_decide(token):
    aid = approvals.verify_token(token)
    if aid is None:
        return "Link invalid or expired.", 403
    db = get_db()
    a = approvals.get_approval(db, aid)
    if a is None or a["status"] != "pending":
        return "Already decided.", 409
    if request.form.get("d") == "reject":
        approvals.set_status(db, aid, "rejected")
        log(f"Approval #{aid} rejected.")
        return "Rejected — nothing was changed. You can close this page."
    approvals.set_status(db, aid, "approved")
    log(f"Approval #{aid} approved — applying…")

    if a["kind"] == "content":
        def job():
            try:
                cfg = runner.load_config(str(CONFIG_PATH))
                site = next(s for s in cfg["sites"] if s["name"] == a["site"])
                res = content_updates.apply(site)
                approvals.set_status(db, aid, "applied", json.dumps(res))
                log(f"Approval #{aid}: content updates applied — {res}")
            except Exception as e:
                approvals.set_status(db, aid, "failed", str(e))
                log(f"Approval #{aid} APPLY FAILED: {e}")

        threading.Thread(target=job, daemon=True).start()
        return ("Approved. Publishing to Google Business Profile / social now "
                "where auto-publish is configured; draft-only items are not "
                "auto-posted — copy that text yourself. You can close this page.")

    def job():
        try:
            res = remediate.execute(apply=True, log=log)
            approvals.set_status(db, aid, "applied",
                                 f"{len(res['summary'])} file(s); backup {res['backup']}")
            log(f"Approval #{aid}: applied fixes to {len(res['summary'])} "
                f"file(s). Backup: {res['backup']}")
            log("Running verification audit…")
            threading.Thread(target=_audit_job, daemon=True).start()
        except Exception as e:
            approvals.set_status(db, aid, "failed", str(e))
            log(f"Approval #{aid} APPLY FAILED: {e}")

    threading.Thread(target=job, daemon=True).start()
    return ("Approved. Fixes are being applied now (a backup is taken first), "
            "followed by an automatic verification audit. You can close this page.")


@app.get("/download")
def download():
    p = request.args.get("path", "")
    fp = Path(p).resolve()
    # only serve files from inside the app's reports dir
    if not str(fp).startswith(str((APP_DIR / "reports").resolve())) or not fp.exists():
        return "Not found", 404
    return send_file(fp, as_attachment=fp.suffix != ".html")


@app.get("/manifest.json")
def manifest():
    return jsonify({
        "name": "SEO + AI Optimizer", "short_name": "SEO-AI",
        "start_url": "/", "display": "standalone",
        "background_color": "#12151a", "theme_color": "#12151a",
        "icons": [{"src": "/icon.svg", "sizes": "any", "type": "image/svg+xml"}],
    })


@app.get("/icon.svg")
def icon():
    svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
           '<rect width="100" height="100" rx="20" fill="#12151a"/>'
           '<circle cx="50" cy="50" r="26" fill="none" stroke="#c9a35a" stroke-width="7"/>'
           '<line x1="68" y1="68" x2="86" y2="86" stroke="#c9a35a" stroke-width="9" stroke-linecap="round"/>'
           '<path d="M38 52l8 8 16-18" fill="none" stroke="#2f9c81" stroke-width="7" '
           'stroke-linecap="round" stroke-linejoin="round"/></svg>')
    return svg, 200, {"Content-Type": "image/svg+xml"}


@app.get("/")
def index():
    return render_template_string(PAGE)


# ----------------------------- UI -----------------------------
PAGE = r"""<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SEO + AI Optimizer</title>
<link rel="manifest" href="/manifest.json"><link rel="icon" href="/icon.svg">
<meta name="theme-color" content="#0e1a1c">
<style>
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,380;0,9..144,560;1,9..144,460&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root{
  --ink:#12151a; --panel:#1a1e26; --panel2:#0e1114; --line:#2a2f3a;
  --text:#eae6dc; --dim:#8c9099; --teal:#c9a35a; --amber:#2f6f5c; --red:#c1584f;
  --serif:"Fraunces",Iowan,Georgia,serif; --sans:"Inter",-apple-system,"Segoe UI",sans-serif;
  --mono:"IBM Plex Mono","SF Mono",Consolas,monospace;
}
*{box-sizing:border-box;margin:0}
body{background:var(--ink);color:var(--text);font:15px/1.6 var(--sans);
  padding-bottom:4rem;-webkit-font-smoothing:antialiased}
header{padding:1.35rem 1.2rem 1.1rem;border-bottom:1px solid var(--line);
  display:flex;align-items:center;gap:.8rem;position:sticky;top:0;background:var(--ink);z-index:5}
header img{width:32px;height:32px}
h1{font-family:var(--serif);font-size:1.28rem;font-weight:560;letter-spacing:.01em}
h1 small{display:block;font-family:var(--sans);font-size:.72rem;font-weight:400;
  color:var(--dim);letter-spacing:.03em;margin-top:.15rem}
main{max-width:720px;margin:0 auto;padding:1.1rem 1.2rem}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;
  padding:1.25rem 1.3rem;margin:0 0 1rem;position:relative}
.card h2{font-size:.72rem;text-transform:uppercase;letter-spacing:.14em;color:var(--dim);
  margin-bottom:.9rem;font-weight:600;padding-bottom:.6rem;border-bottom:1px solid var(--line)}
label{display:block;font-size:.79rem;color:var(--dim);margin:.7rem 0 .3rem}
input,select{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--text);
  border-radius:7px;padding:.6rem .7rem;font-size:.93rem;font-family:var(--sans)}
input::placeholder{color:#565b64}
input:focus,select:focus{outline:1.5px solid var(--teal);outline-offset:1px;border-color:transparent}
.row{display:grid;grid-template-columns:1fr 1fr;gap:.8rem}
.mods{display:grid;grid-template-columns:1fr 1fr;gap:.35rem .8rem;margin-top:.6rem}
.mods label{display:flex;align-items:center;gap:.5rem;margin:0;font-size:.85rem;color:var(--text)}
.mods input{width:auto;accent-color:var(--teal)}
button{border:0;border-radius:7px;padding:.72rem 1.2rem;font-size:.8rem;font-weight:600;
  cursor:pointer;font-family:var(--sans);text-transform:uppercase;letter-spacing:.07em}
.primary{background:var(--amber);color:#f2efe4;width:100%;margin-top:1rem}
.primary:disabled{opacity:.45;cursor:default}
.ghost{background:var(--panel2);color:var(--text);border:1px solid var(--line)}
.actions{display:flex;gap:.7rem;align-items:center;margin-top:.4rem;flex-wrap:wrap}
.switch{display:flex;align-items:center;gap:.55rem;font-size:.85rem;color:var(--dim);
  text-transform:none;letter-spacing:0}
.switch input{width:auto;transform:scale(1.3);accent-color:var(--teal)}
#gauge{display:flex;align-items:center;gap:1.4rem}
.ring{position:relative;width:126px;height:126px;border-radius:50%;flex-shrink:0;
  background:conic-gradient(var(--teal) calc(var(--pct,0)*1%),var(--line) 0);
  transition:background 1s ease}
.ring::before{content:"";position:absolute;inset:9px;border-radius:50%;background:var(--panel)}
.big{position:relative;z-index:1;width:100%;height:100%;display:flex;align-items:center;
  justify-content:center;font-family:var(--serif);font-size:2.1rem;font-weight:560;color:var(--teal)}
.big small{font-family:var(--sans);font-size:.68rem;color:var(--dim);font-weight:500;margin-left:.15rem}
.bars{flex:1;min-width:0}
.bar{margin:.5rem 0}
.bar .t{display:flex;justify-content:space-between;font-size:.78rem;color:var(--dim)}
.track{background:var(--panel2);border-radius:5px;height:7px;margin-top:4px;overflow:hidden}
.fill{height:7px;border-radius:5px;background:var(--teal);transition:width .6s}
ul{padding-left:1.1rem}li{margin:.45rem 0;font-size:.9rem}
.issue{color:var(--red)}
.delta{font-family:var(--mono);font-size:.78rem;margin-left:.4rem}
.up{color:var(--teal)}.down{color:var(--red)}
#log{background:var(--panel2);border-radius:7px;padding:.7rem .8rem;font-family:var(--mono);
  font-size:.73rem;color:var(--dim);max-height:150px;overflow-y:auto;white-space:pre-wrap}
.dl{display:inline-block;margin:.3rem .6rem .1rem 0;color:var(--teal);font-size:.85rem;text-decoration:none;
  border-bottom:1px dashed var(--teal)}
.spin{display:inline-block;width:13px;height:13px;border:2px solid #f2efe4;border-top-color:transparent;
  border-radius:50%;animation:s 0.8s linear infinite;vertical-align:-2px;margin-right:.4rem}
@keyframes s{to{transform:rotate(360deg)}}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(1.3)}}
.dot-working{background:#3ecf6a !important;animation:pulse 1s ease-in-out infinite}
.hidden{display:none}
.note{font-size:.78rem;color:var(--dim);margin-bottom:.6rem;line-height:1.55}
fieldset{border:1px solid var(--line);border-radius:8px;padding:.9rem 1rem 1rem;margin-top:.9rem}
legend{font-size:.76rem;text-transform:uppercase;letter-spacing:.1em;color:var(--dim);
  padding:0 .4rem;font-weight:600}
.chiprow{display:flex;gap:1rem;flex-wrap:wrap;margin-top:.6rem}
.chiprow label{display:flex;align-items:center;gap:.45rem;margin:0;font-size:.85rem;
  color:var(--text);text-transform:none;letter-spacing:0}
.chiprow input{width:auto;accent-color:var(--teal)}
.saved-badge{color:var(--amber);font-size:.72rem;margin-left:.4rem}
details summary{cursor:pointer;font-size:.83rem;color:var(--dim);margin-top:.7rem}
@media(max-width:560px){.row{grid-template-columns:1fr}.mods{grid-template-columns:1fr}
  #gauge{flex-direction:column;align-items:flex-start;gap:.7rem}}

/* --- light theme override (toggled via data-theme on <html>) --- */
[data-theme="light"]{
  --ink:#f6f4ee; --panel:#ffffff; --panel2:#f1efe6; --line:#ddd6c4;
  --text:#20242a; --dim:#6c7078; --teal:#9c7830; --amber:#2f6f5c; --red:#b23b32;
}
[data-theme="light"] input::placeholder{color:#9a978c}
[data-theme="light"] .spin{border-color:#f2efe4}

/* --- sidebar + tabs shell --- */
.shell{display:flex;align-items:flex-start}
.sidebar{width:212px;flex-shrink:0;min-height:100vh;border-right:1px solid var(--line);
  position:sticky;top:0;display:flex;flex-direction:column;padding:.9rem .7rem;gap:.2rem}
.navbtn{background:transparent;color:var(--dim);text-align:left;width:100%;text-transform:none;
  letter-spacing:0;font-size:.88rem;font-weight:500;padding:.65rem .8rem;border-radius:7px}
.navbtn.active{background:var(--panel2);color:var(--text)}
.navbtn:hover{color:var(--text)}
.side-spacer{flex:1}
.themeToggle{background:var(--panel2);color:var(--text);border:1px solid var(--line);
  text-transform:none;letter-spacing:0;font-size:.8rem;width:100%}
.subnav{display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1rem}
.subbtn{background:var(--panel2);color:var(--dim);text-transform:none;letter-spacing:0;
  font-size:.82rem;font-weight:500;padding:.5rem .9rem;border-radius:20px;border:1px solid var(--line)}
.subbtn.active{background:var(--teal);color:var(--ink);border-color:var(--teal)}
main{max-width:760px;flex:1;margin:0 auto;padding:1.1rem 1.4rem}
@media(max-width:800px){
  .shell{flex-direction:column}
  .sidebar{width:100%;min-height:auto;position:static;flex-direction:row;
    border-right:0;border-bottom:1px solid var(--line);overflow-x:auto}
  .side-spacer{display:none}
  .themeToggle{width:auto;flex-shrink:0}
}
</style></head><body>
<div class="shell">
<nav class="sidebar">
  <button class="navbtn active" data-tab="dash">Dashboard</button>
  <button class="navbtn" data-tab="settings">Settings</button>
  <div class="side-spacer"></div>
  <button class="themeToggle" id="themeBtn">🌙 Dark</button>
</nav>
<div style="flex:1;min-width:0">
<header><img src="/icon.svg" alt=""><h1>SEO + AI Optimizer<small>rank on Google &middot; get cited by ChatGPT, Claude &amp; Perplexity</small></h1>
<div id="statusPill" style="margin-left:auto;display:flex;align-items:center;gap:.5rem;
  padding:.4rem .8rem;border-radius:20px;background:var(--panel2);border:1px solid var(--line);
  font-size:.78rem;color:var(--dim);white-space:nowrap">
  <span id="statusDot" style="width:9px;height:9px;border-radius:50%;background:var(--dim);
    display:inline-block"></span>
  <span id="statusText">Idle</span>
</div></header>
<main>

<div class="tabpanel" id="tab-dash">
<div class="card"><h2>Your website</h2>
  <label for="url">Website address</label><input id="url" placeholder="https://yourbusiness.com">
  <div class="row">
    <div><label for="name">Business name</label><input id="name" placeholder="My Business"></div>
    <div><label for="btype">Business type</label>
      <select id="btype"><option>Organization</option><option>LocalBusiness</option>
      <option>Store</option><option>Restaurant</option><option>ProfessionalService</option></select></div>
  </div>
  <label for="kw">Keywords you want to rank for <span style="color:var(--dim)">(comma separated)</span></label>
  <input id="kw" placeholder="best coffee shop bangalore, specialty coffee">
  <div class="row">
    <div><label for="loc">Locations served</label><input id="loc" placeholder="Bengaluru, Karnataka"></div>
    <div><label for="mp">Pages to scan (1–100)</label><input id="mp" type="number" value="25" min="1" max="100"></div>
  </div>
  <details style="margin-top:.8rem"><summary style="cursor:pointer;font-size:.85rem;color:var(--dim)">Advanced: choose checks</summary>
    <div class="mods" id="mods"></div>
  </details>
  <button class="primary" id="run">Run audit</button>
</div>

<div class="card"><h2>Keep it ranking — auto re-check</h2>
  <div class="actions">
    <label class="switch"><input type="checkbox" id="sched"> Re-audit automatically every
      <input id="ivl" type="number" value="24" min="1" style="width:70px"> hours</label>
  </div>
  <p id="nextrun" style="font-size:.78rem;color:var(--dim);margin-top:.5rem"></p>
</div>

<div class="card hidden" id="resCard"><h2>Results — <span id="rsite"></span></h2>
  <div id="gauge">
    <div class="ring" id="ring"><div class="big" id="overall">–<small>/100</small></div></div>
    <div class="bars" id="bars"></div>
  </div>
  <div id="dls" style="margin-top:.9rem"></div>
  <h2 style="margin-top:1.2rem">Do these next</h2><ul id="recs"></ul>
  <h2 style="margin-top:1.2rem">Issues found</h2><ul id="issues"></ul>

  <div id="hostingBox" class="hidden" style="margin-top:1rem;padding:.9rem 1rem;
    background:var(--panel2);border:1px solid var(--line);border-radius:10px">
    <p class="note" style="margin-bottom:.5rem"><strong style="color:var(--text)">
      Some issues here aren't a code fix — they're a hosting question.</strong>
      No file to edit; just forward this to your host's support (email/chat), zero
      technical work on your end.</p>
    <button class="ghost" id="copyHostingMsg">Copy message for my host</button>
    <span class="note" id="hostingCopyStatus"></span>
  </div>

  <h2 style="margin-top:1.2rem">Broken links <span style="color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">— your call, one by one</span></h2>
  <p class="note">These pages returned "not found" during your last audit. Tick the ones
    you want removed from your site's links (the visible text stays, only the dead link is
    removed) — nothing changes until you click Update below. Leave unticked to keep as-is.</p>
  <div id="brokenLinks"></div>
  <button class="ghost" id="applyBrokenLinks" style="margin-top:.6rem">Update selected links on my server</button>
  <p class="note" id="brokenLinksResult"></p>
</div>

<div class="card"><h2>What changed on your site <span style="color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">— and which deploy did it</span></h2>
  <p style="font-size:.78rem;color:var(--dim);margin-bottom:.6rem">Every audit fingerprints each page (title, canonical, schema, noindex…). Changes are matched to deploys your CI reports to <code style="color:var(--teal)">/webhook/deploy</code>.</p>
  <div id="changes"><span style="color:var(--dim);font-size:.85rem">No changes recorded yet — appears from your second audit onward.</span></div>
</div>

<div class="card"><h2>Pending approvals</h2>
  <p class="note">Anything waiting on your Approve/Reject click shows here —
    use Resend if the email never arrived, or just copy the link directly.</p>
  <div id="pendingList"></div>
</div>

<div class="card"><h2>Approval history</h2>
  <p class="note">Already-decided approvals stay here too — including the exact caption text
    for content updates — so nothing is ever only in an email you might have lost.</p>
  <div id="historyList"></div>
</div>

<div class="card"><h2>Activity</h2><div id="log">Ready. Enter your website above and press Run audit.</div></div>
</div><!-- /tab-dash -->

<div class="tabpanel hidden" id="tab-settings">
<div class="subnav">
  <button class="subbtn active" data-sub="ftp">Auto-fix connection</button>
  <button class="subbtn" data-sub="content">Content updates</button>
  <button class="subbtn" data-sub="citations">AI citations</button>
  <button class="subbtn" data-sub="email">Email &amp; notifications</button>
  <button class="subbtn" data-sub="facts">Business facts</button>
  <button class="subbtn" data-sub="indexnow">IndexNow</button>
</div>

<div class="subpanel" id="sub-ftp">
<div class="card"><h2>Auto-fix connection — where fixes get uploaded</h2>
  <p class="note">This is where technical SEO fixes (canonical tags, missing meta
    descriptions, schema, robots.txt…) get uploaded once you approve them by
    email. Nothing is ever uploaded without your Approve click.</p>
  <label for="rm_conn">Connect using</label>
  <select id="rm_conn"><option value="ftp">FTP / FTPS (most cPanel hosts)</option>
    <option value="sftp">SFTP</option>
    <option value="local">Local folder on this computer/server</option></select>

  <div id="rm_ftp_box">
    <div class="row">
      <div><label for="rm_ftp_host">FTP host</label><input id="rm_ftp_host" placeholder="ftp.yourhost.com"></div>
      <div><label for="rm_ftp_dir">Remote folder</label><input id="rm_ftp_dir" placeholder="/public_html"></div>
    </div>
    <div class="row">
      <div><label for="rm_ftp_user">FTP username</label><input id="rm_ftp_user" placeholder="username"></div>
      <div><label for="rm_ftp_pass">FTP password <span id="b_ftp_pass" class="saved-badge hidden">saved ✓</span></label>
        <input id="rm_ftp_pass" type="password" placeholder="leave blank to keep saved value"></div>
    </div>
    <label class="switch" style="margin-top:.6rem"><input type="checkbox" id="rm_ftp_tls" checked> Use FTPS (secure) — leave on unless your host doesn't support it</label>
  </div>

  <div id="rm_sftp_box" class="hidden">
    <div class="row">
      <div><label for="rm_sftp_host">SFTP host</label><input id="rm_sftp_host" placeholder="sftp.yourhost.com"></div>
      <div><label for="rm_sftp_port">Port</label><input id="rm_sftp_port" type="number" value="22"></div>
    </div>
    <div class="row">
      <div><label for="rm_sftp_user">SFTP username</label><input id="rm_sftp_user" placeholder="username"></div>
      <div><label for="rm_sftp_pass">SFTP password <span id="b_sftp_pass" class="saved-badge hidden">saved ✓</span></label>
        <input id="rm_sftp_pass" type="password" placeholder="leave blank to keep saved value"></div>
    </div>
    <label for="rm_sftp_dir">Remote folder</label><input id="rm_sftp_dir" placeholder="/public_html">
  </div>

  <div id="rm_local_box" class="hidden">
    <label for="rm_local_path">Folder path</label><input id="rm_local_path" placeholder="./site-copy">
  </div>

  <details><summary>Advanced: which fixes are allowed</summary>
    <div class="mods" id="rm_fixes"></div>
    <label for="rm_maxfiles" style="margin-top:.6rem">Safety brake — refuse if more than this many files would change</label>
    <input id="rm_maxfiles" type="number" value="200" min="1">
  </details>
  <div class="actions">
    <button class="ghost" id="saveRM">Save auto-fix connection</button>
    <button class="ghost" id="testRM">Test connection</button>
  </div>
  <p class="note" id="rmTestResult"></p>
</div>
</div><!-- /sub-ftp -->

<div class="subpanel hidden" id="sub-content">
<div class="card"><h2>Content updates — Google Business Profile &amp; social</h2>
  <p class="note">When this is on, every scheduled audit also drafts a fresh Google
    Business Profile description/post and social captions, then emails you an
    approval link — nothing publishes until you click Approve there.</p>
  <label class="switch"><input type="checkbox" id="cu_on"> Enable content updates</label>

  <label for="cu_tone">Writing tone</label>
  <select id="cu_tone"><option value="professional">Professional</option>
    <option value="friendly">Friendly</option><option value="bold">Bold</option></select>

  <fieldset><legend>Google Business Profile</legend>
    <label class="switch"><input type="checkbox" id="gbp_on"> Publish to Google Business Profile automatically</label>
    <p class="note" style="margin-top:.5rem">Needs a one-time Google API approval before this can go live —
      see DEPLOY.md. Until then it still drafts the text into your approval email.</p>
    <div class="row">
      <div><label for="gbp_loc">Location ID</label><input id="gbp_loc" placeholder="accounts/123/locations/456"></div>
      <div><label for="gbp_freq">New post every (days)</label><input id="gbp_freq" type="number" value="7" min="1"></div>
    </div>
    <div class="row">
      <div><label for="gbp_cid">Client ID <span id="b_cid" class="saved-badge hidden">saved ✓</span></label>
        <input id="gbp_cid" type="password" placeholder="leave blank to keep saved value"></div>
      <div><label for="gbp_cs">Client secret <span id="b_cs" class="saved-badge hidden">saved ✓</span></label>
        <input id="gbp_cs" type="password" placeholder="leave blank to keep saved value"></div>
    </div>
    <label for="gbp_rt">Refresh token <span id="b_rt" class="saved-badge hidden">saved ✓</span></label>
    <input id="gbp_rt" type="password" placeholder="leave blank to keep saved value">
    <div class="chiprow">
      <label><input type="checkbox" id="gbp_desc"> Keep description up to date</label>
      <label><input type="checkbox" id="gbp_post"> Post updates on schedule</label>
    </div>
  </fieldset>

  <fieldset><legend>Facebook Page</legend>
    <label for="fb_mode">How should Facebook posts work?</label>
    <select id="fb_mode">
      <option value="off">Off \u2014 don't post to Facebook</option>
      <option value="manual">Manual \u2014 email me a ready caption to paste myself</option>
      <option value="auto">Automatic \u2014 post it for me (needs the details below)</option>
    </select>
    <p class="note" style="margin-top:.5rem">Automatic posts include a generated branded image.</p>
    <div class="row">
      <div><label for="fb_pid">Page ID</label><input id="fb_pid" placeholder="10015…"></div>
      <div><label for="fb_tok">Page access token <span id="b_tok" class="saved-badge hidden">saved ✓</span></label>
        <input id="fb_tok" type="password" placeholder="leave blank to keep saved value"></div>
    </div>
  </fieldset>

  <fieldset><legend>Instagram Business</legend>
    <label for="ig_mode">How should Instagram posts work?</label>
    <select id="ig_mode">
      <option value="off">Off \u2014 don't post to Instagram</option>
      <option value="manual">Manual \u2014 email me a ready caption to paste myself</option>
      <option value="auto">Automatic \u2014 post it for me (needs the details below)</option>
    </select>
    <p class="note" style="margin-top:.5rem">Automatic needs your own Instagram Business account
      linked to a Facebook Page, and Meta's app-review approval for content publishing — same
      one-time process as Google Business Profile. Until that's approved, pick Manual instead.</p>
    <div class="row">
      <div><label for="ig_uid">Instagram Business User ID</label><input id="ig_uid" placeholder="1789..."></div>
      <div><label for="ig_tok">Access token <span id="b_igtok" class="saved-badge hidden">saved ✓</span></label>
        <input id="ig_tok" type="password" placeholder="leave blank to keep saved value"></div>
    </div>
  </fieldset>

  <fieldset><legend>LinkedIn Page</legend>
    <label for="li_mode">How should LinkedIn posts work?</label>
    <select id="li_mode">
      <option value="off">Off \u2014 don't post to LinkedIn</option>
      <option value="manual">Manual \u2014 email me a ready caption to paste myself</option>
      <option value="auto">Automatic \u2014 post it for me (needs the details below)</option>
    </select>
    <p class="note" style="margin-top:.5rem">Automatic needs your own LinkedIn Company Page and a
      Marketing API app approved by LinkedIn (their own review process). Text-only for now — image
      posts aren't wired up yet. Until approved, pick Manual instead.</p>
    <div class="row">
      <div><label for="li_org">Organization URN</label><input id="li_org" placeholder="urn:li:organization:12345"></div>
      <div><label for="li_tok">Access token <span id="b_litok" class="saved-badge hidden">saved ✓</span></label>
        <input id="li_tok" type="password" placeholder="leave blank to keep saved value"></div>
    </div>
  </fieldset>

  <fieldset><legend>X (Twitter)</legend>
    <label class="switch"><input type="checkbox" id="x_on"> Email me a ready caption to paste myself</label>
    <p class="note" style="margin-top:.5rem">No automatic option for X yet — their posting API's
      pricing/rules change often enough that it isn't reliably worth building against. Manual
      (copy/paste) is the only mode here for now.</p>
  </fieldset>
  <fieldset><legend>Auto-blog</legend>
    <label class="switch"><input type="checkbox" id="blog_on"> Publish a new blog post automatically</label>
    <p class="note" style="margin-top:.5rem">Publishes over the same FTP/SFTP
      connection as your auto-fixes, as a new page under /blog/, styled to match
      your site's own header, footer, and CSS automatically. Text is
      built from your own keywords and locations, not invented — review the
      draft in your approval email before it goes live.</p>
    <label for="blog_freq">New post every (days)</label>
    <input id="blog_freq" type="number" value="14" min="1">
  </fieldset>
  <button class="ghost" id="saveCU" style="width:100%;margin-top:1rem">Save content update settings</button>
</div>
</div><!-- /sub-content -->

<div class="subpanel hidden" id="sub-citations">
<div class="card"><h2>AI citations — does ChatGPT / Claude / Perplexity recommend you?</h2>
  <p class="note">These checks call each engine's own paid API per question (there's no free
    programmatic equivalent to ChatGPT/Claude/Perplexity themselves — see the note under Email &amp;
    notifications tab intro, or ask in chat for the honest breakdown). Typical cost with default models
    is a fraction of a cent per question. Only engines with a key below get queried.</p>
  <div id="citKeys">
    <div class="row" style="margin-top:.4rem">
      <div><label>OpenAI key (ChatGPT)</label><input id="k_openai" type="password" placeholder="sk-…"></div>
      <div><label>Anthropic key (Claude)</label><input id="k_anthropic" type="password" placeholder="sk-ant-…"></div>
    </div>
    <div class="row" style="margin-top:.4rem">
      <div><label>Perplexity key</label><input id="k_perplexity" type="password" placeholder="pplx-…"></div>
      <div style="display:flex;align-items:flex-end"><button class="ghost" style="width:100%" id="saveKeys">Save keys</button></div>
    </div>
  </div>
  <button class="primary" id="runCit" style="margin-top:.8rem">Check AI answers now</button>
  <div id="citBoard" style="margin-top:.9rem"></div>
</div>
</div><!-- /sub-citations -->

<div class="subpanel hidden" id="sub-email">
<div class="card"><h2>Email &amp; notifications</h2>
  <p class="note">Controls where approval-request emails get sent. Locally, this writes straight to
    your <code>.env</code> file — no editing it by hand. On Render/cloud hosts, environment variables
    still need to be set in that platform's own dashboard (that's a hosting-platform rule, not
    something this app can work around).</p>
  <div class="row">
    <div><label for="em_host">SMTP host</label><input id="em_host" placeholder="smtp.gmail.com"></div>
    <div><label for="em_port">SMTP port</label><input id="em_port" type="number" value="587"></div>
  </div>
  <div class="row">
    <div><label for="em_user">SMTP username</label><input id="em_user" placeholder="you@gmail.com"></div>
    <div><label for="em_pass">SMTP password <span id="b_empass" class="saved-badge hidden">saved ✓</span></label>
      <input id="em_pass" type="password" placeholder="leave blank to keep saved value"></div>
  </div>
  <div class="row">
    <div><label for="em_from">From address</label><input id="em_from" placeholder="you@gmail.com"></div>
    <div><label for="em_to">Send approvals to</label><input id="em_to" placeholder="you@gmail.com"></div>
  </div>
  <label for="em_slack">Slack webhook URL (optional alternative to email)</label>
  <input id="em_slack" type="password" placeholder="leave blank to keep saved value">
  <div class="actions">
    <button class="ghost" id="saveEmail">Save email settings</button>
    <button class="ghost" id="testEmail">Send test email</button>
  </div>
  <p class="note" id="emailTestResult"></p>
</div>
</div><!-- /sub-email -->

<div class="subpanel hidden" id="sub-facts">
<div class="card"><h2>Business facts — the real information behind automation</h2>
  <p class="note">This is what turns "needs manual content work" into "auto-fixable."
    Everything below starts blank on purpose — the tool will never invent a phone number, a
    review, a stat, or an FAQ answer for you. Fill in only what's real; leave the rest blank.
    Once something's filled in here, the matching item in your audit report becomes
    auto-fixable and goes through the same approve-by-email flow as everything else.</p>

  <label for="bf_phone">Real phone number (must already appear as text somewhere on your site)</label>
  <input id="bf_phone" placeholder="+91 98765 43210">

  <label for="bf_stats">One real, true sentence about your business</label>
  <input id="bf_stats" placeholder="e.g. 400+ clients served since 2018">

  <fieldset><legend>Real testimonials (only ones already visible on your site)</legend>
    <div id="bf_testimonials"></div>
    <button class="ghost" type="button" id="bf_addTestimonial" style="margin-top:.5rem">+ Add a testimonial</button>
  </fieldset>

  <fieldset><legend>Author byline</legend>
    <div class="row">
      <div><label for="bf_author_name">Real name</label><input id="bf_author_name" placeholder="e.g. Rakesh Kumar"></div>
      <div><label for="bf_author_cred">Real credential (optional)</label><input id="bf_author_cred" placeholder="e.g. Founder, AppSage"></div>
    </div>
  </fieldset>

  <fieldset><legend>Real FAQ — questions customers actually ask you</legend>
    <div id="bf_faqs"></div>
    <button class="ghost" type="button" id="bf_addFaq" style="margin-top:.5rem">+ Add a question</button>
  </fieldset>

  <button class="ghost" id="saveFacts" style="width:100%;margin-top:1rem">Save business facts</button>
</div>
</div><!-- /sub-facts -->

<div class="subpanel hidden" id="sub-indexnow">
<div class="card"><h2>IndexNow — instant re-crawl pings</h2>
  <p class="note">When a fix changes your pages, this pings Bing and Yandex directly so they
    re-crawl the change in minutes instead of waiting weeks. (Google doesn't participate in
    IndexNow — this doesn't affect Google specifically, but it's free and instant for the
    engines that do.)</p>
  <label class="switch"><input type="checkbox" id="in_on"> Enable IndexNow</label>
  <label for="in_mode">Verification key file</label>
  <select id="in_mode">
    <option value="auto">Auto \u2014 upload the key file for me automatically</option>
    <option value="manual">Manual \u2014 I'll upload it myself, once</option>
    <option value="approval">Approval \u2014 propose it like any other fix, upload after I approve</option>
  </select>
  <div id="in_manual_box" class="hidden">
    <p class="note">Download this and upload it to your site's root folder yourself, once:</p>
    <p class="note"><a class="dl" id="in_key_link" href="#" target="_blank">Key file</a>
      \u2014 <span id="in_key_status"></span></p>
  </div>
  <button class="ghost" id="saveIndexNow" style="width:100%;margin-top:1rem">Save IndexNow settings</button>
</div>
</div><!-- /sub-indexnow -->
</div><!-- /tab-settings -->

</main>
</div><!-- /flex row -->
</div><!-- /shell -->
<script>
const $=id=>document.getElementById(id);
const MOD_LABELS={technical_seo_audit:"Technical SEO",geo_ai_audit:"AI readiness (GEO)",
 content_analysis:"Content & keywords",lead_gen_audit:"Lead generation",
 structured_data_generator:"Generate schema",llms_txt_generator:"Generate llms.txt",
 sitemap_check:"Sitemap check",robots_check:"Robots.txt check"};

async function loadCfg(){
  const c=await (await fetch("/api/config")).json();
  $("url").value=c.url.includes("example.com")?"":c.url;
  $("name").value=c.name==="Example Business"?"":c.name;
  $("btype").value=c.business_type; $("kw").value=c.keywords;
  $("loc").value=c.locations; $("mp").value=c.max_pages; $("ivl").value=c.interval_hours;
  $("mods").innerHTML=Object.entries(MOD_LABELS).map(([k,l])=>
    `<label><input type="checkbox" data-m="${k}" ${c.modules[k]?"checked":""}> ${l}</label>`).join("");
}
async function saveCfg(){
  const modules={};document.querySelectorAll("[data-m]").forEach(x=>modules[x.dataset.m]=x.checked);
  await fetch("/api/config",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({url:$("url").value,name:$("name").value,business_type:$("btype").value,
      keywords:$("kw").value,locations:$("loc").value,max_pages:$("mp").value,
      interval_hours:$("ivl").value,modules})});
}
$("run").onclick=async()=>{
  if(!$("url").value.trim()){alert("Enter your website address first.");return}
  await saveCfg();
  const r=await (await fetch("/api/run",{method:"POST"})).json();
  if(!r.ok)alert(r.error);
};
$("sched").onchange=async e=>{
  await saveCfg();
  await fetch("/api/scheduler",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({on:e.target.checked})});
};
function delta(d){if(!d)return"";const v=d.delta;
  return `<span class="delta ${v>=0?'up':'down'}">${v>=0?'▲':'▼'}${Math.abs(v)}</span>`}
async function poll(){
  const s=await (await fetch("/api/status")).json();
  $("run").disabled=s.running;
  $("run").innerHTML=s.running?'<span class="spin"></span>Auditing…':'Run audit';
  $("statusDot").className=s.running?"dot-working":"";
  $("statusDot").style.background=s.running?"":"var(--dim)";
  $("statusText").textContent=s.running?"Working \u2014 updating your server now":
    (s.scheduler_on ? "Idle \u2014 this is normal, waiting for the next scheduled audit"
                    : "Idle \u2014 this is normal, nothing to do until you click Run audit");
  $("log").textContent=s.log.length?s.log.join("\n"):$("log").textContent;
  $("log").scrollTop=$("log").scrollHeight;
  $("sched").checked=s.scheduler_on;
  $("nextrun").textContent=s.next_run?("Next automatic audit: "+new Date(s.next_run*1000).toLocaleString()):"";
  if(s.result){
    const r=s.result;$("resCard").classList.remove("hidden");
    $("rsite").textContent=r.site+" · "+r.pages_crawled+" pages";
    $("overall").innerHTML=r.overall+"<small>/100</small>"+(r.diff?delta(r.diff.overall):"");
    $("ring").style.setProperty("--pct",r.overall);
    const keys={"Technical SEO":"technical","AI readiness":"geo","Content":"content","Lead generation":"lead_gen"};
    $("bars").innerHTML=Object.entries(r.scores).map(([k,v])=>
      `<div class="bar"><div class="t"><span>${k}</span><span>${v}${r.diff?delta(r.diff[keys[k]]):""}</span></div>
       <div class="track"><div class="fill" style="width:${v}%"></div></div></div>`).join("");
    let d="";
    if(r.html_report)d+=`<a class="dl" href="/download?path=${encodeURIComponent(r.html_report)}" target="_blank">Open full report</a>`;
    if(r.llms_txt)d+=`<a class="dl" href="/download?path=${encodeURIComponent(r.llms_txt)}">Download llms.txt</a>`;
    if(r.org_schema)d+=`<a class="dl" href="/download?path=${encodeURIComponent(r.org_schema)}">Download schema (JSON-LD)</a>`;
    $("dls").innerHTML=d;
    $("recs").innerHTML=r.recommendations.map(x=>{
      const m=x.match(/^(.*?)\s*\[(auto-fixable[^\]]*|needs manual[^\]]*)\]\s*$/);
      if(!m) return `<li>${x}</li>`;
      const isAuto=m[2].startsWith("auto-fixable");
      const badge=`<span style="font-size:.68rem;padding:.1rem .5rem;border-radius:10px;margin-left:.4rem;
        background:${isAuto?"rgba(201,163,90,.18)":"var(--panel2)"};
        color:${isAuto?"var(--teal)":"var(--dim)"};border:1px solid ${isAuto?"var(--teal)":"var(--line)"}">
        ${isAuto?"auto-fixable":"manual edit"}</span>`;
      return `<li>${m[1]} ${badge}</li>`;
    }).join("")||"<li>Nothing critical — nice work.</li>";
    $("issues").innerHTML=r.issues.map(x=>{
      const isAuto=x.tag.startsWith("auto-fixable")||x.tag.startsWith("partly auto-fixable");
      const isBroken=x.tag.startsWith("your call");
      const isHosting=x.tag.startsWith("hosting issue");
      const color=isAuto?"var(--teal)":isBroken?"var(--red)":isHosting?"#c98f4a":"var(--dim)";
      const bg=isAuto?"rgba(201,163,90,.18)":isBroken?"rgba(193,88,79,.15)":isHosting?"rgba(201,143,74,.15)":"var(--panel2)";
      const badge=`<span style="font-size:.68rem;padding:.1rem .5rem;border-radius:10px;
        background:${bg};color:${color};border:1px solid ${color}">${x.tag}</span>`;
      return `<li class="issue"><b>${x.issue}</b> ${badge}<br>
        <span style="color:var(--dim);font-size:.78rem">${x.url}</span></li>`;
    }).join("")||"<li>None found 🎉</li>";
    loadHostingMessage();
  }
}
async function loadHostingMessage(){
  const r=await (await fetch("/api/hosting-message")).json();
  $("hostingBox").classList.toggle("hidden", !r.has_issues);
  $("copyHostingMsg").onclick=async()=>{
    await navigator.clipboard.writeText(r.message);
    $("hostingCopyStatus").textContent="\u2713 Copied — paste into an email or chat to your host";
    $("hostingCopyStatus").style.color="var(--amber)";
  };
}
const sevColor={critical:"var(--red)",warning:"var(--amber)",info:"var(--dim)"};
async function loadChanges(){
  const ch=await (await fetch("/api/changes")).json();
  if(!ch.length)return;
  $("changes").innerHTML=ch.slice(0,15).map(c=>{
    const dep=c.deploy?` <span style="color:var(--amber)">· deploy ${c.deploy.ref} (${c.deploy.source})</span>`:"";
    return `<div style="border-left:3px solid ${sevColor[c.severity]};padding:.3rem .6rem;margin:.4rem 0;font-size:.82rem">
      <b style="color:${sevColor[c.severity]}">${c.severity.toUpperCase()}</b> ${c.element} changed${dep}<br>
      <span style="color:var(--dim)">${c.url}</span><br>
      <span style="font-family:var(--mono);font-size:.72rem;color:var(--dim)">${JSON.stringify(c.old)} → ${JSON.stringify(c.new)}</span></div>`}).join("");
}
function citBoard(c){
  if(!c||(!c.per_engine.length&&!c.latest.length)){
    $("citBoard").innerHTML='<span style="color:var(--dim);font-size:.85rem">No checks yet. Save a key and press "Check AI answers now".</span>';return}
  const eng=c.per_engine.map(e=>
    `<div class="bar"><div class="t"><span>${e.engine} — mentioned ${e.mention_rate}% · cited ${e.citation_rate}% (${e.checks} checks)</span></div>
     <div class="track"><div class="fill" style="width:${e.mention_rate}%"></div></div></div>`).join("");
  const rows=c.latest.slice(0,8).map(l=>
    `<div style="padding:.35rem 0;border-bottom:1px solid var(--line);font-size:.8rem">
      <b>${l.engine}</b> · ${l.mentioned?'<span class="up">✓ mentioned</span>':'<span class="down">✗ not mentioned</span>'}
      ${l.cited?'<span class="up">✓ cited</span>':''}
      ${l.competitors.length?`<span style="color:var(--amber)">competitors: ${l.competitors.join(", ")}</span>`:""}<br>
      <span style="color:var(--dim)">"${l.question}"</span></div>`).join("");
  $("citBoard").innerHTML=eng+"<div style='margin-top:.6rem'>"+rows+"</div>";
}
async function loadCitations(){citBoard(await (await fetch("/api/citations")).json())}
$("saveKeys").onclick=async()=>{
  const r=await (await fetch("/api/citations/keys",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({openai:$("k_openai").value,anthropic:$("k_anthropic").value,perplexity:$("k_perplexity").value})})).json();
  alert(r.enabled.length?("Enabled engines: "+r.enabled.join(", ")):"No keys saved — paste at least one key.");
};
$("runCit").onclick=async()=>{
  const r=await (await fetch("/api/citations/run",{method:"POST"})).json();
  if(!r.ok)alert(r.error);else setTimeout(loadCitations,4000);
};
const RM_FIX_LABELS={canonical:"Canonical tags",og_tags:"Open Graph tags",viewport:"Viewport tag",
 meta_description:"Missing meta descriptions",schema_homepage:"Homepage schema (JSON-LD)",
 llms_txt:"llms.txt file",robots_txt:"robots.txt",alt_text_from_csv:"Image alt text (from CSV)"};
function rmShowBox(){
  const v=$("rm_conn").value;
  $("rm_ftp_box").classList.toggle("hidden",v!=="ftp");
  $("rm_sftp_box").classList.toggle("hidden",v!=="sftp");
  $("rm_local_box").classList.toggle("hidden",v!=="local");
}
$("testRM").onclick=async()=>{
  const el=$("rmTestResult"); el.textContent="Testing…"; el.style.color="var(--dim)";
  const r=await (await fetch("/api/remediate-settings/test",{method:"POST",
    headers:{"Content-Type":"application/json"}, body:JSON.stringify({
      connection:$("rm_conn").value,
      ftp:{host:$("rm_ftp_host").value, user:$("rm_ftp_user").value, password:$("rm_ftp_pass").value,
        use_tls:$("rm_ftp_tls").checked, remote_dir:$("rm_ftp_dir").value},
      sftp:{host:$("rm_sftp_host").value, port:$("rm_sftp_port").value, user:$("rm_sftp_user").value,
        password:$("rm_sftp_pass").value, remote_dir:$("rm_sftp_dir").value},
      local:{path:$("rm_local_path").value}
    })})).json();
  el.textContent=(r.ok?"\u2713 ":"\u2717 ")+r.message;
  el.style.color=r.ok?"var(--amber)":"var(--red)";
};
$("rm_conn").onchange=rmShowBox;
async function loadRemediate(){
  const c=await (await fetch("/api/remediate-settings")).json();
  $("rm_conn").value=c.connection; rmShowBox();
  $("rm_ftp_host").value=c.ftp.host; $("rm_ftp_dir").value=c.ftp.remote_dir;
  $("rm_ftp_user").value=c.ftp.user; $("rm_ftp_tls").checked=c.ftp.use_tls;
  $("b_ftp_pass").classList.toggle("hidden",!c.ftp.has_password);
  $("rm_sftp_host").value=c.sftp.host; $("rm_sftp_port").value=c.sftp.port;
  $("rm_sftp_user").value=c.sftp.user; $("rm_sftp_dir").value=c.sftp.remote_dir;
  $("b_sftp_pass").classList.toggle("hidden",!c.sftp.has_password);
  $("rm_local_path").value=c.local.path;
  $("rm_maxfiles").value=c.max_files;
  $("rm_fixes").innerHTML=Object.entries(RM_FIX_LABELS).map(([k,l])=>
    `<label><input type="checkbox" data-f="${k}" ${c.fixes[k]?"checked":""}> ${l}</label>`).join("");
}
$("saveRM").onclick=async()=>{
  const fixes={};document.querySelectorAll("[data-f]").forEach(x=>fixes[x.dataset.f]=x.checked);
  const r=await (await fetch("/api/remediate-settings",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({
      connection:$("rm_conn").value, max_files:$("rm_maxfiles").value, fixes,
      ftp:{host:$("rm_ftp_host").value, user:$("rm_ftp_user").value, password:$("rm_ftp_pass").value,
        use_tls:$("rm_ftp_tls").checked, remote_dir:$("rm_ftp_dir").value},
      sftp:{host:$("rm_sftp_host").value, port:$("rm_sftp_port").value, user:$("rm_sftp_user").value,
        password:$("rm_sftp_pass").value, remote_dir:$("rm_sftp_dir").value},
      local:{path:$("rm_local_path").value}
    })})).json();
  $("rm_ftp_pass").value=""; $("rm_sftp_pass").value="";
  if(r.ok){loadRemediate();alert("Auto-fix connection saved.");}
};
function platformMode(enabled, isDraft){ return enabled ? "auto" : (isDraft ? "manual" : "off"); }
async function loadContentUpdates(){
  const c=await (await fetch("/api/content-updates")).json();
  $("cu_on").checked=c.enabled; $("cu_tone").value=c.tone;
  $("gbp_on").checked=c.gbp.enabled; $("gbp_loc").value=c.gbp.location_id;
  $("gbp_freq").value=c.gbp.post_frequency_days;
  $("gbp_desc").checked=c.gbp.update_description; $("gbp_post").checked=c.gbp.post_new_update;
  $("b_cid").classList.toggle("hidden",!c.gbp.has_client_id);
  $("b_cs").classList.toggle("hidden",!c.gbp.has_client_secret);
  $("b_rt").classList.toggle("hidden",!c.gbp.has_refresh_token);
  $("fb_mode").value=platformMode(c.facebook.enabled, c.draft_only.includes("facebook"));
  $("fb_pid").value=c.facebook.page_id;
  $("b_tok").classList.toggle("hidden",!c.facebook.has_token);
  $("ig_mode").value=platformMode(c.instagram.enabled, c.draft_only.includes("instagram"));
  $("ig_uid").value=c.instagram.ig_user_id;
  $("b_igtok").classList.toggle("hidden",!c.instagram.has_token);
  $("li_mode").value=platformMode(c.linkedin.enabled, c.draft_only.includes("linkedin"));
  $("li_org").value=c.linkedin.organization_urn;
  $("b_litok").classList.toggle("hidden",!c.linkedin.has_token);
  $("x_on").checked=c.draft_only.includes("x");
  $("blog_on").checked=c.blog.enabled; $("blog_freq").value=c.blog.frequency_days;
}
$("saveCU").onclick=async()=>{
  const draft_only=[];
  if($("fb_mode").value==="manual") draft_only.push("facebook");
  if($("ig_mode").value==="manual") draft_only.push("instagram");
  if($("li_mode").value==="manual") draft_only.push("linkedin");
  if($("x_on").checked) draft_only.push("x");
  const r=await (await fetch("/api/content-updates",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({
      enabled:$("cu_on").checked, tone:$("cu_tone").value, draft_only,
      gbp:{enabled:$("gbp_on").checked, location_id:$("gbp_loc").value,
        post_frequency_days:$("gbp_freq").value, update_description:$("gbp_desc").checked,
        post_new_update:$("gbp_post").checked, client_id:$("gbp_cid").value,
        client_secret:$("gbp_cs").value, refresh_token:$("gbp_rt").value},
      facebook:{enabled:$("fb_mode").value==="auto", page_id:$("fb_pid").value,
        page_access_token:$("fb_tok").value},
      instagram:{enabled:$("ig_mode").value==="auto", ig_user_id:$("ig_uid").value,
        access_token:$("ig_tok").value},
      linkedin:{enabled:$("li_mode").value==="auto", organization_urn:$("li_org").value,
        access_token:$("li_tok").value},
      blog:{enabled:$("blog_on").checked, frequency_days:$("blog_freq").value}
    })})).json();
  $("gbp_cid").value=""; $("gbp_cs").value=""; $("gbp_rt").value=""; $("fb_tok").value="";
  $("ig_tok").value=""; $("li_tok").value="";
  if(r.ok){loadContentUpdates();alert("Content update settings saved.");}
};
async function loadHistory(){
  const items=await (await fetch("/api/approvals/history")).json();
  const statusColor={pending:"var(--dim)",approved:"var(--teal)",applied:"var(--amber)",
                     rejected:"var(--red)",failed:"var(--red)"};
  $("historyList").innerHTML = items.length ? items.map(a=>`
    <div style="border:1px solid var(--line);border-radius:8px;padding:.6rem .8rem;margin-bottom:.5rem;
      display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.4rem">
      <span>#${a.id} \u2014 ${a.kind==="content"?"Content update":"Fix"} \u2014 ${a.n_items} item(s) \u2014
        <span style="color:${statusColor[a.status]||'var(--dim)'}">${a.status}</span> \u2014 ${a.created_at}</span>
      <a class="dl" href="${a.url}" target="_blank">View / copy captions</a>
    </div>`).join("") : '<p class="note">No approvals yet.</p>';
}
async function loadBrokenLinks(){
  const urls=await (await fetch("/api/broken-links")).json();
  $("brokenLinks").innerHTML = urls.length ? urls.map((u,i)=>`
    <label style="display:flex;align-items:flex-start;gap:.5rem;margin:.4rem 0;font-size:.85rem;
      text-transform:none;letter-spacing:0">
      <input type="checkbox" class="broken-link-cb" value="${u}" style="margin-top:.2rem">
      <span style="word-break:break-all">${u}</span>
    </label>`).join("") : '<p class="note">No broken links found in your last audit \u2014 nice.</p>';
}
$("applyBrokenLinks").onclick=async()=>{
  const selected=[...document.querySelectorAll(".broken-link-cb:checked")].map(x=>x.value);
  const el=$("brokenLinksResult");
  if(!selected.length){el.textContent="Tick at least one link first.";el.style.color="var(--red)";return;}
  el.textContent="Updating your server…"; el.style.color="var(--dim)";
  const r=await (await fetch("/api/broken-links/apply",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({urls:selected})})).json();
  el.textContent=r.ok?`\u2713 Removed ${selected.length} link(s) from your site.`:("\u2717 "+(r.message||"Failed"));
  el.style.color=r.ok?"var(--amber)":"var(--red)";
  if(r.ok) loadBrokenLinks();
};
async function loadPending(){
  const items=await (await fetch("/api/approvals/pending")).json();
  $("pendingList").innerHTML = items.length ? items.map(a=>`
    <div style="border:1px solid var(--line);border-radius:8px;padding:.7rem .8rem;margin-bottom:.6rem">
      <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.4rem">
        <span>#${a.id} \u2014 ${a.kind==="content"?"Content update":"Fix"} \u2014 ${a.n_items} item(s) \u2014 ${a.site}</span>
        <button class="ghost" data-resend="${a.id}" style="padding:.4rem .8rem;font-size:.72rem">Resend email</button>
      </div>
      <div class="note" style="margin:.4rem 0 0;word-break:break-all">
        <a class="dl" href="${a.url}" target="_blank">${a.url}</a>
      </div>
      <div class="note" id="resend_msg_${a.id}"></div>
    </div>`).join("") : '<p class="note">Nothing waiting right now.</p>';
  document.querySelectorAll("[data-resend]").forEach(btn=>btn.onclick=async()=>{
    const id=btn.dataset.resend; const msg=$(`resend_msg_${id}`);
    btn.disabled=true; btn.textContent="Sending…";
    const r=await (await fetch(`/api/approvals/${id}/resend`,{method:"POST"})).json();
    btn.disabled=false; btn.textContent="Resend email";
    msg.textContent=r.message; msg.style.color=r.ok?"var(--amber)":"var(--red)";
  });
}
// --- tabs ---
document.querySelectorAll(".navbtn").forEach(btn=>btn.onclick=()=>{
  document.querySelectorAll(".navbtn").forEach(b=>b.classList.remove("active"));
  btn.classList.add("active");
  document.querySelectorAll(".tabpanel").forEach(p=>p.classList.add("hidden"));
  $(`tab-${btn.dataset.tab}`).classList.remove("hidden");
});
document.querySelectorAll(".subbtn").forEach(btn=>btn.onclick=()=>{
  document.querySelectorAll(".subbtn").forEach(b=>b.classList.remove("active"));
  btn.classList.add("active");
  document.querySelectorAll(".subpanel").forEach(p=>p.classList.add("hidden"));
  $(`sub-${btn.dataset.sub}`).classList.remove("hidden");
});

// --- theme toggle (persisted locally in this browser) ---
function applyTheme(t){
  document.documentElement.setAttribute("data-theme", t);
  $("themeBtn").textContent = t==="light" ? "\u2600\ufe0f Light" : "\ud83c\udf19 Dark";
}
applyTheme(localStorage.getItem("seo-optimizer-theme") || "dark");
$("themeBtn").onclick=()=>{
  const cur = document.documentElement.getAttribute("data-theme")==="light" ? "dark" : "light";
  localStorage.setItem("seo-optimizer-theme", cur);
  applyTheme(cur);
};

// --- email & notifications settings ---
function bfTestimonialRow(t){
  t=t||{name:"",org:"",quote:""};
  const d=document.createElement("div");
  d.className="row"; d.style.marginBottom=".5rem";
  d.innerHTML=`<div><input placeholder="Name" class="bf-t-name" value="${t.name||''}"></div>
    <div><input placeholder="Organization (optional)" class="bf-t-org" value="${t.org||''}"></div>`;
  const q=document.createElement("input");
  q.placeholder="Their exact words"; q.className="bf-t-quote"; q.value=t.quote||"";
  q.style.marginTop=".4rem";
  const wrap=document.createElement("div"); wrap.appendChild(d); wrap.appendChild(q);
  const rm=document.createElement("button"); rm.type="button"; rm.className="ghost";
  rm.textContent="Remove"; rm.style.cssText="margin-top:.4rem;font-size:.72rem;padding:.35rem .7rem";
  rm.onclick=()=>wrap.remove();
  wrap.appendChild(rm);
  wrap.style.cssText="border:1px solid var(--line);border-radius:8px;padding:.6rem;margin-bottom:.6rem";
  $("bf_testimonials").appendChild(wrap);
}
function bfFaqRow(f){
  f=f||{q:"",a:""};
  const wrap=document.createElement("div");
  wrap.style.cssText="border:1px solid var(--line);border-radius:8px;padding:.6rem;margin-bottom:.6rem";
  const q=document.createElement("input"); q.placeholder="Real question a customer asks"; q.className="bf-f-q"; q.value=f.q||"";
  const a=document.createElement("input"); a.placeholder="Real answer"; a.className="bf-f-a"; a.value=f.a||"";
  a.style.marginTop=".4rem";
  const rm=document.createElement("button"); rm.type="button"; rm.className="ghost";
  rm.textContent="Remove"; rm.style.cssText="margin-top:.4rem;font-size:.72rem;padding:.35rem .7rem";
  rm.onclick=()=>wrap.remove();
  wrap.appendChild(q); wrap.appendChild(a); wrap.appendChild(rm);
  $("bf_faqs").appendChild(wrap);
}
$("bf_addTestimonial").onclick=()=>bfTestimonialRow();
$("bf_addFaq").onclick=()=>bfFaqRow();

$("in_mode").onchange=()=>$("in_manual_box").classList.toggle("hidden",$("in_mode").value!=="manual");
async function loadIndexNow(){
  const c=await (await fetch("/api/indexnow")).json();
  $("in_on").checked=c.enabled; $("in_mode").value=c.mode;
  $("in_manual_box").classList.toggle("hidden",c.mode!=="manual");
  if(c.key_url){
    $("in_key_link").href=c.key_url; $("in_key_link").textContent=c.key_filename;
    $("in_key_status").textContent=c.key_uploaded?"already uploaded \u2713":"not uploaded yet";
  }
}
$("saveIndexNow").onclick=async()=>{
  const r=await (await fetch("/api/indexnow",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({enabled:$("in_on").checked, mode:$("in_mode").value})})).json();
  if(r.ok){loadIndexNow();alert("IndexNow settings saved.");}
};
async function loadBusinessFacts(){
  const c=await (await fetch("/api/business-facts")).json();
  $("bf_phone").value=c.phone; $("bf_stats").value=c.stats_blurb;
  $("bf_author_name").value=c.author.name; $("bf_author_cred").value=c.author.credential;
  $("bf_testimonials").innerHTML=""; $("bf_faqs").innerHTML="";
  (c.testimonials||[]).forEach(bfTestimonialRow);
  (c.faq||[]).forEach(bfFaqRow);
}
$("saveFacts").onclick=async()=>{
  const testimonials=[...document.querySelectorAll("#bf_testimonials > div")].map(row=>({
    name: row.querySelector(".bf-t-name").value,
    org: row.querySelector(".bf-t-org").value,
    quote: row.querySelector(".bf-t-quote").value,
  })).filter(t=>t.quote.trim());
  const faq=[...document.querySelectorAll("#bf_faqs > div")].map(row=>({
    q: row.querySelector(".bf-f-q").value,
    a: row.querySelector(".bf-f-a").value,
  })).filter(f=>f.q.trim() && f.a.trim());
  const r=await (await fetch("/api/business-facts",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({
      phone:$("bf_phone").value, stats_blurb:$("bf_stats").value,
      author:{name:$("bf_author_name").value, credential:$("bf_author_cred").value},
      testimonials, faq
    })})).json();
  if(r.ok){loadBusinessFacts();alert("Business facts saved. These become auto-fixable next audit.");}
};
async function loadEmailSettings(){
  const c=await (await fetch("/api/email-settings")).json();
  $("em_host").value=c.smtp_host; $("em_port").value=c.smtp_port;
  $("em_user").value=c.smtp_user; $("em_from").value=c.mail_from; $("em_to").value=c.mail_to;
  $("b_empass").classList.toggle("hidden",!c.has_password);
}
$("saveEmail").onclick=async()=>{
  const r=await (await fetch("/api/email-settings",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({smtp_host:$("em_host").value, smtp_port:$("em_port").value,
      smtp_user:$("em_user").value, smtp_pass:$("em_pass").value,
      mail_from:$("em_from").value, mail_to:$("em_to").value, slack_webhook:$("em_slack").value})})).json();
  $("em_pass").value=""; $("em_slack").value="";
  if(r.ok){loadEmailSettings();alert(r.message||"Saved. Restart the app for it to take effect.");}
  else alert(r.message);
};
$("testEmail").onclick=async()=>{
  const el=$("emailTestResult"); el.textContent="Sending…"; el.style.color="var(--dim)";
  const r=await (await fetch("/api/email-settings/test",{method:"POST"})).json();
  el.textContent=(r.ok?"\u2713 ":"\u2717 ")+r.message;
  el.style.color=r.ok?"var(--amber)":"var(--red)";
};

loadCfg();poll();loadChanges();loadCitations();loadContentUpdates();loadRemediate();loadPending();loadEmailSettings();loadHistory();loadBusinessFacts();loadBrokenLinks();loadIndexNow();
setInterval(poll,1500);setInterval(loadChanges,10000);setInterval(loadCitations,15000);
setInterval(loadPending,8000);setInterval(loadHistory,15000);
</script></body></html>"""


# ----------------------------- entry -----------------------------
def _lan_ip() -> str:
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"


if __name__ == "__main__":
    port = 8765
    print(f"\n  SEO + AI Optimizer running:")
    print(f"  • This computer:  http://127.0.0.1:{port}")
    print(f"  • Your phone (same WiFi):  http://{_lan_ip()}:{port}")
    print(f"    (on the phone, use browser menu → 'Add to Home Screen' to install)\n")
    threading.Timer(1.0, lambda: webbrowser.open(f"http://127.0.0.1:{port}")).start()
    app.run(host="0.0.0.0", port=port, debug=False)
