#!/usr/bin/env python3
"""
Site Clone Slack Bot
Non-technical team members initiate clone jobs and send change requests via Slack.

Commands:
  /newclonejob2  → opens a modal form to start a new clone job (Anthropic API auth version)
  (thread reply) → treated as a change request for that job
  (file upload)  → saved as brief.pdf or to assets-new/ automatically
"""

import os
import re
import json
import threading
import subprocess
import urllib.request
import zipfile
from pathlib import Path

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

# ── 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"))
CLAUDE_BIN      = os.environ.get("CLAUDE_BIN", "claude")
CLAUDE_TIMEOUT  = int(os.environ.get("CLAUDE_TIMEOUT", "900"))  # 15 min max per run
LOG_CHANNEL     = os.environ.get("LOG_CHANNEL", "C0APJCDG1JR")
SERVER_URL      = os.environ.get("SERVER_URL", "").rstrip("/")  # e.g. http://139.180.219.203

app = App(token=SLACK_BOT_TOKEN)

# ── 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):
    with _state_lock:
        s = _load()
        s[thread_ts] = {"job_name": job_name, "job_dir": str(job_dir)}
        _save(s)

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

# ── Per-job CLAUDE.md builder ─────────────────────────────────────────────────

def build_job_claude_md(job_name, url, orig_brand, our_brand, orig_product, our_product, cta_url):
    lines = [
        f"# Job: {job_name}",
        "",
        "## Brief",
        "- Text changes arrive via Slack thread — no PDF needed for text",
        "- `brief.pdf` (if uploaded) is for media asset placement context only",
        "- `assets-new/` contains the new media assets to be swapped in",
        "",
        "## Source",
        f"- **URL:** {url}",
        "",
    ]
    if orig_brand or our_brand or orig_product or our_product:
        lines += [
            "## Branding",
            f"- **Original brand:** {orig_brand or '(TBD)'}",
            f"- **Our brand:** {our_brand or '(TBD)'}",
            f"- **Original product:** {orig_product or '(TBD)'}",
            f"- **Our product:** {our_product or '(TBD)'}",
            "",
        ]
    if cta_url:
        lines += ["## CTA", f"- **Our CTA URL:** {cta_url}", ""]
    lines += [
        "## Manual Asset Notes",
        "_Only fill this in if brief.pdf is missing or incomplete. Otherwise leave blank._",
        "",
        "| New file (assets-new/)     | Replaces (assets/images/)      | Notes                  |",
        "|----------------------------|--------------------------------|------------------------|",
        "|                            |                                |                        |",
        "",
        "## Manual Text Change Notes",
        "_Only fill this in if brief.pdf is missing or incomplete. Otherwise leave blank._",
        "",
        "| Original text (on page)    | New text                       | Location               |",
        "|----------------------------|--------------------------------|------------------------|",
        "|                            |                                |                        |",
        "",
        "## Other Notes",
        "_Any special instructions for this job._",
    ]
    return "\n".join(lines) + "\n"

def _parse_claude_output(stdout, stderr):
    """Parse `claude -p --output-format json` stdout into (text, status, usage_line)."""
    stdout = (stdout or "").strip()
    try:
        data = json.loads(stdout)
    except (json.JSONDecodeError, ValueError):
        raw = stdout or (stderr or "").strip() or "(no output)"
        return raw, "✅ Done", None

    raw = data.get("result") or "(no output)"
    status = "❌ Error" if data.get("is_error") else "✅ Done"

    usage = data.get("usage") or {}
    total_tokens = (
        usage.get("input_tokens", 0)
        + usage.get("cache_creation_input_tokens", 0)
        + usage.get("cache_read_input_tokens", 0)
        + usage.get("output_tokens", 0)
    )
    model_usage = data.get("modelUsage") or {}
    context_window = next(iter(model_usage.values()), {}).get("contextWindow") if model_usage else None
    cost = data.get("total_cost_usd")

    usage_line = None
    if total_tokens:
        parts = [f"📊 Tokens used: {total_tokens:,}"]
        if context_window:
            pct = total_tokens / context_window * 100
            parts.append(f"({pct:.1f}% of {context_window:,} context)")
        if cost is not None:
            parts.append(f"· Cost: ${cost:.4f}")
        usage_line = " ".join(parts)

    return raw, status, usage_line

# ── Run Claude (non-blocking) ─────────────────────────────────────────────────

def run_claude(job_dir, prompt, thread_ts, continue_session=False):
    """Spawn claude in a background thread, post output back to the thread."""

    job_name = Path(job_dir).name

    def _worker():
        cmd = [CLAUDE_BIN, "--dangerously-skip-permissions", "--output-format", "json"]
        if continue_session:
            cmd += ["-c"]          # continue most recent session in this dir
        cmd += ["-p", prompt]

        usage_line = None
        try:
            result = subprocess.run(
                cmd,
                cwd=str(job_dir),
                capture_output=True,
                text=True,
                timeout=CLAUDE_TIMEOUT,
            )
            raw, status, usage_line = _parse_claude_output(result.stdout, result.stderr)
        except subprocess.TimeoutExpired:
            raw = f"Job timed out after {CLAUDE_TIMEOUT // 60} minutes."
            status = "⚠️ Timeout"
        except Exception as exc:
            raw = str(exc)
            status = "❌ Error"

        # Split into ≤3800-char chunks to stay under Slack's block limit
        chunks = [raw[i:i + 3800] for i in range(0, max(len(raw), 1), 3800)]
        for idx, chunk in enumerate(chunks):
            label = status if idx == len(chunks) - 1 else "📄 (continued)"
            app.client.chat_postMessage(
                channel=LOG_CHANNEL,
                thread_ts=thread_ts,
                text=f"{label}:\n```{chunk}```",
            )

        if usage_line:
            app.client.chat_postMessage(
                channel=LOG_CHANNEL,
                thread_ts=thread_ts,
                text=usage_line,
            )

        # Post preview link when done
        if status == "✅ Done" and SERVER_URL:
            app.client.chat_postMessage(
                channel=LOG_CHANNEL,
                thread_ts=thread_ts,
                text=f"🌐 Preview: {SERVER_URL}/{job_name}/",
            )

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

# ── Package job for download ──────────────────────────────────────────────────

DOWNLOAD_RE = re.compile(r"\b(download|zip(?:\s*it|\s*up)?|package(?:\s*it)?)\b", re.IGNORECASE)

# Job bookkeeping — never part of the deliverable
_ZIP_EXCLUDE = {"CLAUDE.md", "assets-new", "brief.pdf", "ERRORS.md", "__pycache__"}

def build_job_zip(job_dir: Path) -> Path:
    """Zip index.html + assets/ (deliverable only) to <job_name>.zip, sibling to the job folder."""
    zip_path = job_dir.parent / f"{job_dir.name}.zip"

    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for path in sorted(job_dir.rglob("*")):
            if path.is_dir():
                continue
            rel = path.relative_to(job_dir)
            if rel.parts[0] in _ZIP_EXCLUDE:
                continue
            zf.write(path, rel.as_posix())

    return zip_path


def send_job_zip(client, job_dir, job_name, thread_ts):
    if not (job_dir / "index.html").exists():
        client.chat_postMessage(
            channel=LOG_CHANNEL,
            thread_ts=thread_ts,
            text=":warning: No `index.html` yet — nothing to package.",
        )
        return

    client.chat_postMessage(
        channel=LOG_CHANNEL,
        thread_ts=thread_ts,
        text=f":package: Packaging `{job_name}` (index.html + assets/)...",
    )

    zip_path = build_job_zip(job_dir)

    comment = f":package: `{zip_path.name}` — index.html + assets/, ready to deploy."
    if SERVER_URL:
        comment += f"\n:globe_with_meridians: Also downloadable anytime at: {SERVER_URL}/{zip_path.name}"

    client.files_upload_v2(
        channel=LOG_CHANNEL,
        thread_ts=thread_ts,
        file=str(zip_path),
        filename=zip_path.name,
        initial_comment=comment,
    )

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

@app.command("/newclonejob2")
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.runfastfast.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 ───────────────────────────────────────────────────────────

@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     = re.sub(r"[^\w\-]", "-", v("job_name")).lower().strip("-")
    url          = v("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 a different name or reply in its existing thread.",
        )
        return

    # Create folder structure
    (job_dir / "assets-new").mkdir(parents=True)

    # Write per-job CLAUDE.md
    (job_dir / "CLAUDE.md").write_text(
        build_job_claude_md(job_name, url, orig_brand, our_brand, orig_product, our_product, cta_url)
    )

    # Build kickoff summary lines
    brand_line   = f"> Brand: *{orig_brand}* → *{our_brand}*\n"   if (orig_brand or our_brand)     else "> Brand: _(to be provided)_\n"
    product_line = f"> Product: *{orig_product}* → *{our_product}*\n" if (orig_product or our_product) else "> Product: _(to be provided)_\n"
    cta_line     = f"> CTA: {cta_url}\n"                            if cta_url                       else "> CTA: _(to be provided)_\n"

    # Post the kickoff message — its ts becomes the thread root
    resp = client.chat_postMessage(
        channel=LOG_CHANNEL,
        text=(
            f"🚀 *Job `{job_name}` started* by <@{user}>\n"
            f"> URL: {url}\n"
            f"{brand_line}"
            f"{product_line}"
            f"{cta_line}\n"
            f"Upload any PDF brief and new assets to *this thread*.\n"
            f"Reply here to request changes after reviewing the output."
        ),
    )
    thread_ts = resp["ts"]
    register_job(thread_ts, job_name, job_dir)

    has_full_details = orig_brand or our_brand or cta_url
    if has_full_details:
        status_msg = "⏳ Cloning page and applying all changes — this usually takes 3–8 minutes..."
        prompt = "Follow the CLAUDE.md instructions and start the job. Make no mistakes."
    else:
        status_msg = "⏳ Cloning page and stripping tracking — branding/CTA details to be applied later..."
        prompt = "Follow the CLAUDE.md instructions. Run Phase 1 only (Steps 1 and 2: rip the page and strip tracking). Stop after Phase 1 — branding, CTA, and other changes will arrive later via the thread."

    app.client.chat_postMessage(
        channel=LOG_CHANNEL,
        thread_ts=thread_ts,
        text=status_msg,
    )

    run_claude(job_dir, prompt, thread_ts)

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

@app.event("message")
def on_message(event, client):
    # Ignore bot posts and top-level (non-threaded) messages
    if event.get("bot_id") or not event.get("thread_ts"):
        return

    # Only handle messages in the log channel
    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()

    # ── Save uploaded files ──────────────────────────────────────────────────
    saved = []
    for f in files:
        name        = f["name"]
        url_private = f["url_private"]

        # brief.pdf goes to job root; everything else → assets-new/
        dest = job_dir / "brief.pdf" if name.lower().endswith(".pdf") else job_dir / "assets-new" / name

        req = urllib.request.Request(
            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 saved:
        joined = ", ".join(saved)
        client.chat_postMessage(
            channel=LOG_CHANNEL,
            thread_ts=thread_ts,
            text=f"📁 Saved {joined} to `{job['job_name']}/`",
        )
        if any(s == "`brief.pdf`" for s in saved) and not text:
            client.chat_postMessage(
                channel=LOG_CHANNEL,
                thread_ts=thread_ts,
                text=(
                    "📄 `brief.pdf` received.\n"
                    "Reply *apply brief* to run the changes now, "
                    "or keep uploading assets first."
                ),
            )
        if not text:
            return   # file-only message — nothing more to do yet

    # ── Text reply → change request ──────────────────────────────────────────
    if not text:
        return

    if DOWNLOAD_RE.search(text):
        send_job_zip(client, job_dir, job["job_name"], thread_ts)
        return

    client.chat_postMessage(
        channel=LOG_CHANNEL,
        thread_ts=thread_ts,
        text=f"🔄 Applying: _{text}_",
    )
    run_claude(job_dir, text, thread_ts, continue_session=True)


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

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