#!/bin/bash
#
# context-link-extractor.sh
# Parse free-form text (Jira description, GitHub Issue body, PR body) and emit
# a JSON catalogue of typed external links the pipeline cares about. The
# orchestrator persists this to agent-state.json.contextLinks[] in Phase 0;
# Phase 1 dispatches each entry to the matching fetcher for analysis context.
#
# Recognised types:
#   - swagger     : OpenAPI / Swagger JSON or YAML spec URLs
#   - confluence  : Confluence pages (host pattern + display/spaces/pageId)
#   - crashlytics : Firebase Crashlytics issue/session URLs
#   - fortify     : Fortify SSC finding URLs, and (URL-less, like graylog)
#                   labelled issue instance ids  -  "Fortify Instance ID: <id>"
#   - graylog     : Graylog trx-id / conversation-id references. Unlike the
#                   others this is matched from free text (labels like "trx",
#                   "trxid", "transaction id", "conversationId", "convId",
#                   "X-conversationId" followed by an id) rather than a URL, so
#                   its entries carry "url": null. Advisory diagnostic context;
#                   pattern-based + tolerant, a project may refine the labels.
#   - figma       : Figma design / make / FigJam / slides URLs
#   - document    : a local document path or a document URL - .docx, .pdf, .md,
#                   .txt. Distinct from generic-doc, which is an HTML page: this
#                   one is a file that must be converted before it can be read.
#   - generic-doc : http(s) URLs that didn't match a more specific type but
#                   look like documentation (Notion, Google Docs, GitHub
#                   markdown blob/wiki, gist)  -  passed through as advisory
#
# Usage:
#   echo "<description text>" | ./context-link-extractor.sh
#   ./context-link-extractor.sh < /path/to/description.txt
#
# Output (stdout, single line JSON array):
#   [
#     { "type": "confluence", "url": "...", "metadata": { "host": "...", "pageId": "...|null", "spaceKey": "...|null" } },
#     { "type": "swagger",    "url": "...", "metadata": { "format": "json|yaml|unknown" } },
#     { "type": "crashlytics","url": "...", "metadata": { "projectId": "...", "platform": "ios|android", "issueId": "...", "sessionId": "...|null" } },
#     { "type": "fortify",    "url": "...|null", "metadata": { "host": "...|null", "versionId": "...|null", "instanceId": "...|null", "source": "url|label" } },
#     { "type": "document",   "url": "...|null", "metadata": { "path": "...", "format": "docx|pdf|md|txt", "source": "path|url" } },
#     { "type": "graylog",    "url": null,  "metadata": { "idType": "trx|conversation", "id": "...", "label": "..." } },
#     { "type": "figma",      "url": "...", "metadata": { "kind": "design|make|board|slides", "fileKey": "...", "nodeId": "...|null" } },
#     ...
#   ]
#
# Exit codes:
#   0  always  -  emits "[]" when no input or no matches; downstream readers
#               must tolerate an empty array.

set -euo pipefail

CONTEXT_LINK_INPUT=$(cat)
export CONTEXT_LINK_INPUT

python3 - <<'PY'
import json
import os
import re
import urllib.parse as up

text = os.environ.get("CONTEXT_LINK_INPUT", "")

# Pull every http(s) URL out of the input first; downstream classifiers run
# against this candidate set. The trailing-punctuation strip avoids capturing
# a closing paren or comma that lives in the surrounding sentence.
URL_RE = re.compile(r"https?://[^\s<>\"'`)\]\}]+")
RAW_URLS = []
for m in URL_RE.finditer(text):
    u = m.group(0).rstrip(".,;:!?")
    # de-dupe while preserving first-seen order
    if u not in RAW_URLS:
        RAW_URLS.append(u)


def classify(url: str):
    """Return (type, metadata) when the URL matches a known channel; else None."""
    parsed = up.urlparse(url)
    host = parsed.netloc.lower()
    path = parsed.path
    query = up.parse_qs(parsed.query)

    # --- Figma --------------------------------------------------------------
    # figma.com/{design|make|board|slides|file}/<fileKey>/...
    if host.endswith("figma.com"):
        parts = [p for p in path.split("/") if p]
        kind = parts[0] if parts else "design"
        # Normalise legacy /file/ to /design/
        if kind == "file":
            kind = "design"
        if kind not in {"design", "make", "board", "slides"}:
            kind = "design"
        file_key = parts[1] if len(parts) > 1 else None
        node_id = (query.get("node-id") or [None])[0]
        if node_id:
            node_id = node_id.replace("-", ":")
        if file_key:
            return ("figma", {"kind": kind, "fileKey": file_key, "nodeId": node_id})

    # --- Firebase Crashlytics ----------------------------------------------
    # console.firebase.google.com/(u/<N>/)?project/<projectId>/crashlytics/app/(ios|android)(:|%3A)<bundle>/issues/<issueId>(/sessions/<sessionId>)?
    if host == "console.firebase.google.com":
        m = re.search(
            r"/project/(?P<project>[^/]+)/crashlytics/app/(?P<platform>ios|android)(?::|%3A)(?P<bundle>[^/]+)/issues/(?P<issue>[^/?#]+)(?:/sessions/(?P<session>[^/?#]+))?",
            path,
        )
        if m:
            return (
                "crashlytics",
                {
                    "projectId": m.group("project"),
                    "platform": m.group("platform"),
                    "bundle": up.unquote(m.group("bundle")),
                    "issueId": m.group("issue"),
                    "sessionId": m.group("session"),
                },
            )

    # --- Confluence --------------------------------------------------------
    # Confluence cloud:   <tenant>.atlassian.net/wiki/spaces/<SPACE>/pages/<id>/<title>
    # Confluence server:  <host>/display/<SPACE>/<title>  or  ?pageId=<id>
    looks_like_confluence = (
        host.endswith(".atlassian.net")
        or "confluence" in host
        or "/wiki/spaces/" in path
        or "/display/" in path
    )
    if looks_like_confluence:
        space_key = None
        page_id = (query.get("pageId") or [None])[0]
        m = re.search(r"/wiki/spaces/(?P<space>[^/]+)/pages/(?P<id>\d+)", path)
        if m:
            space_key = m.group("space")
            page_id = m.group("id")
        if not space_key:
            m = re.search(r"/display/(?P<space>[^/]+)/", path)
            if m:
                space_key = m.group("space")
        if "/wiki/" in path or "/display/" in path or page_id:
            return ("confluence", {"host": host, "pageId": page_id, "spaceKey": space_key})

    # --- Swagger / OpenAPI -------------------------------------------------
    # Heuristic: explicit .json/.yaml/.yml ending on a path that mentions
    # swagger or openapi, or a host that starts with swagger./api-docs./openapi.
    lower_url = url.lower()
    swagger_signals = (
        "swagger" in lower_url
        or "openapi" in lower_url
        or path.endswith(("/swagger.json", "/openapi.json", "/swagger.yaml", "/openapi.yaml", "/openapi.yml"))
    )
    if swagger_signals and (path.endswith((".json", ".yaml", ".yml")) or "/swagger" in path or "/openapi" in path):
        if path.endswith((".yaml", ".yml")):
            fmt = "yaml"
        elif path.endswith(".json"):
            fmt = "json"
        else:
            fmt = "unknown"
        return ("swagger", {"format": fmt})

    # --- Fortify SSC -------------------------------------------------------
    # Common forms:
    #   https://<ssc-host>/ssc/html/ssc/...
    #   https://<ssc-host>/ssc/api/v1/projectVersions/<id>/...
    if "fortify" in host or "/ssc/" in path or "/api/v1/projectVersions/" in path:
        # The SSC UI keeps the version id in the fragment on some deployments
        # and in the path on others; search both.
        where = path + ("#" + parsed.fragment if parsed.fragment else "")
        m = re.search(r"/(?:projectVersions|version)/(?P<id>\d+)", where)
        version_id = m.group("id") if m else None
        m2 = re.search(r"/fix/(?P<inst>[A-Za-z0-9_-]{8,})", where)
        return ("fortify", {
            "host": host,
            "versionId": version_id,
            "instanceId": m2.group("inst") if m2 else None,
            "source": "url",
        })

    # --- Document files (a URL that points at a file, not a page) -----------
    m_doc = re.search(r"\.(docx|pdf|md|markdown|txt)$", path, re.IGNORECASE)
    if m_doc:
        fmt = m_doc.group(1).lower()
        return ("document", {"path": url, "format": "md" if fmt == "markdown" else fmt,
                             "source": "url"})

    # --- Generic doc fallbacks ---------------------------------------------
    # Notion, Google Docs, GitHub markdown blob/wiki, gists. Useful as
    # advisory context  -  extractor flags them so the orchestrator can decide
    # whether to fetch (Block B work).
    if (
        host.endswith(("notion.so", "notion.site"))
        or host == "docs.google.com"
        or host == "drive.google.com"
        or host == "gist.github.com"
        or (host == "github.com" and ("/blob/" in path or "/wiki/" in path))
    ):
        kind = "notion" if "notion" in host else (
            "google-docs" if "google" in host else (
                "gist" if "gist" in host else "github-doc"
            )
        )
        return ("generic-doc", {"host": host, "kind": kind})

    return None


out = []
seen = set()
for url in RAW_URLS:
    result = classify(url)
    if result is None:
        continue
    typ, meta = result
    key = (typ, url)
    if key in seen:
        continue
    seen.add(key)
    out.append({"type": typ, "url": url, "metadata": meta})

# --- Graylog trx / conversation ids (label-based, not URL) -----------------
# Recognise free-text references like "trxId: ABC123", "transaction id = ...",
# "conversationId ...", "X-conversationId: ...". Pattern-based + tolerant  -  a
# project can refine the label set. Longer/more-specific labels lead the
# alternation so "trxid" is not shadowed by "trx". The id is alphanumeric plus
# ._- and long enough (>= 6 chars) to avoid catching stray words.
GRAYLOG_RE = re.compile(
    r"(?P<label>"
    r"x[\s_-]*conversation[\s_-]*id"
    r"|conversation[\s_-]*id"
    r"|conv[\s_-]*id"
    r"|transaction[\s_-]*id"
    r"|trx[\s_-]*id"
    r"|trx"
    r")"
    r"\s*[:=#]?\s*"
    r"[\"']?(?P<id>[A-Za-z0-9][A-Za-z0-9._-]{5,})",
    re.IGNORECASE,
)
for m in GRAYLOG_RE.finditer(text):
    gid = m.group("id").rstrip(".,;:!?)")
    label = m.group("label")
    id_type = "conversation" if "conv" in label.lower() else "trx"
    key = ("graylog", gid)
    if not gid or key in seen:
        continue
    seen.add(key)
    out.append({
        "type": "graylog",
        "url": None,
        "metadata": {"idType": id_type, "id": gid, "label": label},
    })

# --- Fortify issue instance ids (label-based, not URL) ---------------------
# A finding often arrives as a ticket written by a scanner-to-tracker bridge,
# which quotes the instance id and the file:line but no SSC deep link  -  the
# bridge knows the id, the reader does not need the URL, and the pipeline then
# saw nothing to enrich. Same shape as the graylog entries: "url": null, the id
# in metadata. Resolution to a project version happens in fetch-fortify.sh,
# which is where the prefs live.
FORTIFY_ID_RE = re.compile(
    r"(?P<label>"
    r"fortify[\s_-]*instance[\s_-]*id"
    r"|issue[\s_-]*instance[\s_-]*id"
    r"|fortify[\s_-]*id"
    r")"
    r"[\s*_~`]*[:=#]?[\s*_~`]*"
    r"[\"']?(?P<id>(?=[A-Za-z0-9._-]*[0-9])[A-Za-z0-9][A-Za-z0-9._-]{15,})",
    re.IGNORECASE,
)
already_linked = {
    e["metadata"].get("instanceId")
    for e in out
    if e["type"] == "fortify" and e["metadata"].get("instanceId")
}
for m in FORTIFY_ID_RE.finditer(text):
    fid = m.group("id").rstrip(".,;:!?)")
    key = ("fortify", fid)
    if not fid or key in seen or fid in already_linked:
        continue
    seen.add(key)
    out.append({
        "type": "fortify",
        "url": None,
        "metadata": {
            "host": None,
            "versionId": None,
            "instanceId": fid,
            "source": "label",
            "label": m.group("label"),
        },
    })

# --- Local document paths (not URLs, so the URL pass above never sees them) -
# A spec arrives as often as a path as it does as a link: "/Users/x/Spec.docx",
# "~/notes/api.md". Quotes and surrounding prose are stripped; the extension is
# what makes it a document, so a bare directory or a code path cannot match.
DOCUMENT_PATH_RE = re.compile(
    r"(?<![\w/])(~?/[^\s\"'<>|]+\.(?:docx|pdf|md|markdown|txt))",
    re.IGNORECASE,
)
# A URL contains a slash-path too, so skip any match that falls inside one -
# otherwise "https://x/api.pdf" also yields the bogus local path "//x/api.pdf".
url_spans = [m.span() for m in URL_RE.finditer(text)]
for m in DOCUMENT_PATH_RE.finditer(text):
    if any(a <= m.start() < b for a, b in url_spans):
        continue
    raw = m.group(1).rstrip(".,;:!?)")
    key = ("document", raw)
    if key in seen:
        continue
    seen.add(key)
    fmt = raw.rsplit(".", 1)[1].lower()
    out.append({
        "type": "document",
        "url": None,
        "metadata": {"path": raw, "format": "md" if fmt == "markdown" else fmt,
                     "source": "path"},
    })

print(json.dumps(out))
PY
