#!/usr/bin/env python3
"""
Novyx Sentinel - Integrity Auditor v1.1.0
==========================================
Automated verification of data integrity and context persistence compliance.

This script enforces the durability guarantees of the Novyx Core architecture by:
1. Validating cryptographic integrity hashes
2. Verifying cross-domain context linkage
3. Ensuring no orphaned data exists

Supports both schema v1.0 and v1.1.0 (backward compatible).

Usage:
    python tools/sentinel.py [--directory memory/] [--verbose]
"""

import json
import sys
from tools.util import calculate_hash as _calculate_hash
from pathlib import Path
from typing import Dict, List, Tuple, Set, Optional
from dataclasses import dataclass
from datetime import datetime

# Import schema utilities
try:
    from tools.factory import load_schema, get_required_properties
    SCHEMA_AVAILABLE = True
except ImportError:
    SCHEMA_AVAILABLE = False


@dataclass
class ValidationResult:
    """Result of validating a single artifact."""
    file_path: str
    passed: bool
    checks: Dict[str, bool]
    errors: List[str]
    warnings: List[str]
    persistent_id: str = None


class NovyxSentinel:
    """Integrity auditor for Novyx Core artifacts."""

    def __init__(self, root_dir: Path, verbose: bool = False, enforce_graph: bool = False):
        self.root_dir = root_dir
        self.verbose = verbose
        self.enforce_graph = enforce_graph
        self.results: List[ValidationResult] = []
        self.persistent_ids: Dict[str, str] = {}  # Maps ID -> file path
        self.context_links: Dict[str, List[str]] = {}  # Maps file -> links
        self.graph_errors: List[str] = []  # Graph-wide errors if enforce_graph enabled

    def calculate_hash(self, file_path: Path) -> str:
        """Calculate SHA-256 hash of file content, excluding the Integrity_Hash line."""
        return _calculate_hash(file_path)

    def load_jsonld(self, file_path: Path) -> Tuple[dict, List[str]]:
        """Load and parse a JSON-LD file, returning data and any parse errors."""
        errors = []
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                data = json.load(f)
            return data, errors
        except json.JSONDecodeError as e:
            errors.append(f"JSON parse error: {e}")
            return {}, errors
        except Exception as e:
            errors.append(f"File read error: {e}")
            return {}, errors

    def get_artifact_schema_id(self, data: dict) -> Optional[str]:
        """Determine schema_id from artifact @type field."""
        type_field = data.get('@type')
        if not type_field:
            return None
        
        # Handle list or string
        type_list = type_field if isinstance(type_field, list) else [type_field]
        
        # Map known types to schema IDs
        for t in type_list:
            if t == 'Decision' or t == 'novyx:Decision':
                return 'novyx:Decision'
            # Future types can be added here
        
        return None

    def validate_required_fields(self, data: dict, file_path: str) -> Tuple[Dict[str, bool], List[str]]:
        """Validate that all required CORE_SCHEMA fields are present (schema-driven).

        Supports both v1.0 and v1.1.0 schemas:
        - v1.0: Persistent_ID, Context_Link, Integrity_Hash, Temporal_Marker
        - v1.1.0: uuid, novyx:semanticLinks, novyx:integrityHash, novyx:ingestedAt
        
        For typed artifacts (e.g., Decision), reads requiredProperties from CORE_SCHEMA.jsonld.
        """
        checks = {}
        errors = []

        # Try schema-driven validation first
        schema_id = self.get_artifact_schema_id(data)
        if schema_id and SCHEMA_AVAILABLE:
            try:
                required_props = get_required_properties(schema_id)
                if required_props:
                    # Validate each required property (handle aliases like "uuid|Persistent_ID")
                    for prop_spec in required_props:
                        aliases = [p.strip() for p in prop_spec.split("|")]
                        check_name = f"has_{aliases[0]}"
                        
                        # Check if any alias is present
                        found = False
                        for alias in aliases:
                            if alias in data:
                                value = data[alias]
                                # Check for presence (allow empty lists, 0, False as valid)
                                # Only reject None, empty string, or "pending_calculation" placeholder
                                if value is None or (isinstance(value, str) and value in ("", "pending_calculation")):
                                    continue
                                found = True
                                break
                        
                        checks[check_name] = found
                        if not found:
                            errors.append(f"Missing required field: {prop_spec}")
                    
                    return checks, errors
            except Exception as e:
                # Fall back to legacy validation if schema-driven fails
                if self.verbose:
                    print(f"⚠️  Schema-driven validation failed for {schema_id}: {e}", file=sys.stderr)

        # Fallback: legacy hardcoded validation (preserves existing behavior)
        # Check for ID (v1.0: Persistent_ID, v1.1.0: uuid)
        has_id = ('Persistent_ID' in data and data['Persistent_ID']) or \
                 ('uuid' in data and data['uuid'])
        checks['has_id'] = has_id
        if not has_id:
            errors.append("Missing required field: Persistent_ID or uuid")

        # Check for links (v1.0: Context_Link, v1.1.0: novyx:semanticLinks or sameAs)
        has_links = ('Context_Link' in data and data['Context_Link']) or \
                    ('novyx:semanticLinks' in data) or \
                    ('sameAs' in data)
        checks['has_links'] = has_links
        if not has_links:
            errors.append(
                "Missing required field: Context_Link, novyx:semanticLinks, or sameAs")

        # Check for integrity hash (v1.0: Integrity_Hash, v1.1.0: novyx:integrityHash)
        has_hash = ('Integrity_Hash' in data and data['Integrity_Hash']) or \
                   ('novyx:integrityHash' in data and data['novyx:integrityHash'])
        checks['has_hash'] = has_hash
        if not has_hash:
            errors.append(
                "Missing required field: Integrity_Hash or novyx:integrityHash")

        # Check for timestamp (v1.0: Temporal_Marker, v1.1.0: novyx:ingestedAt)
        has_timestamp = ('Temporal_Marker' in data and data['Temporal_Marker']) or \
                        ('novyx:ingestedAt' in data and data['novyx:ingestedAt'])
        checks['has_timestamp'] = has_timestamp
        if not has_timestamp:
            errors.append(
                "Missing required field: Temporal_Marker or novyx:ingestedAt")

        return checks, errors

    def validate_integrity_hash(self, data: dict, file_path: Path) -> Tuple[bool, List[str]]:
        """Validate the Integrity_Hash matches actual file content.

        Supports both v1.0 (Integrity_Hash) and v1.1.0 (novyx:integrityHash).
        """
        errors = []

        # Support both v1.0 and v1.1.0 field names
        declared_hash = None
        if 'Integrity_Hash' in data:
            declared_hash = data['Integrity_Hash']
        elif 'novyx:integrityHash' in data:
            declared_hash = data['novyx:integrityHash']

        if not declared_hash:
            return False, ["No Integrity_Hash or novyx:integrityHash field found"]

        # Check for placeholder values
        if declared_hash in ['pending_calculation', 'TBD', '', None]:
            return False, [f"Integrity hash not calculated: '{declared_hash}'"]

        # Calculate actual hash
        try:
            actual_hash = self.calculate_hash(file_path)

            if actual_hash == declared_hash:
                return True, []
            else:
                errors.append(f"Hash mismatch!")
                errors.append(f"  Declared: {declared_hash}")
                errors.append(f"  Actual:   {actual_hash}")
                return False, errors
        except Exception as e:
            return False, [f"Hash calculation error: {e}"]

    def extract_links(self, data: dict) -> List[str]:
        """Extract all Context_Link and related link fields from the artifact.

        Supports both v1.0 and v1.1.0 schemas:
        - v1.0: Context_Link, linkedHealthLogs, linkedResearch
        - v1.1.0: novyx:semanticLinks (array of {targetId, confidenceScore, rationale})
        """
        links = []

        # v1.0: Direct Context_Link
        if 'Context_Link' in data:
            link = data['Context_Link']
            if link and isinstance(link, str) and link.startswith('urn:uuid:'):
                links.append(link)

        # v1.1.0: Semantic links array
        if 'novyx:semanticLinks' in data and isinstance(data['novyx:semanticLinks'], list):
            for link_obj in data['novyx:semanticLinks']:
                if isinstance(link_obj, dict) and 'targetId' in link_obj:
                    target = link_obj['targetId']
                    if target and target.startswith('urn:uuid:'):
                        links.append(target)

        # v1.0: linkedHealthLogs (from research node)
        if 'linkedHealthLogs' in data and isinstance(data['linkedHealthLogs'], list):
            links.extend([l for l in data['linkedHealthLogs']
                         if l.startswith('urn:uuid:')])

        # v1.0: linkedResearch (from health log)
        if 'linkedResearch' in data and isinstance(data['linkedResearch'], dict):
            if '@id' in data['linkedResearch']:
                link = data['linkedResearch']['@id']
                if link.startswith('urn:uuid:'):
                    links.append(link)

        # Generic dependency tracking
        if 'dependencies' in data and isinstance(data['dependencies'], list):
            links.extend([d for d in data['dependencies'] if isinstance(
                d, str) and d.startswith('urn:uuid:')])

        return links

    def validate_artifact(self, file_path: Path) -> ValidationResult:
        """Perform comprehensive validation on a single artifact."""
        rel_path = str(file_path.relative_to(self.root_dir.parent))

        # Load JSON-LD
        data, parse_errors = self.load_jsonld(file_path)
        if parse_errors:
            return ValidationResult(
                file_path=rel_path,
                passed=False,
                checks={'parse': False},
                errors=parse_errors,
                warnings=[]
            )

        # Collect all checks
        checks = {'parse': True}
        errors = []
        warnings = []

        # Required fields
        field_checks, field_errors = self.validate_required_fields(
            data, rel_path)
        checks.update(field_checks)
        errors.extend(field_errors)

        # Integrity hash
        hash_valid, hash_errors = self.validate_integrity_hash(data, file_path)
        checks['integrity_hash'] = hash_valid
        errors.extend(hash_errors)

        # Store Persistent_ID (v1.0) or uuid (v1.1.0) for later link validation
        persistent_id = data.get('Persistent_ID') or data.get('uuid')
        if persistent_id:
            if persistent_id in self.persistent_ids:
                warnings.append(f"Duplicate ID: {persistent_id}")
            self.persistent_ids[persistent_id] = rel_path

        # Extract links for later validation
        links = self.extract_links(data)
        if links:
            self.context_links[rel_path] = links

        # Temporal marker validation (v1.0: Temporal_Marker, v1.1.0: novyx:ingestedAt)
        timestamp = data.get('Temporal_Marker') or data.get('novyx:ingestedAt')
        if timestamp:
            try:
                datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
                checks['temporal_format'] = True
            except (ValueError, TypeError):
                checks['temporal_format'] = False
                warnings.append("Timestamp is not valid ISO 8601 format")

        # Decision-specific validation
        decision_valid = True
        decision_checks = {}
        if self.is_decision(data):
            decision_valid, decision_errors, decision_checks = self.validate_decision_fields(data)
            checks.update(decision_checks)
            checks['decision_schema'] = decision_valid
            errors.extend(decision_errors)

        # Overall pass/fail (updated for v1.1.0 compatibility + schema-driven validation)
        # Must pass: parse, integrity_hash, and no errors
        base_checks_passed = checks.get('parse', False) and checks.get('integrity_hash', False)
        
        # Check if we have legacy check keys OR schema-driven keys (at least one ID field present)
        has_identity = (checks.get('has_id', False) or 
                       checks.get('has_uuid', False) or 
                       checks.get('has_Persistent_ID', False))
        
        # Check for links/connections field
        has_connections = (checks.get('has_links', False) or
                          checks.get('has_novyx:semanticLinks', False) or
                          checks.get('has_sameAs', False))
        
        # Check for hash field
        has_hash_field = (checks.get('has_hash', False) or
                         checks.get('has_novyx:integrityHash', False) or
                         checks.get('has_Integrity_Hash', False))
        
        # Decision schema validation (if applicable)
        decision_ok = checks.get('decision_schema', True)
        
        # Overall: no errors and key fields present
        passed = (base_checks_passed and 
                 has_identity and
                 has_connections and
                 has_hash_field and
                 decision_ok and
                 len(errors) == 0)

        return ValidationResult(
            file_path=rel_path,
            passed=passed,
            checks=checks,
            errors=errors,
            warnings=warnings,
            persistent_id=persistent_id
        )

    def is_decision(self, data: dict) -> bool:
        """Identify Decision artifacts by @type."""
        type_field = data.get('@type')
        if isinstance(type_field, list):
            return 'Decision' in type_field or 'novyx:Decision' in type_field
        return type_field in ('Decision', 'novyx:Decision')

    def _is_iso(self, value: str) -> bool:
        try:
            datetime.fromisoformat(value.replace('Z', '+00:00'))
            return True
        except Exception:
            return False

    def validate_decision_fields(self, data: dict) -> Tuple[bool, List[str], Dict[str, bool]]:
        """Validate Decision artifact required fields and shapes."""
        errors = []
        checks = {}

        def _assert_field(name: str, condition: bool, message: str):
            checks[name] = condition
            if not condition:
                errors.append(message)

        _assert_field('title', isinstance(data.get('title'), str) and bool(data.get('title').strip()), "Missing or empty title for Decision")
        _assert_field('context', isinstance(data.get('context'), str) and bool(data.get('context').strip()), "Missing or empty context for Decision")

        options = data.get('options_considered')
        _assert_field(
            'options_considered',
            isinstance(options, list) and all(isinstance(o, (str, dict)) for o in options),
            "options_considered must be a list of strings or objects"
        )

        _assert_field('chosen_option', isinstance(data.get('chosen_option'), str) and bool(data.get('chosen_option').strip()),
                      "Missing chosen_option for Decision")
        _assert_field('reasoning', isinstance(data.get('reasoning'), str) and bool(data.get('reasoning').strip()),
                      "Missing reasoning for Decision")

        constraints = data.get('constraints')
        _assert_field(
            'constraints',
            isinstance(constraints, list) and all(isinstance(c, (str, dict)) for c in constraints),
            "constraints must be a list"
        )
        assumptions = data.get('assumptions')
        _assert_field(
            'assumptions',
            isinstance(assumptions, list) and all(isinstance(a, (str, dict)) for a in assumptions),
            "assumptions must be a list"
        )

        confidence = data.get('confidence')
        numeric_conf = isinstance(confidence, (int, float)) and 0 <= confidence <= 1
        enum_conf = isinstance(confidence, str)
        _assert_field('confidence', numeric_conf or enum_conf, "confidence must be 0-1 numeric or enum string")

        _assert_field('expected_outcome', isinstance(data.get('expected_outcome'), str) and bool(data.get('expected_outcome').strip()),
                      "Missing expected_outcome for Decision")
        _assert_field('category', isinstance(data.get('category'), str) and bool(data.get('category').strip()),
                      "Missing category for Decision")

        created_at = data.get('createdAt') or data.get('Temporal_Marker') or data.get('novyx:ingestedAt')
        updated_at = data.get('updatedAt') or data.get('novyx:updatedAt') or created_at
        _assert_field('createdAt', bool(created_at) and isinstance(created_at, str) and self._is_iso(created_at),
                      "createdAt/Temporal_Marker must be ISO 8601")
        _assert_field('updatedAt', bool(updated_at) and isinstance(updated_at, str) and self._is_iso(updated_at),
                      "updatedAt/novyx:updatedAt must be ISO 8601")

        same_as = data.get('sameAs', [])
        if same_as:
            is_valid_links = isinstance(same_as, list) and all(isinstance(link, str) and ("http://" in link or "https://" in link) for link in same_as)
            _assert_field('sameAs_format', is_valid_links, "sameAs links must be valid URLs")

        return len(errors) == 0, errors, checks

    def validate_links(self) -> List[Tuple[str, List[str]]]:
        """Validate that all Context_Links point to existing artifacts."""
        broken_links = []

        for file_path, links in self.context_links.items():
            missing = []
            for link in links:
                # Allow special namespace links (not UUIDs to other artifacts)
                if link.startswith('novyx:') and not link.startswith('urn:uuid:'):
                    continue

                if link not in self.persistent_ids:
                    missing.append(link)

            if missing:
                broken_links.append((file_path, missing))

        return broken_links
    
    def enforce_graph_integrity(self, directory: Path) -> None:
        """
        Perform graph-wide enforcement checks (Layer 4).
        
        Detects orphaned internal links across all artifacts in directory.
        Stores errors in self.graph_errors for reporting.
        """
        try:
            from tools.graph import scan_artifacts, build_index, find_orphaned_links
            
            # Scan and build graph index
            artifacts, scan_errors = scan_artifacts(directory)
            
            if scan_errors:
                self.graph_errors.extend(scan_errors)
            
            if not artifacts:
                return
            
            # Build index and find orphans
            index = build_index(artifacts)
            orphaned = find_orphaned_links(index)
            
            if orphaned:
                self.graph_errors.append("Graph enforcement: Orphaned internal links detected")
                for file_path, orphan_ids in orphaned.items():
                    # Make path relative for consistency
                    try:
                        rel_path = Path(file_path).relative_to(self.root_dir.parent)
                    except ValueError:
                        rel_path = Path(file_path)
                    
                    self.graph_errors.append(
                        f"  {rel_path}: {len(orphan_ids)} orphaned link(s) - {', '.join(list(orphan_ids)[:3])}{'...' if len(orphan_ids) > 3 else ''}"
                    )
        
        except ImportError as e:
            self.graph_errors.append(f"Graph enforcement failed: {e}")
        except Exception as e:
            self.graph_errors.append(f"Graph enforcement error: {e}")

    def scan_directory(self, directory: Path) -> None:
        """Recursively scan directory for .jsonld files and validate them."""
        jsonld_files = sorted(directory.rglob('*.jsonld'))

        if not jsonld_files:
            print(f"⚠️  No .jsonld files found in {directory}")
            return

        print(f"🔍 Scanning {len(jsonld_files)} artifact(s) in {directory}\n")

        # Phase 1: Validate individual artifacts
        for file_path in jsonld_files:
            result = self.validate_artifact(file_path)
            self.results.append(result)

        # Phase 2: Validate cross-references
        broken_links = self.validate_links()

        # Update results with link validation
        for file_path, missing_links in broken_links:
            for result in self.results:
                if result.file_path == file_path:
                    result.checks['links_valid'] = False
                    result.passed = False
                    result.errors.append(
                        f"Broken links: {', '.join(missing_links)}")
        
        # Phase 3: Graph-wide enforcement (optional)
        if self.enforce_graph:
            self.enforce_graph_integrity(directory)

    def print_report(self) -> bool:
        """Print validation report and return True if all checks passed."""
        print("=" * 70)
        print("🛡️  NOVYX SENTINEL - INTEGRITY AUDIT REPORT")
        print("=" * 70)
        print()

        all_passed = True

        for result in self.results:
            if result.passed:
                print(f"✅ PASSED: {result.file_path}")
                if self.verbose and result.warnings:
                    for warning in result.warnings:
                        print(f"   ⚠️  {warning}")
            else:
                print(f"❌ FAILED: {result.file_path}")
                all_passed = False
                for error in result.errors:
                    print(f"   ✗ {error}")
                if result.warnings:
                    for warning in result.warnings:
                        print(f"   ⚠️  {warning}")

            if self.verbose:
                print(f"   Checks: {result.checks}")
            print()

        # Summary
        print("=" * 70)
        passed_count = sum(1 for r in self.results if r.passed)
        failed_count = len(self.results) - passed_count

        print(f"📊 SUMMARY:")
        print(f"   Total artifacts: {len(self.results)}")
        print(f"   ✅ Passed: {passed_count}")
        print(f"   ❌ Failed: {failed_count}")
        print(f"   🔗 Unique IDs: {len(self.persistent_ids)}")
        print(
            f"   🌐 Cross-links: {sum(len(links) for links in self.context_links.values())}")
        print()
        
        # Graph enforcement errors (Layer 4)
        if self.graph_errors:
            all_passed = False
            print("❌ GRAPH ENFORCEMENT FAILURES:")
            for error in self.graph_errors:
                print(f"   {error}")
            print()

        if all_passed:
            print("🎉 ALL INTEGRITY CHECKS PASSED")
            print("   The Novyx Core data layer is verified and durable.")
        else:
            print("⚠️  INTEGRITY VIOLATIONS DETECTED")
            print("   Please fix errors before proceeding.")

        print("=" * 70)

        return all_passed


def main():
    """Entry point for Sentinel auditor."""
    import argparse

    parser = argparse.ArgumentParser(
        description='Novyx Sentinel - Integrity Auditor for persistent data structures'
    )
    parser.add_argument(
        '--directory',
        type=str,
        default='memory',
        help='Directory to scan for .jsonld files (default: memory)'
    )
    parser.add_argument(
        '--verbose',
        action='store_true',
        help='Enable verbose output with detailed check information'
    )
    parser.add_argument(
        '--enforce-graph',
        action='store_true',
        help='Enable graph-wide enforcement (detect orphaned internal links)'
    )

    args = parser.parse_args()

    # Determine root directory (support running from project root or tools/)
    current_dir = Path.cwd()
    if current_dir.name == 'tools':
        root_dir = current_dir.parent / args.directory
    else:
        root_dir = current_dir / args.directory

    if not root_dir.exists():
        print(f"❌ ERROR: Directory not found: {root_dir}")
        sys.exit(1)

    # Run audit
    sentinel = NovyxSentinel(root_dir, verbose=args.verbose, enforce_graph=args.enforce_graph)
    sentinel.scan_directory(root_dir)
    all_passed = sentinel.print_report()

    # Exit with appropriate code
    sys.exit(0 if all_passed else 1)


if __name__ == '__main__':
    main()
