#!/usr/bin/env python3
"""Read public SwarmMemo bounty history. No credentials, writes, or dependencies."""
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import re
import tempfile
import urllib.error
import urllib.parse
import urllib.request

ENDPOINT = "https://swarmmemo.com/api/messages"
OPERATOR = "031d734fde4d37a59f39471fc4c452c32180bee8186844654177626d6ed0e774"
MAX_BYTES = 2_000_000


class ExportError(Exception):
    pass


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def fetch_page(cursor):
    # Only this fixed read endpoint is contacted. Message URLs are never fetched.
    query = urllib.parse.urlencode({"room": "bounties", "limit": 100, "cursor": cursor})
    request = urllib.request.Request(ENDPOINT + "?" + query, headers={
        "Accept": "application/json", "User-Agent": "RecordAndReason-PublicBountyTutorial/1.0"
    }, method="GET")
    try:
        with urllib.request.build_opener(NoRedirect()).open(request, timeout=20) as response:
            raw = response.read(MAX_BYTES + 1)
        if len(raw) > MAX_BYTES:
            raise ExportError("Page exceeded the response-size limit")
        return json.loads(raw.decode("utf-8"))
    except (urllib.error.URLError, OSError, ValueError) as exc:
        # Do not echo arbitrary response bodies, URLs from messages, or credentials.
        raise ExportError("Public feed request failed: " + type(exc).__name__) from exc


def clean(value, length=500):
    value = re.sub(r"\b0x[0-9a-fA-F]{40,}\b", "[public address/hash omitted]", value)
    value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value)
    return value[:length]


def summarize(message):
    text = message["text"]
    operator = message.get("author") == OPERATOR
    first_line = next((line.strip() for line in text.splitlines() if line.strip()), "")
    tags = []
    if operator and first_line.startswith("Bounty:"):
        tags.append("posted_offer")
    if operator and re.search(r"\bPAID\b", text):
        tags.append("operator_payment_report")
    if operator and re.search(r"\bpool (?:is (?:now )?closed|(?:was|has been) exhausted)\b", text, re.I):
        tags.append("operator_closure_report")
    if first_line.startswith("CLAIM "):
        tags.append("claim")
    if not tags:
        tags.append("other_public_message")
    lines = text.splitlines()
    terms = [clean(line.strip()) for line in lines if re.match(r"\s*(Pays|Pool|Deadline|Closes|Claims):", line)]
    evidence = [clean(line.strip()) for line in lines if operator and re.search(r"\b(PAID|pool|closed|accepted|rejected)\b", line, re.I)][:3]
    record = {
        "id": message["id"], "sequence": message["sequence"],
        "posted_at_utc": dt.datetime.fromtimestamp(message["created_at"], dt.timezone.utc).isoformat(),
        "source_url": "https://swarmmemo.com/e/" + message["id"],
        "title": clean(first_line, 180), "tags": tags,
        "author_role_from_api": "operator" if operator else "other_participant",
        "posted_terms": terms, "operator_evidence_excerpts": evidence,
        "availability": "unverified_read_current_thread_before_working"
    }
    reply = message.get("reply_to")
    if isinstance(reply, str) and re.fullmatch(r"[0-9a-f]{32}", reply):
        record["reply_to"] = reply
    return record


def collect(fetch=fetch_page, max_pages=100):
    cursor, visited, by_id = "start", set(), {}
    duplicates = 0
    for page_number in range(1, max_pages + 1):
        if cursor in visited:
            raise ExportError("Pagination cursor repeated; refusing an incomplete snapshot")
        visited.add(cursor)
        page = fetch(cursor)
        if not isinstance(page, dict) or page.get("ok") is not True:
            raise ExportError("Feed did not confirm success")
        data = page.get("data")
        messages = page.get("messages")
        if not isinstance(data, dict) or type(data.get("has_more")) is not bool or not isinstance(messages, list):
            raise ExportError("Unexpected response schema")
        for message in messages:
            if not isinstance(message, dict) or message.get("visibility") != "public" or message.get("hidden") is True:
                raise ExportError("Unexpected nonpublic or hidden message")
            if not isinstance(message.get("id"), str) or not re.fullmatch(r"[0-9a-f]{32}", message["id"]):
                raise ExportError("Invalid message identifier")
            if not isinstance(message.get("text"), str) or type(message.get("sequence")) is not int:
                raise ExportError("Malformed public message")
            if type(message.get("created_at")) not in (int, float) or not 0 <= message["created_at"] <= 253402300799:
                raise ExportError("Invalid message timestamp")
            record = summarize(message)
            existing = by_id.get(record["id"])
            if existing is not None:
                if existing != record:
                    raise ExportError("Conflicting duplicate message; restart for a fresh snapshot")
                duplicates += 1
            else:
                by_id[record["id"]] = record
            if len(by_id) > 10000:
                raise ExportError("Message safety limit reached")
        next_cursor = page.get("next_cursor")
        if not isinstance(next_cursor, str) or not next_cursor:
            raise ExportError("Missing continuation cursor")
        if data["has_more"] is False:
            records = sorted(by_id.values(), key=lambda item: (item["sequence"], item["id"]))
            offers = [record["id"] for record in records if "posted_offer" in record["tags"]]
            return {
                "schema": 1, "captured_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
                "source": ENDPOINT + "?room=bounties&cursor=start", "complete_at_read": True,
                "pages_fetched": page_number, "unique_messages": len(records),
                "duplicate_ids_removed": duplicates, "offer_ids": offers,
                "limitations": ["Tags summarize public text; they do not verify funding, payment, signatures, eligibility or current availability.",
                                "This snapshot is not atomic; new activity may appear after it. Read the linked thread before working.",
                                "Private content, full bodies, signatures and payout addresses are not exported."],
                "messages": records
            }
        cursor = next_cursor
    raise ExportError("Page limit reached before history was exhausted")


def save_atomic(snapshot, output):
    output = Path(output)
    output.parent.mkdir(parents=True, exist_ok=True)
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=output.parent, prefix=".bounty-export-", suffix=".tmp", delete=False) as stream:
            temporary = Path(stream.name)
            json.dump(snapshot, stream, ensure_ascii=False, indent=2)
            stream.write("\n")
        os.replace(temporary, output)
    finally:
        if temporary and temporary.exists():
            temporary.unlink()


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--output", default="public-bounties.json")
    args = parser.parse_args()
    try:
        snapshot = collect()
        save_atomic(snapshot, args.output)
        print(json.dumps({key: snapshot[key] for key in ("pages_fetched", "unique_messages", "duplicate_ids_removed", "offer_ids")}))
        return 0
    except (ExportError, OSError) as exc:
        print("Export failed; previous output was not refreshed: " + str(exc), file=__import__('sys').stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
