"""GodotIQ licensing — PRO tool gating with Polar.sh key validation.

Community users can call PRO tools. The tool runs fully, then the result
is discarded and replaced with a rich preview showing real data subsets.

License sources (checked in order):
1. GODOTIQ_DEV_KEY      — SHA-256 hashed dev key (replaces GODOTIQ_LICENSE=pro)
2. GODOTIQ_LICENSE_KEY   — Polar.sh license key, validated via API
3. Disk cache (24h TTL) at platformdirs-managed location
4. Legacy cache migration from ~/.config/godotiq/license_cache.json
5. Expired cache used as offline fallback when API is unreachable

Sandbox / testing env vars:
- GODOTIQ_POLAR_SANDBOX=true  — use Polar sandbox API endpoint
- GODOTIQ_POLAR_ORG_ID        — override the default organization ID
"""

from __future__ import annotations

import hashlib
import hmac
import json
import logging
import os
import time
from typing import Any

import httpx

from godotiq import paths

logger = logging.getLogger(__name__)

# ── 14 PRO tools (intelligence & analysis) ────────────────────────────

PRO_TOOLS: frozenset[str] = frozenset({
    "godotiq_project_summary",
    "godotiq_file_context",
    "godotiq_dependency_graph",
    "godotiq_validate",
    "godotiq_signal_map",
    "godotiq_trace_flow",
    "godotiq_impact_check",
    "godotiq_animation_audit",
    "godotiq_asset_registry",
    "godotiq_scene_map",
    "godotiq_spatial_audit",
    "godotiq_suggest_scale",
    "godotiq_placement",
    "godotiq_explore",
})

# SHA-256 hash of the dev key (passphrase never stored in source)
_DEV_KEY_HASH = "854afcd070329eb87128469abeb9b919acfd001a6a67e0b42b42f32231a6b208"

# ── Polar.sh configuration ────────────────────────────────────────────

_POLAR_PROD_URL = "https://api.polar.sh/v1/customer-portal/license-keys/validate"
_POLAR_SANDBOX_URL = "https://sandbox-api.polar.sh/v1/customer-portal/license-keys/validate"
_POLAR_DEFAULT_ORG_ID = "a06a7d09-881c-430d-9775-e6a7529a0831"


def _polar_validate_url() -> str:
    """Return the Polar validation URL, using sandbox if GODOTIQ_POLAR_SANDBOX=true."""
    if os.environ.get("GODOTIQ_POLAR_SANDBOX", "").lower() == "true":
        return _POLAR_SANDBOX_URL
    return _POLAR_PROD_URL


def _polar_org_id() -> str:
    """Return the Polar org ID, allowing override via GODOTIQ_POLAR_ORG_ID."""
    return os.environ.get("GODOTIQ_POLAR_ORG_ID", "").strip() or _POLAR_DEFAULT_ORG_ID

_CACHE_TTL_SECONDS = 86400  # 24 hours


# ── In-process license cache ──────────────────────────────────────────
# Avoids repeated disk reads / API calls within a single server process.

_license_status: bool | None = None


def _reset_license_cache() -> None:
    """Reset the in-process license cache.  For testing only."""
    global _license_status
    _license_status = None


# ── License check ─────────────────────────────────────────────────────

def is_pro() -> bool:
    """Return True if the current environment has a valid PRO license.

    Check order:
    1. GODOTIQ_DEV_KEY (SHA-256 hashed dev key)
    2. GODOTIQ_LICENSE_KEY validated against Polar.sh API
    3. Disk cache (24h TTL)
    4. Expired cache as offline fallback
    """
    global _license_status
    if _license_status is not None:
        return _license_status
    _license_status = _check_license()
    return _license_status


def _check_license() -> bool:
    """Run the full license validation chain."""
    # 1. Dev key override (SHA-256 hashed)
    dev_key = os.environ.get("GODOTIQ_DEV_KEY", "").strip()
    if dev_key:
        if hmac.compare_digest(hashlib.sha256(dev_key.encode("utf-8")).hexdigest(), _DEV_KEY_HASH):
            return True
        # Wrong dev key -- fall through to license key check

    # 2. License key from env
    key = os.environ.get("GODOTIQ_LICENSE_KEY", "").strip()
    if not key:
        return False

    key_hash = _hash_key(key)

    # 3. Check disk cache
    cache = _read_cache()
    if cache is not None and cache.get("key_hash") == key_hash:
        age = time.time() - cache.get("validated_at", 0)
        if age < _CACHE_TTL_SECONDS:
            return cache.get("valid", False)

    # 4. Validate against Polar API
    try:
        valid = _validate_polar(key)
        _write_cache(key_hash, valid)
        return valid
    except Exception:
        logger.debug("Polar API unreachable, falling back to cache")
        # 5. Offline fallback — use expired cache if key matches
        if cache is not None and cache.get("key_hash") == key_hash:
            return cache.get("valid", False)
        return False


# ── Polar API validation ──────────────────────────────────────────────

def _validate_polar(key: str) -> bool:
    """Validate a license key against the Polar.sh API.

    POST https://api.polar.sh/v1/customer-portal/license-keys/validate
    Body: {"key": key, "organization_id": org_id}
    No auth header needed.
    Valid if: HTTP 200 AND response.status == "granted".
    """
    url = _polar_validate_url()
    org = _polar_org_id()

    # Mask the key: show first 10 and last 4 chars
    masked = key[:10] + "..." + key[-4:] if len(key) > 14 else key

    resp = httpx.post(
        url,
        json={"key": key, "organization_id": org},
        timeout=10,
    )

    if resp.status_code != 200:
        logger.info("License: key=%s → denied (HTTP %s)", masked, resp.status_code)
        logger.debug("License detail: org=%s url=%s", org, url)
        return False

    data = resp.json()
    status = data.get("status", "unknown")
    result = "granted" if status == "granted" else f"denied ({status})"
    logger.info("License: key=%s → %s", masked, result)
    logger.debug("License detail: org=%s url=%s", org, url)
    return status == "granted"


# ── Disk cache ────────────────────────────────────────────────────────

def _hash_key(key: str) -> str:
    """SHA-256 hash of the license key (never store the key in plaintext)."""
    return hashlib.sha256(key.encode("utf-8")).hexdigest()


def _read_cache() -> dict | None:
    """Read license cache from disk with legacy migration.

    Check order:
    1. Canonical path (via paths.license_cache_path())
    2. Legacy path (~/.config/godotiq/license_cache.json) — migrate if found
    """
    cache_file = paths.license_cache_path()
    if cache_file.exists():
        try:
            data = json.loads(cache_file.read_text(encoding="utf-8"))
            if isinstance(data, dict):
                return data
        except Exception:
            pass  # Fall through to legacy check

    # Canonical not found or invalid — check legacy path for migration
    legacy = paths.legacy_license_cache_path()
    if legacy is None:
        return None

    try:
        data = json.loads(legacy.read_text(encoding="utf-8"))
        if not isinstance(data, dict):
            logger.debug("Legacy license cache is not a valid dict, skipping migration")
            return None
        # Migrate: write to canonical path
        _write_cache_data(data)
        logger.debug("Migrated legacy license cache to %s", cache_file)
        return data
    except Exception:
        logger.debug("Failed to read/migrate legacy license cache", exc_info=True)
        return None


def _write_cache(key_hash: str, valid: bool) -> None:
    """Write license validation result to disk cache."""
    cache = {
        "key_hash": key_hash,
        "valid": valid,
        "validated_at": time.time(),
    }
    _write_cache_data(cache)


def _write_cache_data(data: dict) -> None:
    """Write cache data dict to the canonical cache path."""
    try:
        cache_file = paths.license_cache_path()
        cache_file.parent.mkdir(parents=True, exist_ok=True)
        cache_file.write_text(json.dumps(data), encoding="utf-8")
    except Exception:
        logger.debug("Failed to write license cache", exc_info=True)


# ── Preview builders ──────────────────────────────────────────────────
# Each takes the full tool result dict and returns a tuple of:
#   (preview: dict, locked_sections: list[str], message: str)
# All keys are accessed with .get() so missing data degrades gracefully.


def _preview_project_summary(r: dict) -> tuple[dict, list[str], str]:
    counts = r.get("counts", {})
    autoloads = r.get("autoloads", [])
    preview = {
        "project_name": r.get("project_name", ""),
        "engine": r.get("engine", ""),
        "type": r.get("type", ""),
        "counts": counts,
        "autoloads": [a if isinstance(a, str) else a.get("name", str(a)) for a in autoloads],
    }
    locked = [
        "architecture details",
        "autoload implementation paths",
        "scene dependency tree",
        "key systems analysis",
        "signal health overview",
        "code patterns",
    ]
    scripts = counts.get("scripts", 0)
    scenes = counts.get("scenes", 0)
    n = len(autoloads)
    message = (
        f"Project scanned: {scripts} scripts, {scenes} scenes, "
        f"{n} autoloads. Architecture analysis, signal health, and "
        f"code patterns require Pro."
    )
    return preview, locked, message


def _preview_file_context(r: dict) -> tuple[dict, list[str], str]:
    funcs = len(r.get("public_api", {}).get("functions", r.get("functions", [])))
    signals = len(r.get("signals_defined", []))
    preview: dict[str, Any] = {
        "path": r.get("path", ""),
        "signals_defined": signals,
        "functions": funcs,
    }
    if "class_name" in r:
        preview["class_name"] = r["class_name"]
    if "extends" in r:
        preview["extends"] = r["extends"]
    locked = [
        "public API details",
        "signal connections",
        "dependency chain",
        "reverse dependencies (who imports this)",
        "usage in scenes",
    ]
    path = r.get("path", "unknown")
    message = (
        f"{path}: {funcs} functions, {signals} signals defined. "
        f"Full API, dependencies, and reverse imports require Pro."
    )
    return preview, locked, message


def _preview_dependency_graph(r: dict) -> tuple[dict, list[str], str]:
    direct_deps = len(r.get("direct_dependencies", []))
    refs = len(r.get("referenced_by", []))
    emits = len(r.get("emits_signals", []))
    rating = r.get("impact_rating", "unknown")
    preview = {
        "path": r.get("path", ""),
        "impact_rating": rating,
        "direct_dependencies": direct_deps,
        "referenced_by": refs,
        "emits_signals": emits,
    }
    locked = [
        "full dependency tree",
        "reverse dependency details",
        "signal connection targets",
        "transitive impact chain",
    ]
    message = (
        f"{r.get('path', 'unknown')}: impact {rating}, {direct_deps} direct deps, "
        f"referenced by {refs} files. Full dependency tree and signal targets require Pro."
    )
    return preview, locked, message


def _preview_validate(r: dict) -> tuple[dict, list[str], str]:
    total = r.get("total_issues", 0)
    by_sev = r.get("by_severity", {})
    files_checked = r.get("files_checked", 0)
    preview = {
        "total_issues": total,
        "by_severity": by_sev,
        "files_checked": files_checked,
    }
    locked = [
        "issue details and locations",
        "auto-fix suggestions",
        "convention violations list",
    ]
    errors = by_sev.get("error", 0)
    warnings = by_sev.get("warning", 0)
    message = (
        f"Validation found {total} issues ({errors} errors, {warnings} warnings). "
        f"Issue details and fix suggestions require Pro."
    )
    return preview, locked, message


def _preview_signal_map(r: dict) -> tuple[dict, list[str], str]:
    defined = r.get("total_signals_defined", 0)
    connections = r.get("total_connections", 0)
    orphans = len(r.get("orphans", []))
    preview = {
        "total_signals_defined": defined,
        "total_connections": connections,
        "orphans": orphans,
    }
    locked = [
        "signal-to-handler mapping",
        "orphan signal details",
        "missing signal connections",
        "signal flow diagram",
    ]
    message = (
        f"{defined} signals, {connections} connections, {orphans} orphans found. "
        f"Full signal wiring map and orphan details require Pro."
    )
    return preview, locked, message


def _preview_trace_flow(r: dict) -> tuple[dict, list[str], str]:
    entry = r.get("entry", "unknown")
    steps = r.get("total_steps", 0)
    files = r.get("files_involved", 0)
    failures = len(r.get("failure_points", []))
    preview = {
        "entry": entry,
        "total_steps": steps,
        "files_involved": files,
        "failure_points": failures,
    }
    locked = [
        "step-by-step execution chain",
        "file-to-file flow details",
        "failure point locations and reasons",
        "signal chain path",
    ]
    message = (
        f"Traced '{entry}': {steps} steps across {files} files, "
        f"{failures} failure points. Full execution chain requires Pro."
    )
    return preview, locked, message


def _preview_impact_check(r: dict) -> tuple[dict, list[str], str]:
    risk = r.get("risk", "unknown")
    affected = r.get("total_files_affected", 0)
    callers = len(r.get("callers", []))
    signals = len(r.get("signals_affected", []))
    preview = {
        "path": r.get("path", ""),
        "risk": risk,
        "total_files_affected": affected,
        "callers": callers,
        "signals_affected": signals,
    }
    locked = [
        "affected file list",
        "caller details and locations",
        "signal propagation chain",
        "safe change recommendations",
    ]
    message = (
        f"{r.get('path', 'unknown')}: risk {risk}, {affected} files affected, "
        f"{callers} callers. Affected file list and safe change recommendations require Pro."
    )
    return preview, locked, message


def _preview_animation_audit(r: dict) -> tuple[dict, list[str], str]:
    anims = r.get("total_animations", 0)
    total = r.get("total_issues", 0)
    by_sev = r.get("by_severity", {})
    preview = {
        "total_animations": anims,
        "total_issues": total,
        "by_severity": by_sev,
    }
    locked = [
        "issue details per animation",
        "broken track locations",
        "missing transition list",
        "fix suggestions",
    ]
    message = (
        f"{anims} animations scanned, {total} issues found. "
        f"Issue details and fix suggestions require Pro."
    )
    return preview, locked, message


def _preview_asset_registry(r: dict) -> tuple[dict, list[str], str]:
    total = r.get("total_assets", 0)
    unused = r.get("unused_count", 0)
    raw_cats = r.get("by_category", {})
    cats = {k: len(v) if isinstance(v, list) else v for k, v in raw_cats.items()}
    preview = {
        "total_assets": total,
        "unused_count": unused,
        "by_category": cats,
    }
    locked = [
        "asset file paths",
        "unused asset details",
        "missing reference list",
        "per-asset metadata",
    ]
    message = (
        f"{total} assets cataloged, {unused} unused across {len(cats)} categories. "
        f"Full asset paths and details require Pro."
    )
    return preview, locked, message


def _preview_scene_map(r: dict) -> tuple[dict, list[str], str]:
    total_nodes = r.get("total_nodes", 0)
    bounds = r.get("spatial_summary", {}).get("bounds", {})
    all_nodes = r.get("nodes", [])
    preview_nodes = []
    for node in all_nodes[:5]:
        pn: dict[str, Any] = {
            "name": node.get("name", ""),
            "type": node.get("type", ""),
            "world_position": node.get("world_position", []),
        }
        if "distance_to_focus" in node:
            pn["distance_to_focus"] = node["distance_to_focus"]
        preview_nodes.append(pn)
    preview = {
        "total_nodes": total_nodes,
        "spatial_summary": {"bounds": bounds},
        "nodes": preview_nodes,
    }
    remaining = max(0, total_nodes - 5)
    locked = [
        f"full node list with positions ({remaining} more)",
        "distance matrix",
        "spatial relationships",
        "nearby object queries",
    ]
    message = (
        f"Mapped {total_nodes} nodes in {bounds}. Showing 5 of {total_nodes}. "
        f"Full spatial layout with distances and relationships requires Pro."
    )
    return preview, locked, message


def _preview_spatial_audit(r: dict) -> tuple[dict, list[str], str]:
    total = r.get("total_issues", 0)
    by_sev = r.get("by_severity", {})
    checks = r.get("checks_run", [])
    preview = {
        "total_issues": total,
        "by_severity": by_sev,
        "checks_run": checks,
    }
    locked = [
        "issue details and locations",
        "affected node paths",
        "fix suggestions",
        "overlap/gap specifics",
    ]
    message = (
        f"Spatial audit: {total} issues across {len(checks)} checks. "
        f"Issue locations and fix suggestions require Pro."
    )
    return preview, locked, message


def _preview_suggest_scale(r: dict) -> tuple[dict, list[str], str]:
    tier = r.get("match_tier", "unknown")
    refs = r.get("reference_models", r.get("references", []))
    n = len(refs) if isinstance(refs, list) else refs
    preview: dict[str, Any] = {
        "match_tier": tier,
        "references": n,
    }
    if "target" in r:
        preview["target"] = r["target"]
    locked = [
        "recommended scale values",
        "recommended position",
        "reference model comparison details",
    ]
    message = (
        f"Scale analysis complete, match: {tier}, {n} references. "
        f"Recommended scale and position require Pro."
    )
    return preview, locked, message


def _preview_placement(r: dict) -> tuple[dict, list[str], str]:
    evaluated = r.get("candidates_evaluated", 0)
    suggestions = len(r.get("suggestions", []))
    zones = len(r.get("excluded_zones", []))
    preview = {
        "candidates_evaluated": evaluated,
        "suggestions": suggestions,
        "excluded_zones": zones,
    }
    locked = [
        "placement coordinates",
        "confidence scores",
        "constraint satisfaction details",
        "marker slot matches",
    ]
    message = (
        f"{evaluated} positions evaluated, {suggestions} valid placements found. "
        f"Coordinates and confidence scores require Pro."
    )
    return preview, locked, message


def _preview_explore(r: dict) -> tuple[dict, list[str], str]:
    areas = r.get("areas_inspected", 0)
    positions = r.get("total_screenshots", 0)
    nodes = r.get("total_nodes", 0)
    preview = {
        "areas_found": areas,
        "positions_calculated": positions,
        "total_nodes": nodes,
    }
    locked = ["screenshots", "visual inspection data"]
    message = f"Found {areas} areas with {nodes} nodes. Visual inspection requires Pro."
    return preview, locked, message


_PREVIEW_BUILDERS: dict[str, Any] = {
    "godotiq_project_summary": _preview_project_summary,
    "godotiq_file_context": _preview_file_context,
    "godotiq_dependency_graph": _preview_dependency_graph,
    "godotiq_validate": _preview_validate,
    "godotiq_signal_map": _preview_signal_map,
    "godotiq_trace_flow": _preview_trace_flow,
    "godotiq_impact_check": _preview_impact_check,
    "godotiq_animation_audit": _preview_animation_audit,
    "godotiq_asset_registry": _preview_asset_registry,
    "godotiq_scene_map": _preview_scene_map,
    "godotiq_spatial_audit": _preview_spatial_audit,
    "godotiq_suggest_scale": _preview_suggest_scale,
    "godotiq_placement": _preview_placement,
    "godotiq_explore": _preview_explore,
}


# ── Community preview builder ─────────────────────────────────────────

def build_community_preview(tool_name: str, teaser_data: dict) -> dict:
    """Build a community preview response, regardless of license status.

    Unlike gate_pro(), this function ALWAYS constructs a preview. Used by
    Pro tool stubs when the bundle is unavailable (community mode or degraded state).
    """
    builder = _PREVIEW_BUILDERS.get(tool_name)
    if builder is None:
        return {
            "tier": "community",
            "tool": tool_name,
            "preview": {},
            "locked_sections": ["full analysis"],
            "message": f"{tool_name} analysis complete. Full results require Pro.",
            "upgrade": "godotiq.com/pro",
        }

    preview, locked, message = builder(teaser_data)
    return {
        "tier": "community",
        "tool": tool_name,
        "preview": preview,
        "locked_sections": locked,
        "message": message,
        "upgrade": "godotiq.com/pro",
    }


# ── Gate function ─────────────────────────────────────────────────────

def gate_pro(tool_name: str, result: dict) -> dict:
    """Gate a PRO tool result.  Returns result as-is for PRO users.

    For Community users, discards the full result and returns a rich
    preview dict with real data subsets and locked section names.

    Error results (containing "error" key) always pass through ungated
    so Community users still see actionable error messages.
    """
    # Not a PRO tool — pass through
    if tool_name not in PRO_TOOLS:
        return result

    # PRO license — pass through
    if is_pro():
        return result

    # Error results — pass through (community users need error messages)
    if "error" in result:
        return result

    # Community user calling PRO tool — build preview
    builder = _PREVIEW_BUILDERS.get(tool_name)
    if builder is None:
        return {
            "tier": "community",
            "tool": tool_name,
            "preview": {},
            "locked_sections": ["full analysis"],
            "message": f"{tool_name} analysis complete. Full results require Pro.",
            "upgrade": "godotiq.com/pro",
        }

    preview, locked, message = builder(result)
    return {
        "tier": "community",
        "tool": tool_name,
        "preview": preview,
        "locked_sections": locked,
        "message": message,
        "upgrade": "godotiq.com/pro",
    }
