#!/usr/bin/env python3
"""
03_publish_to_wordpress.py (Beta 0.5)

Watches a transcripts folder for new .txt files. If a transcript begins with a
spoken "Meta note" / "System note" block AND includes:
  - "create blog post" OR "create a blog post"
then it automatically creates a WordPress DRAFT via the WP REST API.

It extracts:
  - title (spoken "Title:" or derived from body)
  - excerpt (spoken "Excerpt:" or derived)
  - tags (spoken "Tags:" or derived keywords; creates tags if missing)

Categories: intentionally NOT set (leave unassigned).

Beta 0.5 addition:
- Strips the transcription footer/tagline block from WordPress output by default.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import shlex
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple

import requests

# Footer block markers (must match transcribe_audio.py)
FOOTER_START = "[[TRANSCRIBE_FOOTER_START]]"
FOOTER_END   = "[[TRANSCRIBE_FOOTER_END]]"

TIMESTAMP_RE = re.compile(r"^\[\d{2}:\d{2}(?::\d{2})?\]\s*")

def strip_timestamps(text: str) -> str:
    return "\n".join(TIMESTAMP_RE.sub("", ln) for ln in text.splitlines()).strip()

def strip_footer_block(text: str) -> str:
    if FOOTER_START in text and FOOTER_END in text:
        pre = text.split(FOOTER_START, 1)[0].rstrip()
        return pre
    return text.rstrip()

# ----------------------------
# Configuration defaults
# ----------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_TRANSCRIPTS_DIR = SCRIPT_DIR / "_02_Transcripts"
DEFAULT_LOG_DIR = SCRIPT_DIR / "_LOGS"
DEFAULT_STATE_FILE = DEFAULT_LOG_DIR / "wp_publish_state.json"
DEFAULT_LOG_FILE = DEFAULT_LOG_DIR / "wp_publish.log"

META_SCAN_MAX_LINES = 30

ACTION_PATTERNS = [
    r"\bcreate\s+blog\s+post\b",
    r"\bcreate\s+a\s+blog\s+post\b",
]

GATE_PATTERNS = [
    r"\bmeta\s+note\b",
    r"\bsystem\s+note\b",
]

STOPWORDS = {
    "a","an","and","are","as","at","be","because","been","but","by",
    "can","could","did","do","does","for","from","had","has","have",
    "he","her","him","his","how","i","if","in","into","is","it","its",
    "just","like","me","more","most","my","no","not","of","on","or",
    "our","out","so","some","than","that","the","their","them","then",
    "there","these","they","this","to","up","was","we","were","what",
    "when","where","which","who","why","with","you","your"
}


@dataclass
class ParsedMeta:
    should_post: bool
    title: Optional[str]
    excerpt: Optional[str]
    tags: Optional[List[str]]
    body: str


def log(msg: str, log_file: Path) -> None:
    log_file.parent.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}\n"
    with log_file.open("a", encoding="utf-8") as f:
        f.write(line)
    print(line, end="")


def sha256_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8", errors="ignore")).hexdigest()


def load_state(state_file: Path) -> dict:
    state_file.parent.mkdir(parents=True, exist_ok=True)
    if state_file.exists():
        try:
            return json.loads(state_file.read_text(encoding="utf-8"))
        except Exception:
            return {}
    return {}


def save_state(state_file: Path, state: dict) -> None:
    state_file.parent.mkdir(parents=True, exist_ok=True)
    state_file.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")


def normalize_tag(tag: str) -> str:
    t = tag.strip().lower()
    t = re.sub(r"[^a-z0-9\s\-_/]+", "", t)
    t = re.sub(r"\s+", " ", t).strip()
    return t


def split_sentences(text: str) -> List[str]:
    text = re.sub(r"\s+", " ", text.strip())
    if not text:
        return []
    parts = re.split(r"(?<=[.!?])\s+", text)
    return [p.strip() for p in parts if p.strip()]


def derive_title(body: str) -> str:
    lines = [ln.strip() for ln in body.splitlines() if ln.strip()]
    candidate = lines[0] if lines else ""
    sents = split_sentences(candidate)
    title = sents[0] if sents else candidate
    title = title.strip().strip('"“”').strip()
    title = re.sub(r"\s+", " ", title)
    if len(title) > 72:
        title = title[:72].rstrip() + "…"
    title = re.sub(r"[.!?]+$", "", title).strip()
    return title or "Untitled Draft"


def derive_excerpt(body: str, max_chars: int = 220) -> str:
    sents = split_sentences(body)
    excerpt = " ".join(sents[:2]) if sents else body.strip()
    excerpt = re.sub(r"\s+", " ", excerpt).strip()
    if len(excerpt) > max_chars:
        excerpt = excerpt[:max_chars].rstrip() + "…"
    return excerpt


def derive_tags(body: str, max_tags: int = 7) -> List[str]:
    text = body.lower()
    words = re.findall(r"[a-z0-9][a-z0-9\-_/]{2,}", text)
    freq = {}
    for w in words:
        if w in STOPWORDS:
            continue
        w = w.strip("-_/")
        if len(w) < 3 or w in STOPWORDS:
            continue
        freq[w] = freq.get(w, 0) + 1
    ranked = sorted(freq.items(), key=lambda kv: (-kv[1], kv[0]))
    tags = [normalize_tag(k) for k, _ in ranked[:max_tags]]
    return [t for t in tags if t]


def parse_transcript_for_meta(text: str) -> ParsedMeta:
    # Clean up: timestamps + footer
    text = strip_footer_block(text)
    text = strip_timestamps(text)

    lines = text.splitlines()
    head = "\n".join(lines[:META_SCAN_MAX_LINES]).lower()

    gate_ok = any(re.search(pat, head, flags=re.I) for pat in GATE_PATTERNS)
    action_ok = any(re.search(pat, head, flags=re.I) for pat in ACTION_PATTERNS)
    should_post = bool(gate_ok and action_ok)

    def find_field(pattern: str) -> Optional[str]:
        m = re.search(pattern, "\n".join(lines[:META_SCAN_MAX_LINES]), flags=re.I | re.M)
        return m.group(1).strip() if m else None

    title = find_field(r"^\s*title\s*:\s*(.+)\s*$")
    excerpt = find_field(r"^\s*excerpt\s*:\s*(.+)\s*$")
    tags_raw = find_field(r"^\s*tags\s*:\s*(.+)\s*$")

    tags = None
    if tags_raw:
        tags = [normalize_tag(t) for t in re.split(r"[,\|;]+", tags_raw) if normalize_tag(t)]

    body_start_idx = 0
    seen_gate_or_action = False
    for i, ln in enumerate(lines[:META_SCAN_MAX_LINES]):
        if re.search(r"(meta\s+note|system\s+note)", ln, flags=re.I) or any(re.search(p, ln, flags=re.I) for p in ACTION_PATTERNS):
            seen_gate_or_action = True
        if seen_gate_or_action and ln.strip() == "":
            body_start_idx = i + 1
            break

    body = "\n".join(lines[body_start_idx:]).strip()

    if not title:
        title = derive_title(body)
    if not excerpt:
        excerpt = derive_excerpt(body)
    if not tags:
        tags = derive_tags(body)

    return ParsedMeta(should_post=should_post, title=title, excerpt=excerpt, tags=tags, body=body)


class WordPressClient:
    def __init__(self, site_url: str, username: str, app_password: str, log_file: Path):
        self.site_url = site_url.rstrip("/")
        self.api_base = f"{self.site_url}/wp-json/wp/v2"
        self.auth = (username, app_password)
        self.log_file = log_file

    def _req(self, method: str, path: str, **kwargs):
        url = f"{self.api_base}{path}"
        resp = requests.request(method, url, auth=self.auth, timeout=30, **kwargs)
        if resp.status_code >= 400:
            raise RuntimeError(f"WP API error {resp.status_code}: {resp.text[:500]}")
        return resp

    def ensure_tag_ids(self, tag_names: List[str]) -> List[int]:
        ids: List[int] = []
        for name in tag_names:
            clean = name.strip()
            if not clean:
                continue

            resp = self._req("GET", f"/tags?search={requests.utils.quote(clean)}&per_page=100")
            items = resp.json()

            tag_id = None
            for it in items:
                if str(it.get("name", "")).strip().lower() == clean.lower():
                    tag_id = int(it["id"])
                    break

            if tag_id is None:
                resp2 = self._req("POST", "/tags", json={"name": clean})
                tag_id = int(resp2.json()["id"])
                log(f"Created new tag: '{clean}' (id={tag_id})", self.log_file)

            ids.append(tag_id)

        seen = set()
        out = []
        for i in ids:
            if i not in seen:
                seen.add(i)
                out.append(i)
        return out

    def create_draft_post(self, title: str, content: str, excerpt: str, tags: List[str]) -> Tuple[int, str]:
        tag_ids = self.ensure_tag_ids(tags) if tags else []
        payload: dict[str, object] = {
            "title": title,
            "content": content,
            "excerpt": excerpt,
            "status": "draft",
        }
        if tag_ids:
            payload["tags"] = tag_ids

        resp = self._req("POST", "/posts", json=payload)
        data = resp.json()
        post_id = int(data["id"])
        link = data.get("link") or f"{self.site_url}/?p={post_id}"
        return post_id, link


def get_env_required(name: str) -> str:
    v = os.environ.get(name, "").strip()
    if not v:
        raise SystemExit(f"Missing required environment variable: {name}")
    return v


def _normalize_site_key(value: str) -> str:
    """Normalize a site label into an env-prefix style key.

    Examples:
      - "berchman" -> "BERCHMAN"
      - "bert.forsale" -> "BERT_FORSALE"
      - "Bert Mahoney" -> "BERT_MAHONEY"
    """
    value = value.strip().upper()
    value = re.sub(r"[^A-Z0-9]+", "_", value)
    return re.sub(r"_+", "_", value).strip("_")


def _get_site_prefixed_env(prefix: str, name: str) -> str:
    return os.environ.get(f"{_normalize_site_key(prefix)}_{name}", "").strip()


def resolve_wp_credentials(site_key: Optional[str] = None) -> tuple[str, str, str, str]:
    """Resolve WordPress credentials from either generic or site-prefixed env vars.

    Priority:
      1) Generic WP_SITE_URL / WP_USERNAME / WP_APP_PASSWORD
      2) Explicit site key from --site-key or WP_SITE_KEY / WP_PROFILE / WP_SITE_PREFIX
      3) Any site-prefixed vars already loaded in the environment
    """
    generic = {
        "site_url": os.environ.get("WP_SITE_URL", "").strip(),
        "username": os.environ.get("WP_USERNAME", "").strip(),
        "app_password": os.environ.get("WP_APP_PASSWORD", "").strip(),
    }
    if all(generic.values()):
        return generic["site_url"], generic["username"], generic["app_password"], "generic WP_*"

    candidates: List[str] = []
    for candidate in [
        site_key,
        os.environ.get("WP_SITE_KEY"),
        os.environ.get("WP_PROFILE"),
        os.environ.get("WP_SITE_PREFIX"),
    ]:
        if candidate:
            normalized = _normalize_site_key(candidate)
            if normalized and normalized not in candidates:
                candidates.append(normalized)

    for prefix in candidates:
        site_url = _get_site_prefixed_env(prefix, "WP_URL")
        username = _get_site_prefixed_env(prefix, "WP_USER")
        app_password = _get_site_prefixed_env(prefix, "WP_APP_PASSWORD")
        if site_url and username and app_password:
            return site_url, username, app_password, f"{prefix}_WP_*"

    raise SystemExit(
        "Missing WordPress credentials. Set WP_SITE_URL/WP_USERNAME/WP_APP_PASSWORD or "
        "set WP_SITE_KEY (or pass --site-key) plus <SITEKEY>_WP_URL/<SITEKEY>_WP_USER/<SITEKEY>_WP_APP_PASSWORD."
    )


def _expand_env_refs(value: str, env: dict[str, str]) -> str:
    """Expand simple ${NAME} references using already-loaded/env values."""
    def repl(match: re.Match[str]) -> str:
        return env.get(match.group(1), match.group(0))

    return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl, value)


def load_env_file(path: Path, *, override: bool = False) -> None:
    """Load a small KEY=VALUE env file without logging values.

    By default, existing environment variables win, so launchd/shell-provided
    secrets are not overwritten by local config files. Pass ``override=True`` for
    canonical secret files that should refresh stale inherited process env vars.
    Lines may use optional `export`, comments, and single/double-quoted values.
    """
    if not path.exists():
        return

    loaded = dict(os.environ)
    for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[len("export "):].strip()
        if "=" not in line:
            continue

        key, raw_value = line.split("=", 1)
        key = key.strip()
        if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
            continue
        if key in os.environ and not override:
            loaded[key] = os.environ[key]
            continue

        try:
            parts = shlex.split(raw_value, comments=True, posix=True)
            value = " ".join(parts) if parts else ""
        except ValueError:
            value = raw_value.strip().strip('"').strip("'")
        value = _expand_env_refs(value, loaded)
        os.environ[key] = value
        loaded[key] = value


def iter_transcript_files(transcripts_dir: Path) -> List[Path]:
    return sorted([p for p in transcripts_dir.glob("*.txt") if p.is_file()])


def main():
    parser = argparse.ArgumentParser(description="Auto-publish selected transcripts to WordPress as drafts.")
    parser.add_argument("--transcripts-dir", default=str(DEFAULT_TRANSCRIPTS_DIR))
    parser.add_argument("--log-dir", default=str(DEFAULT_LOG_DIR))
    parser.add_argument("--env-file", default=str(SCRIPT_DIR / "config.env"), help="Optional KEY=VALUE env file (default: ./config.env)")
    parser.add_argument("--site-key", default=os.environ.get("WP_SITE_KEY", ""), help="Optional site selector like BERTMAHONEY, BERCHMAN, or BERT_FORSALE")
    parser.add_argument("--poll-seconds", type=int, default=5)
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()

    load_env_file(Path(args.env_file).expanduser())
    for hermes_env in [
        Path("/Users/bertmahoney/.hermes/.env"),
        Path.home() / ".hermes" / ".env",
    ]:
        load_env_file(hermes_env, override=True)

    transcripts_dir = Path(args.transcripts_dir).expanduser().resolve()
    log_dir = Path(args.log_dir).expanduser().resolve()
    log_file = log_dir / "wp_publish.log"
    state_file = log_dir / "wp_publish_state.json"

    transcripts_dir.mkdir(parents=True, exist_ok=True)
    log_dir.mkdir(parents=True, exist_ok=True)

    site_url, username, app_password, resolved_from = resolve_wp_credentials(args.site_key)

    wp = WordPressClient(site_url, username, app_password, log_file)

    state = load_state(state_file)
    state.setdefault("posted", {})

    log(f"Using WordPress credentials from: {resolved_from}", log_file)
    log(f"Watching transcripts folder: {transcripts_dir}", log_file)
    log(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE'} | Poll every {args.poll_seconds}s", log_file)

    while True:
        try:
            for path in iter_transcript_files(transcripts_dir):
                key = str(path)
                text = path.read_text(encoding="utf-8", errors="ignore")
                digest = sha256_text(text)

                already = state["posted"].get(key)
                if already and already.get("sha") == digest:
                    continue

                parsed = parse_transcript_for_meta(text)

                if not parsed.should_post:
                    state["posted"][key] = {
                        "sha": digest,
                        "status": "ignored_no_intent",
                        "updated_at": datetime.now().isoformat(timespec="seconds"),
                    }
                    save_state(state_file, state)
                    continue

                log(f"Intent detected in: {path.name}", log_file)
                log(f"  Title: {parsed.title}", log_file)
                log(f"  Tags: {', '.join(parsed.tags or [])}", log_file)

                if args.dry_run:
                    state["posted"][key] = {
                        "sha": digest,
                        "status": "dry_run_would_post",
                        "title": parsed.title,
                        "updated_at": datetime.now().isoformat(timespec="seconds"),
                    }
                    save_state(state_file, state)
                    continue

                post_id, link = wp.create_draft_post(
                    title=parsed.title or "Untitled Draft",
                    content=parsed.body,
                    excerpt=parsed.excerpt or "",
                    tags=parsed.tags or [],
                )

                log(f"Draft created: post_id={post_id} | {link}", log_file)

                state["posted"][key] = {
                    "sha": digest,
                    "status": "posted_draft",
                    "post_id": post_id,
                    "link": link,
                    "title": parsed.title,
                    "posted_at": datetime.now().isoformat(timespec="seconds"),
                }
                save_state(state_file, state)

            time.sleep(args.poll_seconds)

        except KeyboardInterrupt:
            log("Stopped by user (Ctrl+C).", log_file)
            return
        except Exception as e:
            log(f"ERROR: {e}", log_file)
            time.sleep(max(5, args.poll_seconds))


if __name__ == "__main__":
    main()
