#!/usr/bin/env python3
"""
OCI resource map + cost visualization.

For each profile in ~/.oci/config:
  1. Enumerates every resource in the tenancy via Resource Search
  2. Pulls last-30-days cost via Usage API at two granularities:
     - compartment x service (summary)
     - compartment x sku x resourceId (line-item drill-down)
  3. Categorises each resource (Compute / Storage / Network / ...)
  4. Flags system-created vs user-created resources
  5. Cross-references against engineering/apps/*.md
  6. Writes infra/oci-resource-map.md with:
       - A top-level LR mermaid (tenancies -> compartments)
       - One collapsible per compartment containing:
            - a focused LR mermaid (category -> resources, cost-bearing only)
            - top resources by cost
            - SKU breakdown
            - full resource table (categorised, with age + system flag)

Run:  .venv/bin/python oci-resource-map.py [--profiles ORA448Global EIDOSDev1]
"""
from __future__ import annotations

import argparse
import datetime as dt
import re
import sys
import warnings
from collections import defaultdict
from configparser import ConfigParser
from pathlib import Path

import oci

warnings.filterwarnings("ignore", category=FutureWarning)

REPO = Path(__file__).resolve().parents[2]
APPS_DIR = REPO / "apps"
OUTPUT = REPO / "infra" / "oci-resource-map.md"
OCI_CONFIG = Path.home() / ".oci" / "config"

OCID_RE = re.compile(r"ocid1\.[a-z0-9]+\.oc1[.\-][a-z0-9\-]*\.?[a-z0-9]+")
COMPARTMENT_RE = re.compile(r"\*\*Compartment:\*\*\s*`?([A-Za-z0-9_\-]+)`?")
TENANCY_RE = re.compile(r"\*\*Tenancy:\*\*\s*`?([A-Za-z0-9_\-]+)`?")

# Resource type -> high-level category. Anything missing falls to "Other".
CATEGORY = {
    # Compute
    "Instance": "Compute", "BootVolume": "Compute", "Image": "Compute",
    "InstancePool": "Compute", "InstanceConfiguration": "Compute",
    # Storage
    "BootVolumeBackup": "Storage", "BootVolumeReplica": "Storage",
    "Volume": "Storage", "VolumeBackup": "Storage", "VolumeGroup": "Storage",
    "Bucket": "Storage", "FileSystem": "Storage", "MountTarget": "Storage",
    # Network
    "Vcn": "Network", "Subnet": "Network", "RouteTable": "Network",
    "SecurityList": "Network", "NetworkSecurityGroup": "Network",
    "InternetGateway": "Network", "NatGateway": "Network",
    "ServiceGateway": "Network", "LocalPeeringGateway": "Network",
    "Drg": "Network", "DrgAttachment": "Network", "RemotePeeringConnection": "Network",
    "PrivateIp": "Network", "PublicIp": "Network", "Vnic": "Network",
    "VnicAttachment": "Network", "VlanIp": "Network",
    "LoadBalancer": "Network", "NetworkLoadBalancer": "Network",
    # DNS
    "CustomerDnsZone": "DNS", "DnsResolver": "DNS", "DnsView": "DNS",
    "Steeringpolicy": "DNS", "DnsTsigKey": "DNS",
    # Database
    "AutonomousDatabase": "Database", "AutonomousContainerDatabase": "Database",
    "DbSystem": "Database", "Database": "Database", "MysqlDbSystem": "Database",
    "Cluster": "Database",
    # IAM
    "User": "IAM", "Group": "IAM", "Policy": "IAM", "Compartment": "IAM",
    "DynamicGroup": "IAM", "IdentityProvider": "IAM",
    # Email
    "EmailSender": "Email", "EmailDkim": "Email", "EmailDomain": "Email",
    # Monitoring/Notifications
    "OnsTopic": "Monitoring", "OnsSubscription": "Monitoring",
    "Alarm": "Monitoring", "LogGroup": "Monitoring", "Log": "Monitoring",
    # OS Management
    "OsmhProfile": "OS Management", "OsmhManagedInstance": "OS Management",
    # Tags
    "TagDefault": "Tagging", "TagNamespace": "Tagging",
    # DevOps
    "VbsInstance": "DevOps", "DevopsProject": "DevOps", "DevopsRepository": "DevOps",
    # Vault / KMS
    "Vault": "Security", "Key": "Security", "Secret": "Security",
}

CATEGORY_ORDER = [
    "Database", "Compute", "Storage", "Network", "DNS", "Email",
    "Monitoring", "Security", "IAM", "Tagging", "OS Management", "DevOps", "Other",
]


def categorise(rtype: str) -> str:
    return CATEGORY.get(rtype, "Other")


def is_system_managed(rtype: str, name: str | None) -> bool:
    """Heuristic: True for Oracle-created or auto-managed resources."""
    name = (name or "").strip()
    if not name:
        return False
    if name.startswith("Default ") or name.startswith("default "):
        return True
    if name.startswith("Auto-backup"):
        return True
    if rtype == "TagNamespace" and "Oracle" in name:
        return True
    if rtype == "TagDefault" and name.lower() in {"createdby", "createdon"}:
        return True
    if rtype == "OsmhProfile":
        return True
    if rtype == "Policy" and name in {"Tenant Admin Policy", "PSM-root-Policy"}:
        return True
    if rtype == "Group" and name in {"Administrators", "All Domain Users"}:
        return True
    if rtype == "Compartment" and name == "ManagedCompartmentForPaaS":
        return True
    return False


def humanise_age(ts) -> str:
    if ts is None:
        return "—"
    if isinstance(ts, str):
        try:
            ts = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
        except ValueError:
            return ts
    now = dt.datetime.now(dt.timezone.utc)
    if ts.tzinfo is None:
        ts = ts.replace(tzinfo=dt.timezone.utc)
    delta = now - ts
    d = delta.days
    if d < 1:
        h = int(delta.total_seconds() // 3600)
        return f"{h}h ago"
    if d < 31:
        return f"{d}d ago"
    if d < 365:
        return f"{d // 30}mo ago"
    return f"{d // 365}y {(d % 365) // 30}mo ago"


def load_app_index() -> tuple[dict[str, str], dict[tuple[str, str], set[str]]]:
    ocid_to_app: dict[str, str] = {}
    comp_to_apps: dict[tuple[str, str], set[str]] = defaultdict(set)
    for md in sorted(APPS_DIR.glob("[0-9][0-9]-*.md")):
        slug = md.stem.split("-", 1)[1]
        text = md.read_text()
        for ocid in OCID_RE.findall(text):
            ocid_to_app.setdefault(ocid, slug)
        tenancy_m = TENANCY_RE.search(text)
        for comp in COMPARTMENT_RE.findall(text):
            tenancy = tenancy_m.group(1) if tenancy_m else "?"
            comp_to_apps[(tenancy, comp)].add(slug)
    return ocid_to_app, comp_to_apps


def discover_profiles() -> list[str]:
    if not OCI_CONFIG.exists():
        sys.exit(f"missing OCI config at {OCI_CONFIG}")
    cp = ConfigParser()
    cp.read(OCI_CONFIG)
    return [s for s in cp.sections() if s != "DEFAULT"] or (["DEFAULT"] if "DEFAULT" in cp else [])


def enumerate_tenancy(profile: str) -> dict:
    cfg = oci.config.from_file(profile_name=profile)
    oci.config.validate_config(cfg)
    identity = oci.identity.IdentityClient(cfg)
    search = oci.resource_search.ResourceSearchClient(cfg)

    tenancy_id = cfg["tenancy"]
    tenancy_name = identity.get_tenancy(tenancy_id).data.name

    comp_name: dict[str, str] = {tenancy_id: "root"}
    try:
        comps = oci.pagination.list_call_get_all_results(
            identity.list_compartments,
            tenancy_id,
            compartment_id_in_subtree=True,
            access_level="ANY",
        ).data
        for c in comps:
            comp_name[c.id] = c.name
    except oci.exceptions.ServiceError as e:
        print(f"  WARNING: list_compartments failed for {profile}: {e.message}", file=sys.stderr)

    resources = []
    page = None
    while True:
        resp = search.search_resources(
            oci.resource_search.models.StructuredSearchDetails(
                type="Structured", query="query all resources"
            ),
            limit=1000, page=page,
        )
        resources.extend(resp.data.items)
        page = resp.next_page
        if not page:
            break

    return {
        "profile": profile,
        "tenancy_id": tenancy_id,
        "tenancy_name": tenancy_name,
        "region": cfg["region"],
        "compartments": comp_name,
        "resources": resources,
    }


def fetch_cost(profile, tenancy_id, group_by, days=30):
    cfg = oci.config.from_file(profile_name=profile)
    usage = oci.usage_api.UsageapiClient(cfg)
    end = dt.datetime.now(dt.timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
    start = end - dt.timedelta(days=days)
    try:
        resp = usage.request_summarized_usages(
            oci.usage_api.models.RequestSummarizedUsagesDetails(
                tenant_id=tenancy_id,
                time_usage_started=start,
                time_usage_ended=end,
                granularity="DAILY",
                query_type="COST",
                group_by=group_by,
                compartment_depth=6,
            )
        )
        return resp.data.items, None
    except oci.exceptions.ServiceError as e:
        return [], f"{e.status} {e.code}: {e.message}"


def attribute(resources, ocid_to_app, comp_to_apps, tenancy_name, comp_names):
    n_attr = n_unattr = 0
    for r in resources:
        app = ocid_to_app.get(r.identifier)
        if not app:
            comp = comp_names.get(r.compartment_id, "?")
            apps = comp_to_apps.get((tenancy_name, comp), set())
            if len(apps) == 1:
                app = f"{next(iter(apps))} (by compartment)"
        if app:
            n_attr += 1
        else:
            n_unattr += 1
        r._app = app
        r._category = categorise(r.resource_type)
        r._system = is_system_managed(r.resource_type, r.display_name)
    return n_attr, n_unattr


def fmt_money(amount: float, currency: str) -> str:
    sym = {"GBP": "£", "USD": "$", "EUR": "€"}.get(currency, currency + " ")
    return f"{sym}{amount:,.2f}"


def safe_label(s: str, maxlen: int = 40) -> str:
    """Escape characters that break Mermaid labels."""
    if s is None:
        return "?"
    s = s.replace('"', "'").replace("[", "(").replace("]", ")").replace("|", "/")
    s = s.replace("\\", "/")
    return (s[:maxlen] + "...") if len(s) > maxlen else s


def build_view(t: dict, svc_rows, line_rows):
    """Aggregate everything into a per-compartment view structure."""
    comp_names = t["compartments"]
    name_to_cid = {v: k for k, v in comp_names.items()}

    # OCIDs we got back from Resource Search. Anything not in this set is an
    # ephemeral cost line — typically per-object Object Storage rows, deleted
    # resources, or sub-resources that Search doesn't index. Their cost still
    # rolls up into compartment/SKU totals, just not into the resource table.
    known_ocids = {r.identifier for r in t["resources"]}

    # Per-resource cost from the line-item call (resourceId-keyed).
    resource_cost: dict[str, float] = defaultdict(float)
    # Per-compartment-sku for the SKU table.
    sku_cost: dict[tuple[str, str], float] = defaultdict(float)
    # Per-compartment overall (ground truth for compartment totals).
    comp_cost: dict[str, float] = defaultdict(float)
    currency = ""
    for it in line_rows:
        cname = it.compartment_name or "root"
        sku = it.sku_name or "Unknown SKU"
        amt = float(it.computed_amount or 0)
        currency = currency or (it.currency or "")
        comp_cost[cname] += amt
        sku_cost[(cname, sku)] += amt
        # Skip Object Storage entirely at resource level — OCI emits one cost
        # row per object which is noise, not signal. Bucket-level summary is
        # still visible via the SKU table and compartment total.
        if it.resource_id and it.resource_id in known_ocids and not (sku or "").startswith("Object Storage"):
            resource_cost[it.resource_id] += amt

    # Per-compartment-service from the summary call.
    svc_cost: dict[tuple[str, str], float] = defaultdict(float)
    for it in svc_rows:
        cname = it.compartment_name or "root"
        svc = it.service or "Unknown"
        amt = float(it.computed_amount or 0)
        currency = currency or (it.currency or "")
        svc_cost[(cname, svc)] += amt

    # Group resources by compartment.
    by_comp: dict[str, list] = defaultdict(list)
    for r in t["resources"]:
        cid = r.compartment_id or t["tenancy_id"]
        cname = comp_names.get(cid, cid[-12:])
        r._compartment_name = cname
        r._cost = resource_cost.get(r.identifier, 0.0)
        by_comp[cname].append(r)

    # Make sure every compartment that has cost but no resources also shows.
    for cname in comp_cost:
        by_comp.setdefault(cname, [])

    compartments_view = []
    for cname, rs in by_comp.items():
        cid = name_to_cid.get(cname, t["tenancy_id"])
        # Group by category -> service -> resources
        by_cat: dict[str, dict[str, list]] = defaultdict(lambda: defaultdict(list))
        for r in rs:
            by_cat[r._category][r.resource_type].append(r)
        apps = {r._app.split(" (")[0] for r in rs if r._app}
        compartments_view.append({
            "id": cid,
            "name": cname,
            "total": comp_cost.get(cname, 0.0),
            "resource_count": len(rs),
            "system_count": sum(1 for r in rs if r._system),
            "user_count": sum(1 for r in rs if not r._system),
            "apps": apps,
            "by_category": by_cat,
            "skus": {sku: amt for (c, sku), amt in sku_cost.items() if c == cname},
            "services": {svc: amt for (c, svc), amt in svc_cost.items() if c == cname},
            "resources": rs,
        })

    # Drop phantom rows: compartment names that came only from cost-API
    # aggregations (e.g. the tenancy itself) and hold no resources or money.
    compartments_view = [
        cv for cv in compartments_view
        if cv["resource_count"] > 0 or cv["total"] > 0.005
    ]
    compartments_view.sort(key=lambda x: -x["total"])
    return compartments_view, currency


def render_summary_mermaid(tenancies) -> str:
    """Tenancy -> compartment, with cost; LR layout."""
    lines = ["```mermaid", "graph LR"]
    n = 0

    def nid(p):
        nonlocal n
        n += 1
        return f"{p}{n}"

    for t in tenancies:
        currency = t["currency"]
        total = sum(c["total"] for c in t["compartments_view"])
        t_id = nid("T")
        lines.append(
            f'  {t_id}["<b>{safe_label(t["tenancy_name"])}</b><br/>'
            f'{fmt_money(total, currency)}/30d<br/>'
            f'{len(t["resources"])} resources"]'
        )
        for cv in t["compartments_view"]:
            c_id = nid("C")
            apps = ", ".join(sorted(cv["apps"])) if cv["apps"] else "—"
            lines.append(
                f'  {t_id} --> {c_id}["<b>{safe_label(cv["name"])}</b><br/>'
                f'{fmt_money(cv["total"], currency)}/30d<br/>'
                f'{cv["resource_count"]} resources ({cv["user_count"]} user / {cv["system_count"]} system)<br/>'
                f'apps: {safe_label(apps, 50)}"]'
            )
    lines.append("```")
    return "\n".join(lines)


def render_compartment_mermaid(cv: dict, currency: str) -> str:
    """Compartment -> category -> resource (cost-bearing only); LR layout."""
    lines = ["```mermaid", "graph LR"]
    cnode = "C0"
    lines.append(
        f'  {cnode}["<b>{safe_label(cv["name"])}</b><br/>{fmt_money(cv["total"], currency)}/30d"]'
    )

    nid = 1

    def nxt():
        nonlocal nid
        nid += 1
        return nid

    # Walk categories in our canonical order, but only those present.
    cat_keys = [c for c in CATEGORY_ORDER if c in cv["by_category"]]
    for cat in cat_keys:
        services = cv["by_category"][cat]
        # Sum cost in this category
        cat_cost = sum(r._cost for svc_rs in services.values() for r in svc_rs)
        cat_n = sum(len(svc_rs) for svc_rs in services.values())
        cat_id = f"CAT{nxt()}"
        lines.append(
            f'  {cnode} --> {cat_id}["<b>{cat}</b><br/>{fmt_money(cat_cost, currency)}/30d<br/>{cat_n} resources"]'
        )
        # Cost-bearing resources individually; non-cost ones collapsed per service.
        for svc, rs in services.items():
            cost_bearing = [r for r in rs if r._cost > 0]
            free = [r for r in rs if r._cost <= 0]
            for r in sorted(cost_bearing, key=lambda x: -x._cost):
                r_id = f"R{nxt()}"
                tag = f" → <b>{r._app}</b>" if r._app else ""
                sysflag = " [sys]" if r._system else ""
                lines.append(
                    f'  {cat_id} --> {r_id}["{svc}{sysflag}<br/>'
                    f'{safe_label(r.display_name or r.identifier[-12:], 35)}<br/>'
                    f'{fmt_money(r._cost, currency)}/30d{tag}"]'
                )
            if free:
                f_id = f"F{nxt()}"
                sys_n = sum(1 for r in free if r._system)
                lines.append(
                    f'  {cat_id} --> {f_id}["{svc} ×{len(free)}<br/>free tier<br/>'
                    f'{sys_n} system / {len(free) - sys_n} user"]'
                )

    lines.append("```")
    return "\n".join(lines)


def render_resource_table(cv: dict, currency: str) -> str:
    """Full resource table for a compartment, categorised, with age + system flag."""
    out = ["| Category | Type | Name | Cost | Age | System? | App | OCID |",
           "|---|---|---|---|---|---|---|---|"]
    rs = sorted(
        cv["resources"],
        key=lambda x: (-x._cost, x._category, x.resource_type, x.display_name or ""),
    )
    for r in rs:
        cost = fmt_money(r._cost, currency) if r._cost else "—"
        age = humanise_age(getattr(r, "time_created", None))
        sysf = "system" if r._system else "user"
        app = r._app or "—"
        name = safe_label(r.display_name or "—", 60)
        out.append(
            f"| {r._category} | {r.resource_type} | {name} | {cost} | {age} | {sysf} | {app} | `{r.identifier[-16:]}` |"
        )
    return "\n".join(out)


def render_sku_table(cv: dict, currency: str) -> str:
    if not cv["skus"]:
        return "_no cost data_"
    items = sorted(cv["skus"].items(), key=lambda x: -x[1])
    out = ["| SKU | Cost |", "|---|---|"]
    for sku, amt in items:
        if amt < 0.01:
            continue
        out.append(f"| {sku} | {fmt_money(amt, currency)} |")
    if len(out) == 2:
        out.append("| _all SKUs < £0.01_ |  |")
    return "\n".join(out)


def render_doc(tenancies, errors) -> str:
    now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    parts = [
        "# OCI Resource Map & Cost",
        "",
        f"_Generated by `infra/scripts/oci-resource-map.py` at **{now}**._",
        "",
        "> OCI cost data lags ~6h (Usage API). Figures here cover the **last 30 days**. "
        "Each cost is line-item (per-resource, per-SKU) where available. "
        "Re-run the script to refresh.",
        "",
        "> **Legend** `[sys]` = system- or Oracle-managed (default route tables, "
        "auto-backups, Oracle tag namespace, etc.); everything else is user-created.",
        "",
    ]
    if errors:
        parts += ["## Errors during run", ""]
        for e in errors:
            parts.append(f"- {e}")
        parts.append("")

    # Summary table
    parts += ["## Summary", "",
              "| Tenancy | Region | Resources | User / System | 30-day cost | Unattributed |",
              "|---|---|---|---|---|---|"]
    for t in tenancies:
        user = sum(cv["user_count"] for cv in t["compartments_view"])
        system = sum(cv["system_count"] for cv in t["compartments_view"])
        total = sum(cv["total"] for cv in t["compartments_view"])
        parts.append(
            f"| `{t['tenancy_name']}` | {t['region']} | {len(t['resources'])} | "
            f"{user} / {system} | {fmt_money(total, t['currency'])} | "
            f"{t['n_unattr']} / {len(t['resources'])} |"
        )
    parts.append("")

    # Top-level mermaid
    parts += ["## Tenancy → compartments", "", render_summary_mermaid(tenancies), ""]

    # Per-compartment drill-down (collapsibles)
    parts += ["## Compartment drill-down", "",
              "_Each compartment below is a collapsible block. Open it for the focused "
              "resource graph, per-resource cost, SKU breakdown, and the full resource table._",
              ""]
    for t in tenancies:
        parts.append(f"### {t['tenancy_name']} (`{t['region']}`)")
        parts.append("")
        for cv in t["compartments_view"]:
            # Open by default if non-trivial cost or has apps.
            open_marker = "???+" if (cv["total"] > 0.01 or cv["apps"]) else "???"
            apps = ", ".join(sorted(cv["apps"])) if cv["apps"] else "no app mapping"
            header = (
                f'{cv["name"]} — {fmt_money(cv["total"], t["currency"])}/30d · '
                f'{cv["resource_count"]} resources '
                f'({cv["user_count"]} user / {cv["system_count"]} system) · '
                f'{apps}'
            )
            parts.append(f'{open_marker} note "{header}"')
            parts.append("")
            # Everything inside the details block must be 4-space indented,
            # including every line of any multi-line content (mermaid, tables).
            sections = [
                "**Resource graph (this compartment):**",
                "",
                render_compartment_mermaid(cv, t["currency"]),
                "",
                "**Top SKUs (cost line items):**",
                "",
                render_sku_table(cv, t["currency"]),
                "",
                "**All resources in this compartment:**",
                "",
                render_resource_table(cv, t["currency"]),
            ]
            for section in sections:
                for line in section.split("\n"):
                    parts.append(("    " + line) if line else "")
            parts.append("")

    # Paid resources — everything in the estate that costs money, sorted.
    parts += ["## Paid resources",
              "",
              "Every individual resource across the estate with a non-trivial 30-day "
              "cost (≥ £0.01). Object Storage roll-ups are shown at compartment level "
              "in the per-compartment SKU tables above, not duplicated here.",
              ""]
    paid_rows: list[tuple] = []
    for t in tenancies:
        for r in t["resources"]:
            if r._cost >= 0.01:
                paid_rows.append((r, t))
    paid_rows.sort(key=lambda x: -x[0]._cost)
    if not paid_rows:
        parts.append("_Nothing meets the £0.01 threshold — full estate is essentially free-tier._")
        parts.append("")
    else:
        total = sum(r._cost for r, _ in paid_rows)
        parts.append(f"**{len(paid_rows)} paid resources, £{total:,.2f}/30d in total.**")
        parts.append("")
        parts.append("| Tenancy | Compartment | Category | Type | Name | Cost | Age | App | OCID |")
        parts.append("|---|---|---|---|---|---|---|---|---|")
        for r, t in paid_rows:
            name = safe_label(r.display_name or "—", 50)
            age = humanise_age(getattr(r, "time_created", None))
            app = r._app or "—"
            parts.append(
                f"| {t['tenancy_name']} | {r._compartment_name} | {r._category} | "
                f"{r.resource_type} | {name} | {fmt_money(r._cost, t['currency'])} | "
                f"{age} | {app} | `{r.identifier[-16:]}` |"
            )
        parts.append("")

    # Unattributed
    parts += ["## Unattributed resources",
              "",
              "Resources not referenced by any `apps/*.md`. **System-managed entries "
              "are usually safe to ignore.** The rest are gaps to backfill into app docs.",
              ""]
    any_unattr = False
    for t in tenancies:
        unattr = [r for r in t["resources"] if not r._app]
        if not unattr:
            continue
        any_unattr = True
        parts.append(f"### {t['tenancy_name']}")
        parts.append("")
        parts.append("| Category | Type | Name | Compartment | Age | System? | OCID |")
        parts.append("|---|---|---|---|---|---|---|")
        for r in sorted(unattr, key=lambda x: (x._system, x._category, x.resource_type, x.display_name or "")):
            age = humanise_age(getattr(r, "time_created", None))
            sysf = "system" if r._system else "user"
            name = safe_label(r.display_name or "—", 60)
            parts.append(
                f"| {r._category} | {r.resource_type} | {name} | "
                f"{r._compartment_name} | {age} | {sysf} | `{r.identifier[-20:]}` |"
            )
        parts.append("")
    if not any_unattr:
        parts.append("_None — every resource is attributed to an app._")
        parts.append("")

    return "\n".join(parts)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--profiles", nargs="*", help="OCI config profiles (default: all)")
    ap.add_argument("--days", type=int, default=30, help="cost window in days (default 30)")
    args = ap.parse_args()

    profiles = args.profiles or discover_profiles()
    print(f"Profiles: {', '.join(profiles)}")

    ocid_to_app, comp_to_apps = load_app_index()
    print(f"App index: {len(ocid_to_app)} OCID references / {len(comp_to_apps)} (tenancy, compartment) pairs")

    tenancies, errors = [], []
    for p in profiles:
        print(f"\n=== {p} ===")
        try:
            t = enumerate_tenancy(p)
        except Exception as e:
            errors.append(f"enumerate_tenancy({p}): {e}")
            print(f"  ERROR: {e}", file=sys.stderr)
            continue
        print(f"  {t['tenancy_name']}: {len(t['resources'])} resources, {len(t['compartments'])} compartments")
        n_attr, n_unattr = attribute(
            t["resources"], ocid_to_app, comp_to_apps, t["tenancy_name"], t["compartments"]
        )
        t["n_attr"], t["n_unattr"] = n_attr, n_unattr
        n_sys = sum(1 for r in t["resources"] if r._system)
        print(f"  Categorised, {n_sys} flagged as system-managed; {n_attr} attributed / {n_unattr} unattributed")

        svc_rows, err1 = fetch_cost(p, t["tenancy_id"], ["compartmentName", "service"], args.days)
        if err1:
            errors.append(f"{p} cost (service): {err1}")
            print(f"  WARNING: {err1}", file=sys.stderr)
        line_rows, err2 = fetch_cost(
            p, t["tenancy_id"], ["compartmentName", "skuName", "resourceId"], args.days
        )
        if err2:
            errors.append(f"{p} cost (line-item): {err2}")
            print(f"  WARNING: {err2}", file=sys.stderr)
        else:
            print(f"  Line-item cost rows: {len(line_rows)}")

        view, currency = build_view(t, svc_rows, line_rows)
        t["compartments_view"] = view
        t["currency"] = currency or "GBP"
        total = sum(cv["total"] for cv in view)
        print(f"  Total 30d cost: {fmt_money(total, t['currency'])}")
        tenancies.append(t)

    doc = render_doc(tenancies, errors)
    OUTPUT.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT.write_text(doc)
    print(f"\nWrote {OUTPUT}")


if __name__ == "__main__":
    main()
