#!/usr/bin/env python3
"""
FlyDocs Hook: session-start.py
Triggered: When a new Claude Code session begins (SessionStart)
Purpose: Inject continuity context from previous session, active issue, and config state

Exit codes:
  0 - Success (JSON output with additionalContext)
"""

import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path


def _session_dir(repo_dir: str | None = None) -> Path:
    """Resolve the workspace-scoped session directory (FLY-674)."""
    try:
        from repo_context import resolve_session_dir
        return resolve_session_dir(repo_dir)
    except (ImportError, Exception):
        base = Path(repo_dir) if repo_dir else Path('.')
        return base / '.flydocs' / 'session' / 'default'


def get_previous_session_summary(repo_dir: str | None = None) -> str | None:
    """Extract key details from last session summary."""
    summary_file = _session_dir(repo_dir) / 'last-summary.json'
    if not summary_file.exists():
        return None
    try:
        data = json.loads(summary_file.read_text())
        bits: list[str] = []
        issues = data.get('issues', [])
        if issues:
            bits.append(f'issues={",".join(issues)}')
        pending = data.get('pending', [])
        if pending:
            bits.append(f'pending={len(pending)}')
        blockers = data.get('blockers', [])
        if blockers:
            bits.append(f'blockers={len(blockers)}')
        notes = data.get('notes')
        if notes:
            bits.append(f'notes={notes[:80]}')
        if bits:
            return f'Last session: {" | ".join(bits)}'
    except (json.JSONDecodeError, OSError, IOError):
        pass
    return None


def get_active_issue_context(repo_dir: str | None = None) -> str | None:
    """Build active issue status line from session files."""
    sd = _session_dir(repo_dir)
    issue_id = None
    status = None

    focus_file = sd / 'focus.md'
    if focus_file.exists():
        try:
            match = re.search(r'[A-Z]+-[0-9]+', focus_file.read_text())
            if match:
                issue_id = match.group(0)
        except (OSError, IOError):
            pass

    status_file = sd / 'status'
    if status_file.exists():
        try:
            status = status_file.read_text().strip()
        except (OSError, IOError):
            pass

    if not issue_id:
        return None

    ac_part = ''
    ac_file = sd / 'acceptance-criteria.md'
    if ac_file.exists():
        try:
            content = ac_file.read_text()
            total = len(re.findall(r'^\s*-\s*\[', content, re.MULTILINE))
            done = len(re.findall(r'^\s*-\s*\[x\]', content, re.MULTILINE | re.IGNORECASE))
            if total > 0:
                ac_part = f' | AC: {done}/{total}'
        except (OSError, IOError):
            pass

    status_label = f' ({status})' if status else ''
    return f'Active: {issue_id}{status_label}{ac_part}'


def get_config_freshness(repo_dir: str | None = None) -> str | None:
    """Warn if validation cache is stale or missing (v1 configs)."""
    base = Path(repo_dir) if repo_dir else Path('.')
    # v2 configs use get_config_freshness_v2() instead
    config_file = base / '.flydocs/config.json'
    if config_file.exists():
        try:
            config = json.loads(config_file.read_text())
            if config.get('configFormat') == 2:
                return None  # Skip v1 check for v2 configs
        except (json.JSONDecodeError, OSError):
            pass

    cache_file = base / '.flydocs/validation-cache.json'
    if not cache_file.exists():
        return None
    try:
        data = json.loads(cache_file.read_text())
        timestamp_str = data.get('timestamp')
        if not timestamp_str:
            return None
        cached_at = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
        age_hours = (datetime.now(timezone.utc) - cached_at).total_seconds() / 3600
        if age_hours > 24:
            return 'Config validation stale (>24h) — run `flydocs update` to refresh it'
    except (json.JSONDecodeError, OSError, IOError, ValueError):
        pass
    return None


def get_config_freshness_v2(repo_dir: str | None = None) -> str | None:
    """Check config freshness via config/check endpoint (v2 configs only, FLY-540)."""
    base = Path(repo_dir) if repo_dir else Path('.')
    config_file = base / '.flydocs/config.json'
    if not config_file.exists():
        return None

    try:
        config = json.loads(config_file.read_text())
    except (json.JSONDecodeError, OSError):
        return None

    if config.get('configFormat') != 2 or config.get('tier') != 'cloud':
        return None

    # Resolve API key: env var > global credentials
    api_key = os.environ.get('FLYDOCS_API_KEY')
    if not api_key:
        cred_file = Path.home() / '.flydocs' / 'credentials'
        if cred_file.exists():
            try:
                cred = json.loads(cred_file.read_text())
                api_key = cred.get('apiKey')
            except (json.JSONDecodeError, OSError):
                pass

    if not api_key:
        return None

    # Call config/check — lightweight, ~50ms
    base_url = os.environ.get('FLYDOCS_RELAY_URL', 'https://app.flydocs.ai/api/relay').rstrip('/')
    try:
        import urllib.request
        req = urllib.request.Request(
            f'{base_url}/config/check',
            headers={
                'Authorization': f'Bearer {api_key}',
                'Accept': 'application/json',
            },
        )
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode('utf-8'))
    except Exception:
        # Server unreachable — silently continue, never block a session
        return None

    if data.get('needsSync'):
        stale_fields: list[str] = []
        local_config_version = config.get('configVersion', 0)
        if data.get('configVersion', 0) > local_config_version:
            stale_fields.append('config')
        if data.get('templateVersion', 0) > 0:
            stale_fields.append('templates')
        if data.get('contextVersion', 0) > 0:
            stale_fields.append('context')
        detail = f' ({", ".join(stale_fields)})' if stale_fields else ''
        return f'Config updated{detail} — run `flydocs update`'

    return None


def check_setup_complete(repo_dir: str | None = None) -> str | None:
    """FLY-815: Nudge users to /onboard if project context isn't filled in.

    Uses `flydocs/context/project.md` content as the authoritative signal
    rather than the `setupComplete` config flag. This avoids regressing
    existing cloud users whose portal Get Started flow finished setup
    server-side but never wrote `setupComplete: true` to their local
    config.json.

    Banner fires when project.md is missing OR still contains the
    `<!-- Fill during setup:` placeholder marker.
    """
    base = Path(repo_dir) if repo_dir else Path('.')
    config_file = base / '.flydocs/config.json'
    if not config_file.exists():
        return None

    project_md = base / 'flydocs/context/project.md'
    if not project_md.exists():
        needs_setup = True
    else:
        try:
            needs_setup = '<!-- Fill during setup:' in project_md.read_text()
        except (OSError, IOError):
            return None

    if not needs_setup:
        return None

    return (
        'Setup not complete — run /onboard in your IDE to set up project context. '
        'Other commands will run against placeholder context until then.'
    )


def check_skills_manifest(repo_dir: str | None = None) -> str | None:
    """Alert-mode skills manifest validation (FLY-541)."""
    base = Path(repo_dir) if repo_dir else Path('.')
    config_file = base / '.flydocs/config.json'
    if not config_file.exists():
        return None

    try:
        config = json.loads(config_file.read_text())
    except (json.JSONDecodeError, OSError):
        return None

    expected = config.get('skills', {}).get('installed', [])
    if not expected:
        return None

    # Skills live at workspace root (parent), not per-repo
    skills_dir = Path('.claude/skills')
    if not skills_dir.is_dir():
        return f'Skills directory missing — run `flydocs update`'

    # Check for installed directories
    try:
        installed = {d.name for d in skills_dir.iterdir() if d.is_dir()}
    except OSError:
        return None

    expected_set = set(expected)
    missing = expected_set - installed
    unexpected = installed - expected_set

    warnings: list[str] = []
    if missing:
        warnings.append(f'Missing skills: {", ".join(sorted(missing))} — run `flydocs update`')
    if unexpected:
        warnings.append(f'Unexpected skills: {", ".join(sorted(unexpected))}')

    return ' | '.join(warnings) if warnings else None


def get_pending_usage_notice(repo_dir: str | None = None) -> str | None:
    """Deliver any queued AI-usage disclosure (FLY-1210).

    The disclosure used to be printed by `flydocs usage report`, which the Stop
    hook launches with stdout on /dev/null — so it was written to nothing and
    then recorded as already shown, meaning no developer ever saw the notice
    that names their opt-out. Capture now queues it and this hook delivers it.

    The CLI owns the state transition (`usage notice` claims and marks it):
    having this hook mutate the same JSON would recreate the two-writers
    problem FLY-1186 removed from focus.md.
    """
    try:
        result = subprocess.run(
            ['flydocs', 'usage', 'notice'],
            cwd=repo_dir or None,
            capture_output=True,
            text=True,
            timeout=5,
        )
    except (OSError, subprocess.SubprocessError):
        return None

    text = result.stdout.strip()
    return text or None


def main() -> None:
    """Main hook execution."""
    try:
        input_data = json.loads(sys.stdin.read())
    except (json.JSONDecodeError, ValueError):
        input_data = {}

    cwd = input_data.get('cwd', '')
    if cwd and Path(cwd).is_dir():
        os.chdir(cwd)
    elif os.environ.get('CLAUDE_PROJECT_DIR') and Path(os.environ['CLAUDE_PROJECT_DIR']).is_dir():
        os.chdir(os.environ['CLAUDE_PROJECT_DIR'])

    # Resolve active repo for sibling-repos topology
    from repo_context import resolve_repo_dir, is_workspace, list_repo_dirs
    repo_dir = resolve_repo_dir()
    rd = repo_dir if repo_dir != os.getcwd() else None
    in_workspace = is_workspace()

    parts: list[str] = []

    if in_workspace:
        # Workspace mode: aggregate session state across all child repos
        all_repos = list_repo_dirs()
        all_issues: list[str] = []
        all_pending: list[str] = []
        all_blockers: list[str] = []
        latest_notes: str | None = None
        latest_ts: str = ''
        active_issues: list[str] = []

        for repo_name, repo_path in all_repos:
            repo_sd = _session_dir(repo_path)

            # Collect session summaries
            summary_file = repo_sd / 'last-summary.json'
            if summary_file.exists():
                try:
                    data = json.loads(summary_file.read_text())
                    all_issues.extend(data.get('issues', []))
                    all_pending.extend(data.get('pending', []))
                    all_blockers.extend(data.get('blockers', []))
                    ts = data.get('timestamp', '')
                    if ts > latest_ts:
                        latest_ts = ts
                        latest_notes = data.get('notes')
                except (json.JSONDecodeError, OSError):
                    pass

            # Collect active issues per repo
            focus_file = repo_sd / 'focus.md'
            if focus_file.exists():
                try:
                    match = re.search(r'[A-Z]+-[0-9]+', focus_file.read_text())
                    if match:
                        status_file = repo_sd / 'status'
                        status = ''
                        if status_file.exists():
                            status = status_file.read_text().strip()
                        label = f'{match.group(0)}@{repo_name}'
                        if status:
                            label += f'({status})'
                        active_issues.append(label)
                except (OSError, IOError):
                    pass

        # Build workspace summary
        if all_issues:
            unique_issues = list(dict.fromkeys(all_issues))  # dedupe, preserve order
            bits: list[str] = [f'issues={",".join(unique_issues)}']
            if all_pending:
                bits.append(f'pending={len(all_pending)}')
            if all_blockers:
                bits.append(f'blockers={len(all_blockers)}')
            if latest_notes:
                bits.append(f'notes={latest_notes[:80]}')
            parts.append(f'Last session: {" | ".join(bits)}')

        if active_issues:
            parts.append(f'Active across repos: {", ".join(active_issues)}')

        # Workspace topology
        parts.append(f'Workspace: {len(all_repos)} repos ({", ".join(n for n, _ in all_repos)})')

        # Config freshness from active repo
        freshness = get_config_freshness(rd)
        if freshness:
            parts.append(freshness)
        freshness_v2 = get_config_freshness_v2(rd)
        if freshness_v2:
            parts.append(freshness_v2)

        skills_warning = check_skills_manifest(rd)
        if skills_warning:
            parts.append(skills_warning)

        setup_nudge = check_setup_complete(rd)
        if setup_nudge:
            parts.append(setup_nudge)

        usage_notice = get_pending_usage_notice(rd)
        if usage_notice:
            parts.append(usage_notice)
    else:
        # Single-repo mode: existing behavior
        summary = get_previous_session_summary(rd)
        if summary:
            parts.append(summary)

        issue_ctx = get_active_issue_context(rd)
        if issue_ctx:
            parts.append(issue_ctx)

        freshness = get_config_freshness(rd)
        if freshness:
            parts.append(freshness)

        freshness_v2 = get_config_freshness_v2(rd)
        if freshness_v2:
            parts.append(freshness_v2)

        skills_warning = check_skills_manifest(rd)
        if skills_warning:
            parts.append(skills_warning)

        setup_nudge = check_setup_complete(rd)
        if setup_nudge:
            parts.append(setup_nudge)

        usage_notice = get_pending_usage_notice(rd)
        if usage_notice:
            parts.append(usage_notice)

    if not parts:
        sys.exit(0)

    # FLY-781: SessionStart hooks use top-level systemMessage for context
    # injection. The previous hookSpecificOutput.hookEventName="SessionStart"
    # shape is not in the Claude Code hook output schema.
    output = {"systemMessage": " | ".join(parts)}
    print(json.dumps(output))
    sys.exit(0)


if __name__ == '__main__':
    main()
