#!/bin/bash
#
# fetch-confluence.sh
# Fetch a Confluence page by URL or pageId and emit a normalized JSON view
# the Phase 1 (Analysis) prompt can consume. Pulls the page's storage-format
# body, converts it to lightweight markdown, and extracts code blocks plus a
# best-effort list of API contracts (HTTP method + path + JSON body) so
# downstream phases can match the spec to the work.
#
# Two input forms accepted:
#   ./fetch-confluence.sh <page-url>
#   ./fetch-confluence.sh --host <host> --page-id <id>
#
# Optional env:
#   CONFLUENCE_TIMEOUT_SECONDS    default 20
#   CONFLUENCE_AUTH_TOKEN_KEY     default prefs.global.keychainMapping.confluence
#                                 fallback "${USER}_Confluence_Access_Token"
#   CONFLUENCE_HOST_OVERRIDE      forces the host (overrides URL host extraction)
#
# Output (stdout, single JSON object):
#   {
#     "fetchedAt": "<ISO8601>",
#     "source": { "url": "<canonical-url>", "host": "<host>", "pageId": "<id>", "spaceKey": "<key|null>" },
#     "title": "...",
#     "bodyMarkdown": "<storage→markdown>",
#     "codeBlocks": [ { "language": "json|http|...", "content": "..." } ],
#     "apiContracts": [
#       { "method": "POST", "path": "/v1/...", "requestExample": <json>, "responseExample": <json>, "source": "<heuristic>" }
#     ]
#   }
#
# Exit codes:
#   0  success
#   2  missing token or auth failure (after Save Flow trigger)
#   3  network / HTTP error
#   4  bad usage
#
# Notes on auth:
#   The script reads the Confluence token via credential-store.sh. If the
#   token is missing, the script prints a structured "blocked" JSON to stderr
#   and exits 2  -  the orchestrator handles the Save Flow + retry. The script
#   itself never prompts.

set -euo pipefail

URL=""
HOST_OVERRIDE="${CONFLUENCE_HOST_OVERRIDE:-}"
PAGE_ID=""
TIMEOUT="${CONFLUENCE_TIMEOUT_SECONDS:-20}"

while [ $# -gt 0 ]; do
  case "$1" in
    --host)    HOST_OVERRIDE="$2"; shift 2 ;;
    --page-id) PAGE_ID="$2"; shift 2 ;;
    -h|--help)
      echo "usage: $0 <page-url> | $0 --host <host> --page-id <id>" >&2
      exit 4 ;;
    *)
      if [ -z "$URL" ]; then URL="$1"; shift; else
        echo "ERR: unexpected arg $1" >&2; exit 4
      fi ;;
  esac
done

if [ -z "$URL" ] && [ -z "$PAGE_ID" ]; then
  echo "usage: $0 <page-url> | $0 --host <host> --page-id <id>" >&2
  exit 4
fi

# Resolve host + pageId from URL when not given explicitly. Inputs are passed
# as argv: an env prefix on a plain assignment (VAR=x PARSED=$(...)) never
# reaches the child process, so os.environ would always come back empty.
PARSED=$(python3 - "$URL" "$HOST_OVERRIDE" "$PAGE_ID" <<'PY'
import sys, re
import urllib.parse as up

argv = sys.argv[1:] + ["", "", ""]
url = argv[0]
host_override = argv[1]
explicit_id = argv[2]

host = host_override
page_id = explicit_id
space_key = ""

if url:
    p = up.urlparse(url)
    host = host_override or p.netloc
    query = up.parse_qs(p.query)
    if not page_id:
        page_id = (query.get("pageId") or [""])[0]
    m = re.search(r"/wiki/spaces/(?P<space>[^/]+)/pages/(?P<id>\d+)", p.path)
    if m:
        space_key = m.group("space")
        if not page_id:
            page_id = m.group("id")
    if not space_key:
        m = re.search(r"/display/(?P<space>[^/]+)/", p.path)
        if m:
            space_key = m.group("space")

print(f"{host}\t{page_id}\t{space_key}")
PY
)
HOST=$(printf '%s' "$PARSED" | cut -f1)
PAGE_ID_RESOLVED=$(printf '%s' "$PARSED" | cut -f2)
SPACE_KEY=$(printf '%s' "$PARSED" | cut -f3)

if [ -z "$HOST" ]; then
  echo "ERR: could not determine Confluence host" >&2
  exit 4
fi

# Resolve token via keychainMapping with sane fallback.
PREFS="$HOME/.claude/multi-agent-preferences.json"
TOKEN_KEY="${CONFLUENCE_AUTH_TOKEN_KEY:-}"
if [ -z "$TOKEN_KEY" ] && [ -f "$PREFS" ]; then
  TOKEN_KEY=$(python3 -c "
import json
try:
    p = json.load(open('$PREFS'))
    print(p.get('global', {}).get('keychainMapping', {}).get('confluence') or '')
except Exception:
    print('')
")
fi
[ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Confluence_Access_Token"

# Locate the resolver with an existence check, not a `.`-chain.
#
# Sourcing a file that does not exist aborts the shell under `set -e` - `||` included -
# so `. <candidate> || . <candidate> || { error }` reaches neither its later candidates
# nor its error branch. Every fetcher used that shape starting from `$HOME/.claude/...`,
# so on a Copilot-only or Codex-only install they all died with a bare exit 1 and no
# message. Reordering does not help: whichever candidate is absent aborts at that point.
# Checking for the file before sourcing it is the only safe form.
for _cred_resolver in \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/credential-store-resolver.sh" \
  "$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../lib" 2>/dev/null && pwd)/credential-store-resolver.sh" \
  "$HOME/.claude/lib/credential-store-resolver.sh" \
  "$HOME/.copilot/lib/credential-store-resolver.sh" \
  "$HOME/.codex/lib/credential-store-resolver.sh"; do
  [ -f "$_cred_resolver" ] || continue
  # shellcheck source=/dev/null
  . "$_cred_resolver" 2>/dev/null || true
  # `if`, not `[ ... ] && break`: the latter is the loop body's last command and returns
  # 1 when CRED_STORE is still empty, which under `set -e` kills the loop on the first
  # candidate that does not resolve - the very case the loop exists to survive.
  if [ -n "${CRED_STORE:-}" ]; then break; fi
done
unset _cred_resolver
if [ -z "${CRED_STORE:-}" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2
  exit 2
fi

TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
if [ -z "$TOKEN" ]; then
  printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"confluence","expected_key":"'"$TOKEN_KEY"'"}' >&2
  exit 2
fi

# Bearer auth via a curl config fed through process substitution so the token
# never appears in argv (argv is visible to ps).
confluence_auth_cfg() { printf 'header = "Authorization: Bearer %s"\n' "$TOKEN"; }

# Endpoint resolution: Confluence Cloud (<tenant>.atlassian.net/wiki/rest/api)
# vs Server/Data Center (<host>/rest/api). The same paths exist on both.
case "$HOST" in
  *atlassian.net*) API_BASE="https://$HOST/wiki/rest/api" ;;
  *)               API_BASE="https://$HOST/rest/api" ;;
esac

# When we have a page URL but no pageId yet, resolve via title lookup.
if [ -z "$PAGE_ID_RESOLVED" ] && [ -n "$SPACE_KEY" ] && [ -n "$URL" ]; then
  TITLE_RAW=$(python3 -c "
import urllib.parse as up
import sys, re
p = up.urlparse(sys.argv[1])
m = re.match(r'/display/[^/]+/(.+)', p.path)
print(up.unquote(m.group(1)) if m else '')
" "$URL")
  if [ -n "$TITLE_RAW" ]; then
    LOOKUP=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
      -K <(confluence_auth_cfg) -H "Accept: application/json" \
      --data-urlencode "spaceKey=$SPACE_KEY" \
      --data-urlencode "title=$TITLE_RAW" \
      --data-urlencode "expand=" \
      -G "$API_BASE/content" 2>/dev/null || true)
    PAGE_ID_RESOLVED=$(printf '%s' "$LOOKUP" | python3 -c "
import sys, json
try:
    r = json.load(sys.stdin)
    res = r.get('results') or []
    print(res[0]['id'] if res else '')
except Exception:
    print('')
")
  fi
fi

if [ -z "$PAGE_ID_RESOLVED" ]; then
  echo "ERR: could not resolve Confluence pageId from $URL" >&2
  exit 3
fi

# Fetch the page body in storage format.
PAGE_JSON=$(curl -sS --fail --max-time "$TIMEOUT" --connect-timeout 5 \
  -K <(confluence_auth_cfg) -H "Accept: application/json" \
  "$API_BASE/content/$PAGE_ID_RESOLVED?expand=body.storage,space" 2>/dev/null || true)

if [ -z "$PAGE_JSON" ]; then
  echo "ERR: Confluence GET failed for pageId=$PAGE_ID_RESOLVED" >&2
  exit 3
fi

PAGE_URL="$URL" \
PAGE_HOST="$HOST" \
PAGE_ID_FINAL="$PAGE_ID_RESOLVED" \
SPACE_KEY_FINAL="$SPACE_KEY" \
PAGE_JSON_RAW="$PAGE_JSON" \
python3 - <<'PY'
import json, os, re, datetime, html
from html.parser import HTMLParser

raw = os.environ["PAGE_JSON_RAW"]
data = json.loads(raw)

storage = ((data.get("body") or {}).get("storage") or {}).get("value") or ""
title = data.get("title") or "<untitled>"
space_key = ((data.get("space") or {}).get("key")) or os.environ.get("SPACE_KEY_FINAL", "") or None

# Very small HTML→markdown for the subset Confluence storage uses. Anything
# we can't translate cleanly is kept as-is  -  the goal is to give Phase 1 a
# readable, scannable prompt input, not perfect formatting.

CODE_BLOCKS = []

class Storage2MD(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.out = []
        self.stack = []
        self._code = None
        self._code_lang = None

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag in ("h1","h2","h3","h4","h5","h6"):
            self.out.append("\n" + "#" * int(tag[1]) + " ")
            self.stack.append(tag)
        elif tag in ("p", "br"):
            self.out.append("\n")
            self.stack.append(tag)
        elif tag in ("ul","ol"):
            self.out.append("\n")
            self.stack.append(tag)
        elif tag == "li":
            self.out.append("\n- ")
            self.stack.append(tag)
        elif tag in ("strong","b"):
            self.out.append("**"); self.stack.append(tag)
        elif tag in ("em","i"):
            self.out.append("*"); self.stack.append(tag)
        elif tag == "code":
            self.out.append("`"); self.stack.append(tag)
        elif tag == "a":
            self.out.append("["); self._href = a.get("href",""); self.stack.append(tag)
        elif tag == "ac:structured-macro":
            if a.get("ac:name") == "code":
                self._code = []
                self._code_lang = None
                self.stack.append("code-macro")
            else:
                self.stack.append(tag)
        elif tag == "ac:parameter":
            if "code-macro" in self.stack and a.get("ac:name") == "language":
                self._param_lang = True
                self.stack.append("code-param-lang")
            else:
                self.stack.append(tag)
        elif tag == "ac:plain-text-body":
            self.stack.append(tag)
            if "code-macro" in self.stack and self._code is not None:
                self._in_code_body = True

    def handle_endtag(self, tag):
        if tag in ("h1","h2","h3","h4","h5","h6","p","li","ul","ol","br"):
            if tag in ("p","li"): self.out.append("\n")
        elif tag in ("strong","b"): self.out.append("**")
        elif tag in ("em","i"):     self.out.append("*")
        elif tag == "code":         self.out.append("`")
        elif tag == "a":
            self.out.append(f"]({getattr(self,'_href','')})"); self._href = ""
        elif tag == "ac:structured-macro" and self.stack and self.stack[-1] == "code-macro":
            # flush code block
            body = "".join(self._code or []).strip()
            CODE_BLOCKS.append({"language": self._code_lang or "", "content": body})
            self.out.append(f"\n```{self._code_lang or ''}\n{body}\n```\n")
            self._code = None
            self._code_lang = None
        elif tag == "ac:parameter" and self.stack and self.stack[-1] == "code-param-lang":
            self._param_lang = False
        elif tag == "ac:plain-text-body":
            self._in_code_body = False
        if self.stack and (self.stack[-1] == tag or self.stack[-1] in {"code-macro","code-param-lang"}):
            self.stack.pop()

    def handle_data(self, data):
        if getattr(self, "_in_code_body", False) and self._code is not None:
            self._code.append(data)
        elif getattr(self, "_param_lang", False):
            self._code_lang = (data or "").strip() or self._code_lang
        else:
            self.out.append(data)

    def unknown_decl(self, data):
        # Confluence storage wraps code-block contents in <![CDATA[...]]>.
        # HTMLParser doesn't fire handle_data for those by default, so route
        # the payload through the same path as regular character data.
        if data.startswith("CDATA["):
            payload = data[len("CDATA["):]
            self.handle_data(payload)

    def get_markdown(self):
        text = "".join(self.out)
        text = re.sub(r"\n{3,}", "\n\n", text)
        return text.strip()

parser = Storage2MD()
parser.feed(storage)
md = parser.get_markdown()

# Best-effort API contract extraction.
# Heuristic: find a code block whose language is "http" or whose first line
# looks like `<METHOD> <path>`, optionally followed by a JSON code block.
api_contracts = []
HTTP_LINE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(/\S+)\s*$", re.M)
for i, blk in enumerate(CODE_BLOCKS):
    content = blk["content"] or ""
    lang = (blk["language"] or "").lower()
    if lang in ("http", "rest"):
        m = HTTP_LINE.search(content)
        if not m:
            continue
        method, path = m.group(1), m.group(2)
    elif lang in ("", "text"):
        m = HTTP_LINE.match(content)
        if not m:
            continue
        method, path = m.group(1), m.group(2)
    else:
        continue
    # Look ahead for a JSON request body in the next block (request) and
    # the one after (response). Cheap heuristic  -  covers the most common
    # "Method/Path → Request JSON → Response JSON" pattern.
    request_example = None
    response_example = None
    for j in (i + 1, i + 2):
        if j < len(CODE_BLOCKS):
            try:
                candidate = json.loads(CODE_BLOCKS[j]["content"])
                if request_example is None:
                    request_example = candidate
                elif response_example is None:
                    response_example = candidate
                    break
            except Exception:
                pass
    api_contracts.append({
        "method": method,
        "path": path,
        "requestExample": request_example,
        "responseExample": response_example,
        "source": f"codeBlocks[{i}]",
    })

result = {
    "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z",
    "source": {
        "url": os.environ.get("PAGE_URL") or None,
        "host": os.environ.get("PAGE_HOST"),
        "pageId": os.environ.get("PAGE_ID_FINAL"),
        "spaceKey": space_key,
    },
    "title": title,
    "bodyMarkdown": md,
    "codeBlocks": CODE_BLOCKS,
    "apiContracts": api_contracts,
}
print(json.dumps(result, ensure_ascii=False))
PY
