#!/usr/bin/env python3
"""
Site Clone Bot — thin Slack trigger over the OpenHands clone agent.

The agent (OpenHands + self-hosted Devstral) does ALL the work: rip, strip tracking,
apply brand/CTA/text/media changes, and QA — guided by the memory layers written into
each job's .openhands/microagents/repo.md. This file is only Slack plumbing:

  /newclonejob   → modal form → create job folder → launch the agent
  (thread reply) → change request → re-run the agent in that job folder
  (file upload)  → saved as brief.pdf or into assets-new/
"""

import os
import re
import html
import json
import threading
import urllib.request
from pathlib import Path

from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

import rip
import changes
import agent
import job_common

# ── Config ────────────────────────────────────────────────────────────────────

SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]   # xoxb-...
SLACK_APP_TOKEN = os.environ["SLACK_APP_TOKEN"]   # xapp-... (Socket Mode)
JOBS_DIR    = Path(os.environ.get("JOBS_DIR", "/srv/site-clones"))
STATE_FILE  = Path(os.environ.get("STATE_FILE", "/srv/slackbot/jobs.json"))
AI_TIMEOUT  = int(os.environ.get("AI_TIMEOUT", "1800"))   # 30 min default per run
LOG_CHANNEL = os.environ.get("LOG_CHANNEL", "C0B42GD7JU9")
SERVER_URL  = os.environ.get("SERVER_URL", "").rstrip("/")
# Slash command name. Global per Slack workspace, so a second (test) instance running its own Slack
# app must override this (e.g. /newclonejob2) — the default stays /newclonejob so nothing changes
# for prod (the main-branch Claude bot owns /newclonejob) when this branch cuts over.
SLASH_COMMAND = os.environ.get("SLASH_COMMAND", "/newclonejob")

app = App(token=SLACK_BOT_TOKEN)

# Absolute path to the deterministic rip tool the agent invokes as its first step.
RIP_SCRIPT = str(Path(rip.__file__).resolve())

# ── State: thread_ts → job metadata ───────────────────────────────────────────

_state_lock = threading.Lock()

def _load():
    if STATE_FILE.exists():
        return json.loads(STATE_FILE.read_text())
    return {}

def _save(s):
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(s, indent=2))

def register_job(thread_ts, job_name, job_dir, user_id, meta):
    with _state_lock:
        s = _load()
        s[thread_ts] = {
            "job_name": job_name, "job_dir": str(job_dir),
            "user_id": user_id, "meta": meta,
        }
        _save(s)

def get_job(thread_ts):
    with _state_lock:
        return _load().get(thread_ts)

# ── Slack posting helpers ─────────────────────────────────────────────────────

def _post(thread_ts, text):
    app.client.chat_postMessage(channel=LOG_CHANNEL, thread_ts=thread_ts, text=text)

# Download-on-demand: package a zip only when the user explicitly asks. Anchored to the WHOLE
# message so a change request that merely mentions the word (e.g. "change the download button")
# is NOT mistaken for a download command — only messages that ARE the command match.
DOWNLOAD_RE = re.compile(
    r"""^\s*
        (?:can\s+i\s+|please\s+|send\s+me\s+|get\s+me\s+|give\s+me\s+)?     # optional lead-in
        (?:the\s+)?
        (?:download|zip|package)                                            # the verb
        (?:\s+(?:it|up|this|please|the\s+(?:zip|file|clone|folder)))*       # optional trailers
        \s*[.!?]*\s*$""",
    re.IGNORECASE | re.VERBOSE)


def _send_zip(job_dir: Path, thread_ts: str):
    """Package index.html + assets/ and post it to the thread — on explicit request only."""
    if not (job_dir / "index.html").exists():
        _post(thread_ts, "⚠️ No index.html yet — nothing to package.")
        return
    _post(thread_ts, "📦 Packaging…")
    zip_path = job_common.make_zip(job_dir)
    comment = "⬇️ Download:"
    if SERVER_URL:
        comment += f"\n🌐 Also downloadable anytime at: {SERVER_URL}/{zip_path.name}"
    try:
        app.client.files_upload_v2(
            channel=LOG_CHANNEL, thread_ts=thread_ts,
            file=str(zip_path), filename=zip_path.name, title=zip_path.name,
            initial_comment=comment,
        )
    except Exception:
        if SERVER_URL:
            _post(thread_ts, f"⬇️ Download: {SERVER_URL}/{zip_path.name}")

# ── Launch the agent ──────────────────────────────────────────────────────────

def _run_job(job_dir: Path, task: str, thread_ts: str, user_id: str, meta: dict,
             verify: bool, rip_url: str = "", uploads=None):
    def _worker():
        # Keep Slack output minimal — this is used by non-technical people. One "working"
        # message, then one result message. Technical step logs stay in the server journal.
        if rip_url:
            # Initial clone: fully deterministic (rip + brand/product/CTA). Runs in seconds.
            _post(thread_ts, "⚙️ Building your clone…")
            try:
                rip.rip(job_dir, rip_url, post=None)          # silent
            except Exception as exc:
                _post(thread_ts, f"❌ Couldn't fetch the page: {exc}")
                return
            if (job_dir / "index.html").exists():
                try:
                    changes.apply(job_dir / "index.html", meta)
                except Exception as exc:
                    _post(thread_ts, f"❌ Couldn't apply the changes: {exc}")
            result = "✅ Clone ready."
        else:
            # Change request: in-house tool-calling loop. Steps (incl. the verify gate) go to the
            # server journal — keep Slack minimal, but make them visible via `journalctl -u clonebot`.
            try:
                summary = agent.run(job_dir, task, uploads=uploads,
                                    on_log=lambda m: print(f"[agent] {m}", flush=True))
            except Exception as exc:
                _post(thread_ts, f"❌ Couldn't apply that: {exc}")
                return
            result = f"✅ {summary}"

        # Verify quietly, and ONLY on the initial clone — a change request may have intentionally
        # altered brand/CTA, so checking against the original job values would false-alarm.
        if verify and rip_url:
            problems = [c[2:].strip() for c in job_common.verify_job_output(job_dir, meta) if c.startswith("❌")]
            if problems:
                result += "\n⚠️ " + "; ".join(problems)

        if SERVER_URL and (job_dir / "index.html").exists():
            result += f"\n🌐 Preview: {SERVER_URL}/{job_dir.name}/"
        # Download is on-demand (reply "download") — don't zip+upload every round. Hint once, on the
        # initial clone, so non-technical users know how to get the file.
        if rip_url and (job_dir / "index.html").exists():
            result += "\n⬇️ Reply *download* anytime to get the zip."
        _post(thread_ts, result)

    threading.Thread(target=_worker, daemon=True).start()

# ── /newclonejob slash command → open modal ───────────────────────────────────

@app.command(SLASH_COMMAND)
def cmd_newjob(ack, body, client):
    ack()
    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal", "callback_id": "newjob_modal",
            "title":  {"type": "plain_text", "text": "New Clone Job"},
            "submit": {"type": "plain_text", "text": "🚀 Start"},
            "close":  {"type": "plain_text", "text": "Cancel"},
            "blocks": [
                _field("job_name",     "Job name",         "running-shoes", "Short, no spaces — used as the folder name"),
                _field("url",          "Source page URL",  "https://example.com/page"),
                _field("orig_brand",   "Original brand",   "Adidas",  optional=True),
                _field("our_brand",    "Our brand",        "Asadi",   optional=True),
                _field("orig_product", "Original product", "EVO SL",  optional=True),
                _field("our_product",  "Our product",      "EVIL OP", optional=True),
                _field("cta_url",      "CTA link",         "https://tk.example.com/click", optional=True),
            ],
        },
    )

def _field(block_id, label, placeholder, hint=None, optional=False):
    b = {
        "type": "input", "block_id": block_id, "optional": optional,
        "label": {"type": "plain_text", "text": label},
        "element": {"type": "plain_text_input", "action_id": "val",
                    "placeholder": {"type": "plain_text", "text": placeholder}},
    }
    if hint:
        b["hint"] = {"type": "plain_text", "text": hint}
    return b

# ── Modal submitted → create folder + launch agent ────────────────────────────

@app.view("newjob_modal")
def modal_newjob(ack, body, client):
    ack()
    vals = body["view"]["state"]["values"]
    user = body["user"]["id"]

    def v(key):
        raw = vals[key]["val"]["value"]
        return raw.strip() if raw else ""

    job_name = job_common.sanitize_job_name(v("job_name"))
    url      = v("url")
    meta = {
        "url": url,
        "orig_brand": v("orig_brand"), "our_brand": v("our_brand"),
        "orig_product": v("orig_product"), "our_product": v("our_product"),
        "cta_url": v("cta_url"),
    }

    job_dir = JOBS_DIR / job_name
    if job_dir.exists():
        client.chat_postMessage(
            channel=LOG_CHANNEL,
            text=f"⚠️ A job named `{job_name}` already exists. Pick another name or reply in its thread.",
        )
        return

    (job_dir / "assets-new").mkdir(parents=True)
    (job_dir / "job.md").write_text(
        job_common.build_job_md(job_name, url, meta["orig_brand"], meta["our_brand"],
                                 meta["orig_product"], meta["our_product"], meta["cta_url"])
    )

    brand_line = (f"> Brand: *{meta['orig_brand']}* → *{meta['our_brand']}*\n"
                  if (meta["orig_brand"] or meta["our_brand"]) else "")
    product_line = (f"> Product: *{meta['orig_product']}* → *{meta['our_product']}*\n"
                    if (meta["orig_product"] or meta["our_product"]) else "")
    cta_line   = f"> CTA: {meta['cta_url']}\n" if meta["cta_url"] else ""
    resp = client.chat_postMessage(
        channel=LOG_CHANNEL,
        text=(f"🚀 *Job `{job_name}` started* by <@{user}>\n> URL: {url}\n"
              f"{brand_line}{product_line}{cta_line}\n"
              f"Upload any PDF brief / new assets to *this thread*. Reply here to request changes."),
    )
    thread_ts = resp["ts"]
    register_job(thread_ts, job_name, job_dir, user, meta)

    task = (
        f"You are cloning the page at {url}. Work entirely inside this folder.\n\n"
        f"STEP 1 — Run the rip tool to download and localize the page. Execute exactly this command:\n"
        f'    python3 "{RIP_SCRIPT}" "{url}"\n'
        f"It downloads the page with wget, organizes all assets under assets/, rewrites every "
        f"reference to local paths, downloads external CDN assets, and removes known trackers. "
        f"Wait for it to finish and confirm index.html exists before continuing.\n\n"
        f"Then, following .openhands/microagents/repo.md and job.md, do the judgment work:\n"
        f"STEP 2 — scan index.html and assets/js for any REMAINING or unusual tracking the tool "
        f"missed and remove it WITHOUT breaking the surrounding JavaScript.\n"
        f"STEP 3 — apply the brand/product/CTA/text/media changes in job.md (and NOTHING marked "
        f"'none — do not change').\n"
        f"STEP 4 — run final QA over HTTP and fix any broken asset paths or JS errors."
    )
    _run_job(job_dir, task, thread_ts, user, meta, verify=True, rip_url=url)

# ── Thread messages: files → save, text → change request ──────────────────────

@app.event("message")
def on_message(event):
    if event.get("bot_id") or not event.get("thread_ts"):
        return
    if event.get("channel") != LOG_CHANNEL:
        return

    thread_ts = event["thread_ts"]
    job = get_job(thread_ts)
    if not job:
        return

    job_dir = Path(job["job_dir"])
    files   = event.get("files", [])
    text    = (event.get("text") or "").strip()
    # Slack escapes < > & and wraps auto-linked URLs as <url|display>. Normalize so HTML snippets
    # (e.g. a pasted <script>) and URLs in change requests arrive intact, not as &lt;script&gt;.
    text = re.sub(r'<(https?://[^|>]+)\|[^>]*>', r'\1', text)   # <url|display> → url
    text = re.sub(r'<(https?://[^>]+)>', r'\1', text)           # <url> → url
    text = html.unescape(text)                                  # &lt;→< , &gt;→> , &amp;→&

    # Quick "undo" command — revert the last change instead of running the agent.
    if not files and re.match(r'^\s*(undo|revert|undo that|revert last|go back|undo (the )?last( change)?)\s*$',
                              text, re.IGNORECASE):
        msg = agent.undo(job_dir)
        if SERVER_URL and (job_dir / "index.html").exists():
            msg += f"\n🌐 Preview: {SERVER_URL}/{job_dir.name}/"
        _post(thread_ts, msg)
        return

    # Save uploaded files: PDF → brief.pdf, everything else → assets-new/
    saved = []
    uploads = []  # media filenames attached in THIS message — pinned for the agent to use
    for f in files:
        name = f["name"]
        is_pdf = name.lower().endswith(".pdf")
        dest = job_dir / "brief.pdf" if is_pdf else job_dir / "assets-new" / name
        dest.parent.mkdir(parents=True, exist_ok=True)
        req = urllib.request.Request(f["url_private"],
                                     headers={"Authorization": f"Bearer {SLACK_BOT_TOKEN}"})
        with urllib.request.urlopen(req) as r:
            dest.write_bytes(r.read())
        saved.append(f"`{dest.name}`")
        if not is_pdf:
            uploads.append(dest.name)

    if saved:
        _post(thread_ts, f"📁 Saved {', '.join(saved)} to `{job['job_name']}/`.")
        # If the same message also carried an instruction, apply it now (no back-and-forth).
        # Otherwise wait for a follow-up change request.
        if not text:
            _post(thread_ts, "Reply with the change request to apply them.")
            return

    if not text:
        return

    # Explicit download request → package the current deliverable, don't run a change.
    if DOWNLOAD_RE.search(text):
        _send_zip(job_dir, thread_ts)
        return

    _post(thread_ts, f"🔄 Applying: _{text}_")
    _run_job(job_dir, text, thread_ts, job.get("user_id", "unknown"),
             job.get("meta", {}), verify=True, uploads=uploads)

# ── Start ─────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    print(f"Bot starting — jobs dir: {JOBS_DIR}, log channel: {LOG_CHANNEL}")
    SocketModeHandler(app, SLACK_APP_TOKEN).start()
