"""get_portfolio_data -- Bulk portfolio snapshot from the LMA.

Runs up to three SOQL queries:
  1. All active/trial production licenses linked to an Account
  2. Latest released version per package (for version currency)
  3. Exception licenses (Company__c-only, from internal overrides)

Groups by customer and returns a compact per-customer summary with
health-scoring data. Writes full data to portfolio/portfolio-data.json.

The AI interprets the data for scoring, prioritisation, and insights
using the scoring rules file (portfolio/scoring-rules.md), which is
auto-created with sensible defaults on first run and fully editable
by the user.
"""

import json
import logging
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from ..utils import sf_cli, state

logger = logging.getLogger(__name__)

DEFAULT_SCORING_RULES = """\
# Portfolio Health Scoring Rules

> Edit this file to customise thresholds, weights, and core packages.
> Changes take effect on the next `get_portfolio_data` call.
> You can also override any rule conversationally during analysis
> (e.g. "for this analysis, treat csbb as critical").

## Dimension Weights

| Dimension          | Weight |
|--------------------|--------|
| Renewal Risk       | 0.30   |
| Version Currency   | 0.30   |
| Seat Utilization   | 0.20   |
| Product Breadth    | 0.20   |

## Core Packages

These packages carry **2x weight** in seat utilization scoring.
Add or remove packages as needed.

- CSPOFA (Process Orchestration)
- cscfga (Configurator)
- csord (Order Management)
- csbb (Broadband)

## 1. Renewal Risk

Renewal discussions typically take 2-3 months (legal, procurement,
sign-off). Thresholds reflect this lead time.

**IMPORTANT:** Use `upcoming_expiry_days` (not `nearest_expiry_days`)
for renewal scoring. `nearest_expiry_days` includes already-expired
packages and can be misleadingly negative. `upcoming_expiry_days` is
the nearest FUTURE expiry -- the next actual renewal deadline.

Per-package `expiry_days` is also available for detailed analysis
(e.g. which specific packages expire when).

| Window                     | Label    | Score |
|----------------------------|----------|-------|
| < 90 days                  | CRITICAL | 0     |
| 90 - 180 days              | URGENT   | 25    |
| 180 - 365 days             | WATCH    | 50    |
| 1 - 2 years                | HEALTHY  | 75    |
| > 2 years or non-expiring  | SAFE     | 100   |

## 2. Version Currency

CloudSense release numbering (R33+): the major version in the
package version string IS the release number (e.g. 38.0.4 = R38).
Versions with major < 33 (e.g. 1.484.6) are pre-R33 LEGACY (1GP era).

- **R37** is the current stable release.
- **R38** is the latest release (2GP baseline, desired for new projects).

Scoring uses the customer's **oldest release** across their packages:

| Release      | Label        | Score |
|--------------|--------------|-------|
| R38          | LEADING EDGE | 100   |
| R37          | CURRENT      | 90    |
| R36          | ONE BEHIND   | 65    |
| R35          | TWO BEHIND   | 40    |
| R34 or older | OUTDATED     | 15    |
| Pre-R33      | LEGACY       | 0     |

## 3. Seat Utilization

Scoring is non-linear -- both extremes are signals.

| Utilization   | Label     | Score | Signal             |
|---------------|-----------|-------|--------------------|
| 50 - 80%      | HEALTHY   | 100   | sweet spot         |
| 80 - 90%      | GROWING   | 75    | potential upsell   |
| > 90%         | CAPACITY  | 50    | upsell urgently    |
| 30 - 50%      | LOW       | 50    | some concern       |
| < 30%         | UNDERUSED | 25    | churn risk         |
| < 10%         | DORMANT   | 0     | high churn risk    |
| Site License  | NEUTRAL   | 80    | no signal          |

For overall customer seat score: weighted average across packages,
with core packages at 2x weight (see Core Packages above).

## 4. Product Breadth

CloudSense has 50+ packages. More packages = deeper integration =
stickier customer.

| Count       | Label      | Score |
|-------------|------------|-------|
| > 20        | ENTERPRISE | 100   |
| 15 - 20     | DEEP       | 80    |
| 10 - 14     | MODERATE   | 60    |
| 5 - 9       | LIGHT      | 40    |
| 1 - 4       | MINIMAL    | 20    |

## Composite Health Score

```
overall = (renewal × 0.30) + (currency × 0.30) +
          (utilization × 0.20) + (breadth × 0.20)
```

| Score  | Tier      |
|--------|-----------|
| > 80   | HEALTHY   |
| 60 - 80| ATTENTION |
| 40 - 60| AT RISK   |
| < 40   | CRITICAL  |

## Opportunity Detection

Flag these automatically when presenting portfolio analysis:

| Signal     | Trigger                                                     |
|------------|-------------------------------------------------------------|
| UPSELL     | Seat utilization > 90% on any core package                  |
| CROSS-SELL | Missing companion packages (e.g. has cscfga but not CSPOFA) |
| UPGRADE    | Oldest release <= R35 (two or more behind)                  |
| AT-RISK    | Utilization < 30% + expiry < 180 days                       |
| STRATEGIC  | Large customer (>10 packages) + composite < 60              |

## License Status Nuance

A customer may have both "Active" and "Trial" licenses across
different orgs. "Trial" does NOT always mean prospect -- a customer
may have converted to Active in one production org while a legacy
Trial license remains on another. Only flag as a conversion
opportunity if ALL their licenses are Trial with no Active licenses.

## License Expiry Interpretation

The LMA stores expiry dates per license. Many are stale or
misleading. Use these rules to avoid hallucinating renewal
opportunities or misclassifying customers:

| Condition | Interpretation | Action |
|-----------|---------------|--------|
| Active + expired > 2 years ago | Perpetual / non-expiring license (stale LMA date) | Classify as SAFE -- not a renewal concern |
| Active + expired < 1 year ago | Operating on lapsed agreement | Flag as RENEWAL OVERDUE -- genuine action needed |
| Active + expiring soon (positive days) | Active renewal pipeline | Score normally with renewal risk thresholds |
| Trial-only + expired > 1 year ago | Never converted prospect | Exclude from active health scoring. Flag as WIN-BACK or LOST PROSPECT |
| Trial-only + recently expired or active | Conversion opportunity | Flag as CONVERSION -- not a renewal |

When `nearest_expiry_days` is very negative (e.g. -3000+) but the
customer has Active status and healthy seat utilization, this is
almost always a perpetual license with a stale expiry date in the
LMA. Do NOT treat these as renewal risks or lost customers.

When multiple customers share the exact same `nearest_expiry_days`
(e.g. 5+ customers at -156), flag this pattern. It may indicate a
master agreement covering multiple customers or a bulk data update
in the LMA.

## Portfolio Segmentation

Use account context for grouping and filtering:

- **By Region**: EMEA, APAC, Americas -- useful for regional teams
- **By Industry**: Telco/Comms, Media, Utilities -- vertical insights
- **By Parent Company**: Group subsidiaries under parent for
  strategic account views and cross-sell detection
- **By Health Tier**: HEALTHY / ATTENTION / AT RISK / CRITICAL

## Report Personas

When the user asks for a report, tailor it to the audience.

### Executive (VP / C-level)
- 1 page max, no jargon
- Key numbers: total customers, revenue at risk, top 3 actions
- Health distribution: how many HEALTHY / ATTENTION / AT RISK / CRITICAL
- Strategic risks and opportunities (3-5 bullet points)

### Account Manager (Sales)
- Per-customer action items: upsell, cross-sell, renewal
- Seat utilization signals (who needs more licenses?)
- Corporate group opportunities
- Sort by revenue impact / urgency

### Technical Lead (Engineering)
- Version currency detail: which packages, how far behind
- Package complexity per customer
- Upgrade effort indicators
- Sandbox readiness for metadata retrieval

### Customer Success
- Health trends (improving / declining -- requires historical data)
- Engagement signals: utilization trends, package adoption
- Churn risk indicators with recommended actions
- Relationship timeline (tenure, recent changes)
"""

LICENSE_FIELDS = (
    "sfLma__Account__r.Name, "
    "sfLma__Account__r.Id, "
    "sfLma__Account__r.Region__c, "
    "sfLma__Account__r.Country__c, "
    "sfLma__Account__r.Industry, "
    "sfLma__Account__r.ParentId, "
    "sfLma__Account__r.Parent.Name, "
    "Package_Name__c, "
    "Package_Version_Number__c, "
    "sfLma__License_Status__c, "
    "sfLma__Licensed_Seats__c, "
    "sfLma__Used_Licenses__c, "
    "sfLma__Expiration_Date__c, "
    "Days_To_Expire__c, "
    "sfLma__Package_Version__r.sfLma__Package__c"
)

TOOL_DEFINITION = {
    "name": "get_portfolio_data",
    "description": (
        "Fetch a snapshot of ALL active/trial production licenses from the "
        "CloudSense License Management Portal, enriched with seat utilization "
        "and version currency data. Groups results by customer and returns a "
        "compact summary with health-scoring inputs. Writes full data to "
        "portfolio/portfolio-data.json. Use this for cross-customer analysis: "
        "renewal risks, upgrade opportunities, upsell signals, portfolio "
        "health scoring. Requires connect_lma first."
    ),
    "inputSchema": {
        "type": "object",
        "properties": {
            "working_directory": {
                "type": "string",
                "description": "Workspace directory. The AI passes this automatically.",
            },
        },
        "required": [],
    },
}


def _extract_release(version_str: str | None) -> int | None:
    """Extract the CloudSense release number from a version string.

    R33+ use major version as release number (e.g. 38.0.4 -> R38).
    Pre-R33 packages (major < 33, e.g. 1.484.6) return None (LEGACY).
    """
    if not version_str:
        return None
    try:
        major = int(version_str.split(".")[0])
        return major if major >= 33 else None
    except (ValueError, IndexError):
        return None


def _parse_seats(licensed: Any, used: Any) -> dict[str, Any]:
    """Parse seat utilization into a compact structure."""
    if licensed is None and used is None:
        return {"type": "unknown"}
    if isinstance(licensed, str) and licensed.lower() == "site license":
        return {"type": "site"}
    try:
        total = int(licensed) if licensed is not None else None
        active = int(used) if used is not None else 0
        if total and total > 0:
            pct = round((active / total) * 100)
            return {"type": "numeric", "licensed": total, "used": active, "pct": pct}
        return {"type": "numeric", "licensed": total, "used": active, "pct": 0}
    except (ValueError, TypeError):
        return {"type": "other", "raw_licensed": str(licensed)}


def _query_all_active_licenses(lma_alias: str) -> list[dict[str, Any]]:
    query = (
        f"SELECT {LICENSE_FIELDS} "
        f"FROM sfLma__License__c "
        f"WHERE sfLma__Is_Sandbox__c = false "
        f"AND sfLma__License_Status__c IN ('Active', 'Trial') "
        f"AND sfLma__Account__r.Name != null "
        f"ORDER BY sfLma__Account__r.Name"
    )
    return sf_cli.lma_query(query, lma_alias)


def _query_latest_versions(
    package_ids: set[str], lma_alias: str,
) -> dict[str, dict[str, Any]]:
    """Query the latest released version for each package ID.

    Returns {package_id: {"version": "38.0.4", "release_date": "2026-02-17", "release": 38}}.
    """
    if not package_ids:
        return {}

    id_list = ", ".join(f"'{pid}'" for pid in sorted(package_ids))
    query = (
        f"SELECT sfLma__Package__c, sfLma__Version_Number__c, sfLma__Release_Date__c "
        f"FROM sfLma__Package_Version__c "
        f"WHERE sfLma__Package__c IN ({id_list}) "
        f"ORDER BY sfLma__Release_Date__c DESC"
    )
    records = sf_cli.lma_query(query, lma_alias)

    latest: dict[str, dict[str, Any]] = {}
    for rec in records:
        pid = rec.get("sfLma__Package__c")
        if pid and pid not in latest:
            ver = rec.get("sfLma__Version_Number__c") or "?"
            latest[pid] = {
                "version": ver,
                "release_date": rec.get("sfLma__Release_Date__c"),
                "release": _extract_release(ver),
            }
    return latest


def _group_by_customer(
    licenses: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
    """Group Account-linked licenses by customer. Returns raw (mutable) dicts.

    The returned dicts use sets for statuses and defaultdicts for packages,
    allowing further merging before finalisation.
    """
    customers: dict[str, dict[str, Any]] = {}

    for lic in licenses:
        acct_ref = lic.get("sfLma__Account__r")
        if not isinstance(acct_ref, dict):
            continue
        acct_name = acct_ref.get("Name", "Unknown")
        acct_id = acct_ref.get("Id", "")

        region = acct_ref.get("Region__c") or ""
        country = acct_ref.get("Country__c") or ""
        industry = acct_ref.get("Industry") or ""
        parent_id = acct_ref.get("ParentId") or ""
        parent_ref = acct_ref.get("Parent")
        parent_name = parent_ref.get("Name") if isinstance(parent_ref, dict) else ""

        if acct_name not in customers:
            customers[acct_name] = {
                "account_id": acct_id,
                "region": region,
                "country": country,
                "industry": industry,
                "parent_id": parent_id,
                "parent_name": parent_name or "",
                "packages": defaultdict(lambda: {
                    "versions": [], "seats": [], "expiry_days": [],
                    "package_id": None,
                }),
                "statuses": set(),
                "nearest_expiry_days": None,
                "license_count": 0,
            }

        cust = customers[acct_name]
        cust["license_count"] += 1

        status = lic.get("sfLma__License_Status__c") or "Unknown"
        cust["statuses"].add(status)

        pkg_name = lic.get("Package_Name__c") or "Unknown"
        pkg_data = cust["packages"][pkg_name]
        pkg_data["versions"].append(
            lic.get("Package_Version_Number__c") or "?"
        )

        pkg_ver_ref = lic.get("sfLma__Package_Version__r")
        if isinstance(pkg_ver_ref, dict):
            pid = pkg_ver_ref.get("sfLma__Package__c")
            if pid:
                pkg_data["package_id"] = pid

        seat_info = _parse_seats(
            lic.get("sfLma__Licensed_Seats__c"),
            lic.get("sfLma__Used_Licenses__c"),
        )
        pkg_data["seats"].append(seat_info)

        days = lic.get("Days_To_Expire__c")
        try:
            d = float(days) if days is not None else None
        except (ValueError, TypeError):
            d = None
        pkg_data["expiry_days"].append(d)

        if d is not None:
            if cust["nearest_expiry_days"] is None or d < cust["nearest_expiry_days"]:
                cust["nearest_expiry_days"] = d

    return customers


def _finalise_customers(
    customers: dict[str, dict[str, Any]],
    latest_versions: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
    """Convert raw customer dicts into serialisable summaries."""
    result = {}
    for name, cust in customers.items():
        pkg_summaries = {}
        oldest_release: int | None = None

        for pkg_name, pkg_data in cust["packages"].items():
            versions = sorted(set(pkg_data["versions"]))
            customer_release = max(
                (_extract_release(v) for v in versions),
                default=None,
                key=lambda r: r if r is not None else -1,
            )

            pkg_id = pkg_data["package_id"]
            latest_info = latest_versions.get(pkg_id, {}) if pkg_id else {}
            latest_ver = latest_info.get("version")
            latest_rel = latest_info.get("release")

            on_latest = False
            if latest_ver and versions:
                on_latest = latest_ver in versions

            seat_summary = _summarise_seats(pkg_data["seats"])
            pkg_expiry = _summarise_expiry(pkg_data.get("expiry_days", []))

            pkg_summaries[pkg_name] = {
                "versions": versions,
                "customer_release": customer_release,
                "latest_version": latest_ver,
                "latest_release": latest_rel,
                "on_latest": on_latest,
                "seats": seat_summary,
                "expiry_days": pkg_expiry,
            }

            if customer_release is not None:
                if oldest_release is None or customer_release < oldest_release:
                    oldest_release = customer_release

        all_pkg_expiries = [
            pkg["expiry_days"] for pkg in pkg_summaries.values()
            if pkg["expiry_days"] is not None
        ]
        upcoming = [d for d in all_pkg_expiries if d > 0]

        result[name] = {
            "account_id": cust["account_id"],
            "region": cust["region"],
            "country": cust["country"],
            "industry": cust["industry"],
            "parent_id": cust["parent_id"],
            "parent_name": cust["parent_name"],
            "license_count": cust["license_count"],
            "package_count": len(cust["packages"]),
            "statuses": sorted(cust["statuses"]),
            "packages": pkg_summaries,
            "nearest_expiry_days": cust["nearest_expiry_days"],
            "upcoming_expiry_days": min(upcoming) if upcoming else None,
            "oldest_release": oldest_release,
        }
    return result


def _summarise_expiry(expiry_entries: list) -> float | None:
    """Return the nearest expiry across multiple licenses for the same package."""
    valid = []
    for d in expiry_entries:
        if d is not None:
            try:
                valid.append(float(d))
            except (ValueError, TypeError):
                pass
    return min(valid) if valid else None


def _summarise_seats(seat_entries: list[dict[str, Any]]) -> dict[str, Any]:
    """Aggregate seat data across multiple licenses for the same package."""
    numeric = [s for s in seat_entries if s["type"] == "numeric"]
    if numeric:
        total_licensed = sum(s.get("licensed") or 0 for s in numeric)
        total_used = sum(s.get("used") or 0 for s in numeric)
        pct = round((total_used / total_licensed) * 100) if total_licensed > 0 else 0
        return {"type": "numeric", "licensed": total_licensed, "used": total_used, "pct": pct}
    site = [s for s in seat_entries if s["type"] == "site"]
    if site:
        return {"type": "site"}
    return {"type": "unknown"}


def _group_by_parent(
    customers: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
    """Group customers by parent company. Returns {parent_name: {subsidiaries, ...}}."""
    groups: dict[str, dict[str, Any]] = {}
    for cust_name, cust in customers.items():
        pid = cust.get("parent_id")
        if not pid:
            continue
        pname = cust.get("parent_name") or pid
        if pname not in groups:
            groups[pname] = {
                "parent_id": pid,
                "subsidiaries": [],
                "total_licenses": 0,
                "total_packages": 0,
                "oldest_release": None,
                "nearest_expiry_days": None,
                "upcoming_expiry_days": None,
            }
        grp = groups[pname]
        grp["subsidiaries"].append(cust_name)
        grp["total_licenses"] += cust["license_count"]
        grp["total_packages"] += cust["package_count"]

        cust_rel = cust.get("oldest_release")
        if cust_rel is not None:
            if grp["oldest_release"] is None or cust_rel < grp["oldest_release"]:
                grp["oldest_release"] = cust_rel

        cust_exp = cust.get("nearest_expiry_days")
        if cust_exp is not None:
            if grp["nearest_expiry_days"] is None or cust_exp < grp["nearest_expiry_days"]:
                grp["nearest_expiry_days"] = cust_exp

        cust_upcoming = cust.get("upcoming_expiry_days")
        if cust_upcoming is not None:
            if grp["upcoming_expiry_days"] is None or cust_upcoming < grp["upcoming_expiry_days"]:
                grp["upcoming_expiry_days"] = cust_upcoming

    return {k: v for k, v in groups.items() if len(v["subsidiaries"]) > 0}


def _write_history_snapshot(working_dir: Path, data: dict[str, Any]) -> Path | None:
    """Write a timestamped snapshot to portfolio/history/. Returns the path."""
    history_dir = working_dir / "portfolio" / "history"
    history_dir.mkdir(parents=True, exist_ok=True)
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    snapshot_path = history_dir / f"portfolio-data-{today}.json"
    if snapshot_path.exists():
        return None
    snapshot_path.write_text(json.dumps(data, indent=2, default=str))
    return snapshot_path


def _detect_changes(
    working_dir: Path,
    current_customers: dict[str, dict[str, Any]],
) -> list[str]:
    """Compare current snapshot with the most recent previous one."""
    history_dir = working_dir / "portfolio" / "history"
    if not history_dir.exists():
        return []

    snapshots = sorted(history_dir.glob("portfolio-data-*.json"), reverse=True)
    previous = [s for s in snapshots if s != max(snapshots)] if len(snapshots) > 1 else []
    if not previous:
        if snapshots:
            try:
                prev_data = json.loads(snapshots[0].read_text())
                prev_customers = prev_data.get("customers", {})
                if prev_customers == current_customers:
                    return []
            except (json.JSONDecodeError, OSError):
                pass
        return []

    prev_path = previous[0]
    try:
        prev_data = json.loads(prev_path.read_text())
    except (json.JSONDecodeError, OSError):
        return []

    prev_customers = prev_data.get("customers", {})
    prev_date = prev_path.stem.replace("portfolio-data-", "")
    changes: list[str] = []

    curr_names = set(current_customers.keys())
    prev_names = set(prev_customers.keys())

    new_custs = curr_names - prev_names
    if new_custs:
        for n in sorted(new_custs):
            lc = current_customers[n]["license_count"]
            changes.append(f"NEW: {n} ({lc} licenses)")

    lost_custs = prev_names - curr_names
    if lost_custs:
        for n in sorted(lost_custs):
            changes.append(f"REMOVED: {n} (no longer has active licenses)")

    for name in sorted(curr_names & prev_names):
        curr = current_customers[name]
        prev = prev_customers.get(name, {})

        curr_rel = curr.get("oldest_release")
        prev_rel = prev.get("oldest_release")
        if curr_rel and prev_rel and curr_rel != prev_rel:
            direction = "upgraded" if curr_rel > prev_rel else "downgraded"
            changes.append(f"VERSION: {name} {direction} R{prev_rel} -> R{curr_rel}")

        curr_pkgs = curr.get("package_count", 0)
        prev_pkgs = prev.get("package_count", 0)
        diff = curr_pkgs - prev_pkgs
        if diff > 0:
            changes.append(f"GROWTH: {name} added {diff} package(s)")
        elif diff < 0:
            changes.append(f"SHRINK: {name} dropped {abs(diff)} package(s)")

        curr_statuses = set(curr.get("statuses", []))
        prev_statuses = set(prev.get("statuses", []))
        if curr_statuses != prev_statuses:
            changes.append(
                f"STATUS: {name} changed {', '.join(prev_statuses)} -> {', '.join(curr_statuses)}"
            )

    if changes:
        changes.insert(0, f"_Compared with snapshot from {prev_date}_\n")

    return changes


def _ensure_scoring_rules(working_dir: Path) -> Path:
    """Create scoring-rules.md with defaults if it doesn't exist. Returns the path."""
    portfolio_dir = working_dir / "portfolio"
    portfolio_dir.mkdir(parents=True, exist_ok=True)
    rules_path = portfolio_dir / "scoring-rules.md"
    if not rules_path.exists():
        rules_path.write_text(DEFAULT_SCORING_RULES)
        logger.info("Created default scoring rules at %s", rules_path)
    return rules_path


def _read_scoring_rules(rules_path: Path) -> str:
    """Read the scoring rules file content."""
    try:
        return rules_path.read_text()
    except OSError:
        logger.warning("Could not read scoring rules at %s, using defaults", rules_path)
        return DEFAULT_SCORING_RULES


_DEFAULT_OVERRIDES: dict[str, Any] = {
    "_version": 1,
    "unlinked_customers": [
        {
            "name": "Warner Bros. Discovery",
            "company_values": ["Warner Bros. Discovery"],
        },
    ],
}

_OVERRIDES_FILENAME = ".portfolio-overrides.json"


def _ensure_overrides(working_dir: Path) -> Path:
    """Create the internal overrides file with defaults if absent."""
    portfolio_dir = working_dir / "portfolio"
    portfolio_dir.mkdir(parents=True, exist_ok=True)
    path = portfolio_dir / _OVERRIDES_FILENAME
    if not path.exists():
        path.write_text(json.dumps(_DEFAULT_OVERRIDES, indent=2))
        logger.info("Created portfolio overrides at %s", path)
    return path


def _read_overrides(path: Path) -> dict[str, Any]:
    """Read and parse the overrides file. Returns defaults on error."""
    try:
        data = json.loads(path.read_text())
        return data if isinstance(data, dict) else _DEFAULT_OVERRIDES
    except (json.JSONDecodeError, OSError):
        logger.warning("Could not read overrides at %s, using defaults", path)
        return _DEFAULT_OVERRIDES


def _get_unlinked_company_values(
    overrides: dict[str, Any],
) -> tuple[list[str], dict[str, str]]:
    """Extract Company__c values and build a reverse map to canonical names.

    Returns (flat_values, {company_value: canonical_name}).
    """
    entries = overrides.get("unlinked_customers", [])
    all_values: list[str] = []
    reverse_map: dict[str, str] = {}
    for entry in entries:
        canonical = entry.get("name", "")
        for cv in entry.get("company_values", []):
            all_values.append(cv)
            reverse_map[cv] = canonical
    return all_values, reverse_map


_EXCEPTION_LICENSE_FIELDS = (
    "Company__c, "
    "Package_Name__c, "
    "Package_Version_Number__c, "
    "sfLma__License_Status__c, "
    "sfLma__Licensed_Seats__c, "
    "sfLma__Used_Licenses__c, "
    "sfLma__Expiration_Date__c, "
    "Days_To_Expire__c, "
    "sfLma__Package_Version__r.sfLma__Package__c"
)


def _query_unlinked_licenses(
    company_values: list[str], lma_alias: str,
) -> list[dict[str, Any]]:
    """Query production licenses for Company__c-only customers."""
    if not company_values:
        return []
    in_clause = ", ".join(
        f"'{v.replace(chr(39), chr(92) + chr(39))}'" for v in company_values
    )
    query = (
        f"SELECT {_EXCEPTION_LICENSE_FIELDS} "
        f"FROM sfLma__License__c "
        f"WHERE Company__c IN ({in_clause}) "
        f"AND sfLma__Is_Sandbox__c = false "
        f"AND sfLma__Account__c = null "
        f"AND sfLma__License_Status__c IN ('Active', 'Trial') "
        f"ORDER BY Company__c"
    )
    try:
        return sf_cli.lma_query(query, lma_alias)
    except sf_cli.SfCliError as e:
        logger.warning("Unlinked customer license query failed: %s", e)
        return []


def _merge_unlinked_customers(
    customers: dict[str, dict[str, Any]],
    unlinked_licenses: list[dict[str, Any]],
    reverse_map: dict[str, str],
) -> int:
    """Merge unlinked (Company__c-only) licenses into the customer dict.

    Returns the number of licenses merged.
    """
    merged = 0
    for lic in unlinked_licenses:
        raw_company = lic.get("Company__c") or ""
        canonical = reverse_map.get(raw_company, raw_company)
        if not canonical:
            continue

        if canonical not in customers:
            customers[canonical] = {
                "account_id": "",
                "region": "",
                "country": "",
                "industry": "",
                "parent_id": "",
                "parent_name": "",
                "packages": defaultdict(lambda: {
                    "versions": [], "seats": [], "expiry_days": [],
                    "package_id": None,
                }),
                "statuses": set(),
                "nearest_expiry_days": None,
                "license_count": 0,
            }

        cust = customers[canonical]
        cust["license_count"] += 1
        merged += 1

        status = lic.get("sfLma__License_Status__c") or "Unknown"
        cust["statuses"].add(status)

        pkg_name = lic.get("Package_Name__c") or "Unknown"
        pkg_data = cust["packages"][pkg_name]
        pkg_data["versions"].append(
            lic.get("Package_Version_Number__c") or "?"
        )

        pkg_ver_ref = lic.get("sfLma__Package_Version__r")
        if isinstance(pkg_ver_ref, dict):
            pid = pkg_ver_ref.get("sfLma__Package__c")
            if pid:
                pkg_data["package_id"] = pid

        seat_info = _parse_seats(
            lic.get("sfLma__Licensed_Seats__c"),
            lic.get("sfLma__Used_Licenses__c"),
        )
        pkg_data["seats"].append(seat_info)

        days = lic.get("Days_To_Expire__c")
        try:
            d = float(days) if days is not None else None
        except (ValueError, TypeError):
            d = None
        pkg_data["expiry_days"].append(d)

        if d is not None:
            if cust["nearest_expiry_days"] is None or d < cust["nearest_expiry_days"]:
                cust["nearest_expiry_days"] = d

    return merged


def _write_portfolio_json(
    working_dir: Path,
    customers: dict[str, dict[str, Any]],
    total_licenses: int,
    parent_groups: dict[str, dict[str, Any]],
) -> Path:
    portfolio_dir = working_dir / "portfolio"
    portfolio_dir.mkdir(parents=True, exist_ok=True)
    out_path = portfolio_dir / "portfolio-data.json"
    out_path.write_text(json.dumps({
        "_warning": (
            "Raw data only. For portfolio analysis, call get_portfolio_data "
            "via MCP -- the tool response includes scoring rules and analysis "
            "guidance that this file does not contain."
        ),
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "total_licenses": total_licenses,
        "customer_count": len(customers),
        "customers": customers,
        "parent_groups": parent_groups,
    }, indent=2, default=str))
    return out_path


async def run(arguments: dict[str, Any]) -> str:
    """Execute the get_portfolio_data tool."""
    state.set_working_dir(arguments.get("working_directory"))
    current_state = state.load()
    lma_alias = current_state.get("lma_org_alias") or "cs-lma"

    licenses = _query_all_active_licenses(lma_alias)

    if not licenses:
        return (
            "STATUS: NO_DATA\n\n"
            "No active production licenses found in the LMA. "
            "Verify connect_lma was called and the LMA org has license data."
        )

    working_dir = state.get_working_dir()
    rules_path = _ensure_scoring_rules(working_dir)
    scoring_rules = _read_scoring_rules(rules_path)

    overrides_path = _ensure_overrides(working_dir)
    overrides = _read_overrides(overrides_path)

    raw_customers = _group_by_customer(licenses)

    company_values, reverse_map = _get_unlinked_company_values(overrides)
    unlinked = _query_unlinked_licenses(company_values, lma_alias)
    unlinked_count = _merge_unlinked_customers(
        raw_customers, unlinked, reverse_map,
    )

    # Collect package IDs from ALL licenses (Account-linked + unlinked)
    all_package_ids: set[str] = set()
    for lic in licenses:
        ref = lic.get("sfLma__Package_Version__r")
        if isinstance(ref, dict):
            pid = ref.get("sfLma__Package__c")
            if pid:
                all_package_ids.add(pid)
    for lic in unlinked:
        ref = lic.get("sfLma__Package_Version__r")
        if isinstance(ref, dict):
            pid = ref.get("sfLma__Package__c")
            if pid:
                all_package_ids.add(pid)

    latest_versions = _query_latest_versions(all_package_ids, lma_alias)

    total_licenses = len(licenses) + unlinked_count
    customers = _finalise_customers(raw_customers, latest_versions)
    parent_groups = _group_by_parent(customers)

    portfolio_data = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "total_licenses": total_licenses,
        "customer_count": len(customers),
        "customers": customers,
        "parent_groups": parent_groups,
    }

    artifact_path = _write_portfolio_json(
        working_dir, customers, total_licenses, parent_groups,
    )
    _write_history_snapshot(working_dir, portfolio_data)

    change_lines = _detect_changes(working_dir, customers)

    sorted_customers = sorted(
        customers.items(),
        key=lambda x: (
            x[1]["upcoming_expiry_days"]
            if x[1]["upcoming_expiry_days"] is not None
            else 99999
        ),
    )

    lines = [
        "STATUS: READY\n",
        f"## Portfolio Snapshot\n",
        f"- **Customers:** {len(customers)}",
        f"- **Total active production licenses:** {total_licenses}",
        f"- **Distinct packages:** {len(all_package_ids)}",
        f"- **Corporate groups:** {len(parent_groups)}",
        f"- **Data:** `{artifact_path}`\n",
    ]

    if change_lines:
        lines.append("### Changes Since Last Snapshot\n")
        lines.extend(change_lines)
        lines.append("")

    lines.append("### All Customers\n")
    lines.append(
        "| Customer | Pkgs | Region | Status | "
        "Oldest Rel | Upcoming Exp | Overdue | Seat Flags |"
    )
    lines.append(
        "|----------|------|--------|--------|"
        "-----------|-------------|---------|------------|"
    )
    for name, cust in sorted_customers:
        upcoming = cust.get("upcoming_expiry_days")
        upcoming_str = str(int(upcoming)) if upcoming is not None else "—"
        nearest = cust["nearest_expiry_days"]
        overdue_str = str(int(nearest)) if nearest is not None and nearest < 0 else "—"
        status = ", ".join(cust["statuses"])
        oldest = cust.get("oldest_release")
        rel_str = f"R{oldest}" if oldest else "LEGACY"
        region = cust.get("region") or "—"

        seat_flags = _seat_flags(cust["packages"])

        lines.append(
            f"| {name} | {cust['package_count']} | "
            f"{region} | {status} | "
            f"{rel_str} | {upcoming_str} | {overdue_str} | {seat_flags} |"
        )

    if parent_groups:
        lines.append("\n### Corporate Groups\n")
        lines.append(
            "| Parent Company | Subsidiaries | Total Licenses | "
            "Oldest Rel | Upcoming Exp | Overdue |"
        )
        lines.append(
            "|----------------|--------------|----------------|"
            "-----------|-------------|---------|"
        )
        for pname, grp in sorted(parent_groups.items()):
            sub_count = len(grp["subsidiaries"])
            oldest = grp.get("oldest_release")
            rel_str = f"R{oldest}" if oldest else "LEGACY"
            upcoming = grp.get("upcoming_expiry_days")
            upcoming_str = str(int(upcoming)) if upcoming is not None else "—"
            nearest = grp.get("nearest_expiry_days")
            overdue_str = str(int(nearest)) if nearest is not None and nearest < 0 else "—"
            lines.append(
                f"| {pname} | {sub_count} | {grp['total_licenses']} | "
                f"{rel_str} | {upcoming_str} | {overdue_str} |"
            )

    lines.append(
        f"\n\n**Scoring rules:** `{rules_path}`\n"
        "Apply the scoring rules below to compute health scores per customer "
        "(renewal risk, version currency, seat utilization, product breadth). "
        "Present a ranked portfolio view with composite health scores and "
        "flag opportunities (upsell, upgrade, at-risk, cross-sell). "
        "When corporate groups exist, also present a group-level view. "
        "The user can edit the scoring rules file to change thresholds, "
        "or override any rule conversationally. Offer to:\n"
        "- Dive deeper into any specific customer (use find_customer)\n"
        "- Save findings as a shareable report (use save_report)\n"
    )

    lines.append("\n---\n\n<SCORING_RULES>\n")
    lines.append(scoring_rules)
    lines.append("</SCORING_RULES>")

    return "\n".join(lines)


def _seat_flags(packages: dict[str, dict[str, Any]]) -> str:
    """Generate compact seat utilization flags for a customer."""
    flags = []
    for pkg_name, pkg in packages.items():
        seats = pkg.get("seats", {})
        if seats.get("type") == "numeric":
            pct = seats.get("pct", 0)
            if pct >= 90:
                flags.append(f"{pkg_name[:12]}:{pct}%!")
            elif pct < 30 and seats.get("licensed", 0) > 0:
                flags.append(f"{pkg_name[:12]}:{pct}%?")
    if not flags:
        return "-"
    return "; ".join(flags[:3])
