"""SalmAlm core — audit, cache, usage, router, compaction, search,
subagent, skills, session, cron, daily."""
import asyncio, hashlib, json, math, os, re, sqlite3, textwrap, threading, time
from collections import OrderedDict
from datetime import datetime
from pathlib import Path
from typing import Optional

from .constants import *
from .crypto import vault, log

# ============================================================
_db_lock = threading.Lock()  # Single lock for all audit.db access
_audit_lock = _db_lock


def _init_audit_db():
    conn = sqlite3.connect(str(AUDIT_DB))
    conn.execute('''CREATE TABLE IF NOT EXISTS audit_log (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ts TEXT NOT NULL, event TEXT NOT NULL,
        detail TEXT, prev_hash TEXT, hash TEXT NOT NULL
    )''')
    conn.execute('''CREATE TABLE IF NOT EXISTS usage_stats (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ts TEXT NOT NULL, model TEXT NOT NULL,
        input_tokens INTEGER, output_tokens INTEGER, cost REAL
    )''')
    conn.execute('''CREATE TABLE IF NOT EXISTS session_store (
        session_id TEXT PRIMARY KEY,
        messages TEXT NOT NULL,
        updated_at TEXT NOT NULL
    )''')
    conn.commit()
    conn.close()


def audit_log(event: str, detail: str = ''):
    with _audit_lock:
        conn = sqlite3.connect(str(AUDIT_DB))
        row = conn.execute(
            'SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1'
        ).fetchone()
        prev = row[0] if row else '0' * 64
        ts = datetime.now(KST).isoformat()
        payload = f"{ts}|{event}|{detail}|{prev}"
        h = hashlib.sha256(payload.encode()).hexdigest()
        conn.execute(
            'INSERT INTO audit_log (ts, event, detail, prev_hash, hash) VALUES (?,?,?,?,?)',
            (ts, event, detail[:500], prev, h)
        )
        conn.commit()
        conn.close()



class ResponseCache:
    """Simple TTL cache for LLM responses to avoid duplicate calls."""

    def __init__(self, max_size=100, ttl=CACHE_TTL):
        self._cache: OrderedDict = OrderedDict()
        self._max_size = max_size
        self._ttl = ttl

    def _key(self, model: str, messages: list) -> str:
        content = json.dumps({'m': model, 'msgs': messages[-3:]}, sort_keys=True)
        return hashlib.sha256(content.encode()).hexdigest()[:16]

    def get(self, model: str, messages: list) -> Optional[str]:
        k = self._key(model, messages)
        if k in self._cache:
            entry = self._cache[k]
            if time.time() - entry['ts'] < self._ttl:
                self._cache.move_to_end(k)
                log.info(f"💰 Cache hit — saved API call")
                return entry['response']
            del self._cache[k]
        return None

    def put(self, model: str, messages: list, response: str):
        k = self._key(model, messages)
        self._cache[k] = {'response': response, 'ts': time.time()}
        if len(self._cache) > self._max_size:
            self._cache.popitem(last=False)


response_cache = ResponseCache()

_usage_lock = _db_lock  # Share lock with audit to prevent race conditions
_usage = {'total_input': 0, 'total_output': 0, 'total_cost': 0.0,
          'by_model': {}, 'session_start': time.time()}

def _restore_usage():
    """Restore cumulative usage from SQLite on startup."""
    try:
        conn = sqlite3.connect(str(AUDIT_DB))
        rows = conn.execute('SELECT model, SUM(input_tokens), SUM(output_tokens), SUM(cost), COUNT(*) FROM usage_stats GROUP BY model').fetchall()
        for model, inp, out, cost, calls in rows:
            short = model.split('/')[-1] if '/' in model else model
            _usage['total_input'] += (inp or 0)
            _usage['total_output'] += (out or 0)
            _usage['total_cost'] += (cost or 0)
            _usage['by_model'][short] = {'input': inp or 0, 'output': out or 0,
                                          'cost': cost or 0, 'calls': calls or 0}
        conn.close()
        if _usage['total_cost'] > 0:
            log.info(f"📊 Usage restored: ${_usage['total_cost']:.4f} total")
    except Exception as e:
        log.warning(f"Usage restore failed: {e}")


def track_usage(model: str, input_tokens: int, output_tokens: int):
    with _usage_lock:
        short = model.split('/')[-1] if '/' in model else model
        cost_info = MODEL_COSTS.get(short, {'input': 1.0, 'output': 5.0})
        cost = (input_tokens * cost_info['input'] + output_tokens * cost_info['output']) / 1_000_000
        _usage['total_input'] += input_tokens
        _usage['total_output'] += output_tokens
        _usage['total_cost'] += cost
        if short not in _usage['by_model']:
            _usage['by_model'][short] = {'input': 0, 'output': 0, 'cost': 0.0, 'calls': 0}
        _usage['by_model'][short]['input'] += input_tokens
        _usage['by_model'][short]['output'] += output_tokens
        _usage['by_model'][short]['cost'] += cost
        _usage['by_model'][short]['calls'] += 1
        # Persist to SQLite
        try:
            conn = sqlite3.connect(str(AUDIT_DB))
            conn.execute('INSERT INTO usage_stats (ts, model, input_tokens, output_tokens, cost) VALUES (?,?,?,?,?)',
                         (datetime.now(KST).isoformat(), model, input_tokens, output_tokens, cost))
            conn.commit()
            conn.close()
        except Exception:
            pass


def get_usage_report() -> dict:
    with _usage_lock:
        elapsed = time.time() - _usage['session_start']
        return {**_usage, 'elapsed_hours': round(elapsed / 3600, 2)}



class ModelRouter:
    """Routes queries to appropriate models based on complexity."""

    # Tier 1: cheap & fast, Tier 2: balanced, Tier 3: powerful
    TIERS = {
        1: ['google/gemini-3-flash-preview', 'openai/gpt-4.1-nano',
            'openai/gpt-4.1-mini', 'xai/grok-3-mini'],
        2: ['anthropic/claude-sonnet-4-20250514', 'openai/gpt-5.3-codex',
            'xai/grok-4', 'google/gemini-3-pro-preview', 'openai/gpt-4.1',
            'ollama/llama3.3', 'ollama/qwen3'],
        3: ['anthropic/claude-opus-4-6', 'openai/o3',
            'anthropic/claude-sonnet-4-20250514', 'openai/gpt-5.1-codex',
            'ollama/llama3.3'],
    }

    _MODEL_PREF_FILE = BASE_DIR / '.model_pref'

    def __init__(self):
        self.default_tier = 2
        self.force_model: Optional[str] = None
        # Restore persisted model preference
        try:
            if self._MODEL_PREF_FILE.exists():
                saved = self._MODEL_PREF_FILE.read_text().strip()
                if saved and saved != 'auto':
                    self.force_model = saved
                    log.info(f"🔧 Restored model preference: {saved}")
        except Exception:
            pass

    def set_force_model(self, model: Optional[str]):
        """Set and persist model preference."""
        self.force_model = model
        try:
            if model:
                self._MODEL_PREF_FILE.write_text(model)
            elif self._MODEL_PREF_FILE.exists():
                self._MODEL_PREF_FILE.unlink()
        except Exception as e:
            log.error(f"Failed to persist model pref: {e}")

    def route(self, user_message: str, has_tools: bool = False,
              iteration: int = 0) -> str:
        if self.force_model:
            return self.force_model

        msg = user_message.lower()
        msg_len = len(user_message)

        # Tool-heavy iterations → always Tier 2+
        if iteration > 2:
            return self._pick_available(2)

        # Tier 3: complex tasks
        complex_score = sum(1 for kw in COMPLEX_INDICATORS if kw in msg)
        tool_hint_score = sum(1 for kw in TOOL_HINT_KEYWORDS if kw in msg)
        if complex_score >= 2 or msg_len > 1000 or (complex_score >= 1 and tool_hint_score >= 1):
            return self._pick_available(3)

        # Tier 2: tool usage likely or medium complexity
        if has_tools and (tool_hint_score >= 1 or msg_len > 300):
            return self._pick_available(2)

        # Tier 1: simple queries only
        if msg_len < SIMPLE_QUERY_MAX_CHARS and not has_tools and complex_score == 0:
            return self._pick_available(1)

        # Tier 2: default
        return self._pick_available(2)

    _OR_PROVIDERS = frozenset(['deepseek', 'meta-llama', 'mistralai', 'qwen'])

    def _has_key(self, provider: str) -> bool:
        if provider == 'ollama':
            return True  # Ollama always available (local)
        if provider in self._OR_PROVIDERS:
            return bool(vault.get('openrouter_api_key'))
        return bool(vault.get(f'{provider}_api_key'))

    def _pick_available(self, tier: int) -> str:
        models = self.TIERS.get(tier, self.TIERS[2])
        for m in models:
            provider = m.split('/')[0]
            if self._has_key(provider):
                return m
        # Fallback: try any available model
        for t in [2, 1, 3]:
            for m in self.TIERS.get(t, []):
                provider = m.split('/')[0]
                if self._has_key(provider):
                    return m
        return 'google/gemini-3-flash-preview'  # last resort


router = ModelRouter()


# ============================================================
def _msg_content_str(msg: dict) -> str:
    """Extract text content from a message (handles list content blocks)."""
    c = msg.get('content', '')
    if isinstance(c, str):
        return c
    if isinstance(c, list):
        return ' '.join(b.get('text', '') for b in c if isinstance(b, dict) and b.get('type') == 'text')
    return str(c)


def compact_messages(messages: list, model: str = None) -> list:
    """Multi-stage compaction: trim tool results → drop old tools → summarize.
    Hard limit: max 100 messages, max 500K chars (≈125K tokens)."""
    MAX_MESSAGES = 100
    MAX_CHARS = 500_000

    # Hard message count limit
    if len(messages) > MAX_MESSAGES:
        system_msgs = [m for m in messages if m['role'] == 'system'][:1]
        recent = [m for m in messages if m['role'] != 'system'][-40:]
        messages = system_msgs + recent
        log.warning(f"🔪 Hard msg limit: truncated to {len(messages)} messages")

    total_chars = sum(len(_msg_content_str(m)) for m in messages)

    # Hard char limit — emergency truncation
    if total_chars > MAX_CHARS:
        system_msgs = [m for m in messages if m['role'] == 'system'][:1]
        recent = [m for m in messages if m['role'] != 'system'][-20:]
        messages = system_msgs + recent
        total_chars = sum(len(_msg_content_str(m)) for m in messages)
        log.warning(f"🔪 Hard char limit: truncated to {len(messages)} msgs ({total_chars} chars)")

    if total_chars < COMPACTION_THRESHOLD:
        return messages

    log.info(f"📦 Compacting {len(messages)} messages ({total_chars} chars)")

    # Stage 1: Trim long tool results (keep first 500 chars)
    trimmed = []
    for m in messages:
        if m['role'] == 'tool' and len(_msg_content_str(m)) > 500:
            trimmed.append({**m, 'content': _msg_content_str(m)[:500] + '\n... [truncated]'})
        elif m['role'] == 'user' and isinstance(m.get('content'), list):
            # Strip base64 image data from old messages
            new_content = []
            for block in m['content']:
                if isinstance(block, dict) and block.get('type') == 'image':
                    new_content.append({'type': 'text', 'text': '[Image attached]'})
                else:
                    new_content.append(block)
            trimmed.append({**m, 'content': new_content})
        else:
            trimmed.append(m)

    total_after_trim = sum(len(_msg_content_str(m)) for m in trimmed)
    if total_after_trim < COMPACTION_THRESHOLD:
        log.info(f"📦 Stage 1 sufficient: {total_chars} → {total_after_trim} chars")
        return trimmed

    # Stage 2: Drop old tool messages entirely, keep last 10 messages
    system_msgs = [m for m in trimmed if m['role'] == 'system']
    non_system = [m for m in trimmed if m['role'] != 'system']
    recent = non_system[-10:]
    old = non_system[:-10]

    # Drop tool/tool_result messages from old, keep user/assistant
    old_important = [m for m in old if m['role'] in ('user', 'assistant')]

    stage2 = system_msgs + old_important + recent
    total_after_drop = sum(len(_msg_content_str(m)) for m in stage2)
    if total_after_drop < COMPACTION_THRESHOLD:
        log.info(f"📦 Stage 2 sufficient: {total_chars} → {total_after_drop} chars")
        return stage2

    # Stage 3: Summarize old messages
    to_summarize = old_important
    if not to_summarize:
        return system_msgs + recent

    summary_text = '\n'.join(
        f"[{m['role']}]: {_msg_content_str(m)[:300]}" for m in to_summarize[-20:]
    )

    from .llm import call_llm
    # Pick cheapest available model for summarization (avoid hardcoded google)
    summary_model = router._pick_available(1)
    _summ_msgs = [
        {'role': 'system', 'content': 'Summarize the following conversation concisely. Focus on decisions, tasks, and key context in 5-8 sentences.'},
        {'role': 'user', 'content': summary_text}
    ]
    # Note: call_llm is sync (urllib). Always call directly since compact_messages
    # is invoked from sync context. If ever called from async, wrap in run_in_executor.
    summary_result = call_llm(_summ_msgs, model=summary_model, max_tokens=800)

    compacted = system_msgs + [
        {'role': 'system', 'content': f'[Previous conversation summary]\n{summary_result["content"]}'}
    ] + recent

    log.info(f"📦 Stage 3 compacted: {len(messages)} → {len(compacted)} messages, "
             f"{total_chars} → {sum(len(_msg_content_str(m)) for m in compacted)} chars")
    return compacted



# ============================================================
import math

class TFIDFSearch:
    """Lightweight TF-IDF + cosine similarity search. No external deps."""

    def __init__(self):
        self._docs: list = []        # [(label, line_no, text, tokens)]
        self._idf: dict = {}         # term -> IDF score
        self._built = False
        self._last_index_time = 0
        self._stop_words = frozenset([
            '의', '가', '이', '은', '는', '을', '를', '에', '에서', '로', '으로',
            '와', '과', '도', '만', '부터', '까지', '에게', '한테', '에서의',
            'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
            'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
            'that', 'this', 'it', 'not', 'no', 'if', 'then', 'so', 'as', 'by',
        ])

    def _tokenize(self, text: str) -> list:
        """Split text into normalized tokens."""
        text = text.lower()
        # Split on non-alphanumeric (keeping Korean chars)
        tokens = re.findall(r'[\w가-힣]+', text)
        return [t for t in tokens if len(t) > 1 and t not in self._stop_words]

    def _index_files(self):
        """Build index from MEMORY.md, memory/*.md, uploads/*.txt etc."""
        now = time.time()
        if self._built and now - self._last_index_time < 300:  # Re-index every 5 min
            return

        self._docs = []
        doc_freq = {}  # term -> number of docs containing it
        search_files = []

        if MEMORY_FILE.exists():
            search_files.append(('MEMORY.md', MEMORY_FILE))
        for f in sorted(MEMORY_DIR.glob('*.md')):
            search_files.append((f'memory/{f.name}', f))
        uploads_dir = WORKSPACE_DIR / 'uploads'
        if uploads_dir.exists():
            for f in uploads_dir.glob('*'):
                if f.suffix.lower() in ('.txt', '.md', '.py', '.js', '.json', '.csv',
                                         '.html', '.css', '.log', '.xml', '.yaml', '.yml'):
                    search_files.append((f'uploads/{f.name}', f))
        # Also index skills
        skills_dir = WORKSPACE_DIR / 'skills'
        if skills_dir.exists():
            for f in skills_dir.glob('**/*.md'):
                search_files.append((f'skills/{f.relative_to(skills_dir)}', f))

        for label, fpath in search_files:
            try:
                text = fpath.read_text(encoding='utf-8', errors='replace')
                lines = text.splitlines()
                # Index in chunks of 3 lines for context
                for i in range(0, len(lines), 2):
                    chunk = '\n'.join(lines[i:i+3])
                    if not chunk.strip():
                        continue
                    tokens = self._tokenize(chunk)
                    if not tokens:
                        continue
                    # TF for this chunk
                    tf = {}
                    for t in tokens:
                        tf[t] = tf.get(t, 0) + 1
                    self._docs.append((label, i + 1, chunk, tf))
                    # Doc frequency
                    for t in set(tokens):
                        doc_freq[t] = doc_freq.get(t, 0) + 1
            except Exception:
                continue

        # Compute IDF
        n_docs = len(self._docs)
        if n_docs > 0:
            self._idf = {t: math.log(n_docs / (1 + df))
                         for t, df in doc_freq.items()}
        self._built = True
        self._last_index_time = now
        log.info(f"🔍 TF-IDF index built: {len(self._docs)} chunks from {len(search_files)} files")

    def search(self, query: str, max_results: int = 5) -> list:
        """Search with TF-IDF + cosine similarity. Returns [(score, label, lineno, snippet)]."""
        self._index_files()
        if not self._docs:
            return []

        query_tokens = self._tokenize(query)
        if not query_tokens:
            return []

        # Query TF-IDF vector
        query_tf = {}
        for t in query_tokens:
            query_tf[t] = query_tf.get(t, 0) + 1
        query_vec = {t: tf * self._idf.get(t, 0) for t, tf in query_tf.items()}
        query_norm = math.sqrt(sum(v ** 2 for v in query_vec.values()))
        if query_norm == 0:
            return []

        # Score each document
        scored = []
        for label, lineno, chunk, doc_tf in self._docs:
            doc_vec = {t: tf * self._idf.get(t, 0) for t, tf in doc_tf.items()}
            # Cosine similarity
            dot = sum(query_vec.get(t, 0) * doc_vec.get(t, 0)
                      for t in set(query_vec) | set(doc_vec))
            doc_norm = math.sqrt(sum(v ** 2 for v in doc_vec.values()))
            if doc_norm == 0:
                continue
            similarity = dot / (query_norm * doc_norm)
            if similarity > 0.05:  # Threshold
                scored.append((similarity, label, lineno, chunk))

        scored.sort(key=lambda x: -x[0])
        return scored[:max_results]


_tfidf = TFIDFSearch()

# ============================================================
# LLM CRON MANAGER — Scheduled tasks with LLM execution
# ============================================================
class LLMCronManager:
    """Manages LLM-powered scheduled tasks (like OpenClaw cron jobs)."""

    _JOBS_FILE = BASE_DIR / '.cron_jobs.json'

    def __init__(self):
        self.jobs: list[dict] = []

    def load_jobs(self):
        """Load persisted cron jobs from file."""
        try:
            if self._JOBS_FILE.exists():
                self.jobs = json.loads(self._JOBS_FILE.read_text())
                log.info(f"⏰ Loaded {len(self.jobs)} LLM cron jobs")
        except Exception as e:
            log.error(f"Failed to load cron jobs: {e}")
            self.jobs = []

    def save_jobs(self):
        """Persist cron jobs to file."""
        try:
            self._JOBS_FILE.write_text(json.dumps(self.jobs, ensure_ascii=False, indent=2))
        except Exception as e:
            log.error(f"Failed to save cron jobs: {e}")

    def add_job(self, name: str, schedule: dict, prompt: str,
                model: str = None, notify: bool = True) -> dict:
        """Add a new LLM cron job.
        schedule: {'kind': 'cron', 'expr': '0 6 * * *', 'tz': 'Asia/Seoul'}
                  {'kind': 'every', 'seconds': 3600}
                  {'kind': 'at', 'time': '2026-02-18T06:00:00+09:00'}
        """
        import uuid as _uuid
        job = {
            'id': str(_uuid.uuid4())[:8],
            'name': name,
            'schedule': schedule,
            'prompt': prompt,
            'model': model,
            'notify': notify,
            'enabled': True,
            'created': datetime.now(KST).isoformat(),
            'last_run': None,
            'run_count': 0
        }
        self.jobs.append(job)
        self.save_jobs()
        log.info(f"⏰ LLM cron job added: {name} ({job['id']})")
        return job

    def remove_job(self, job_id: str) -> bool:
        before = len(self.jobs)
        self.jobs = [j for j in self.jobs if j['id'] != job_id]
        if len(self.jobs) < before:
            self.save_jobs()
            return True
        return False

    def list_jobs(self) -> list:
        return [{'id': j['id'], 'name': j['name'], 'schedule': j['schedule'],
                 'enabled': j['enabled'], 'last_run': j['last_run'],
                 'run_count': j['run_count']} for j in self.jobs]

    def _should_run(self, job: dict) -> bool:
        """Check if a job should run now."""
        if not job['enabled']:
            return False
        sched = job['schedule']
        now = datetime.now(KST)

        if sched['kind'] == 'every':
            if not job['last_run']:
                return True
            elapsed = (now - datetime.fromisoformat(job['last_run'])).total_seconds()
            return elapsed >= sched['seconds']

        elif sched['kind'] == 'cron':
            # Simple cron: minute hour day month weekday
            expr = sched['expr'].split()
            if len(expr) != 5:
                return False
            checks = [
                (expr[0], now.minute), (expr[1], now.hour),
                (expr[2], now.day), (expr[3], now.month),
                (expr[4], now.weekday())  # 0=Monday
            ]
            for field, val in checks:
                if field == '*':
                    continue
                try:
                    if ',' in field:
                        if val not in [int(x) for x in field.split(',')]:
                            return False
                    elif '-' in field:
                        lo, hi = field.split('-')
                        if not (int(lo) <= val <= int(hi)):
                            return False
                    elif int(field) != val:
                        return False
                except ValueError:
                    return False
            # Don't run twice in same minute
            if job['last_run']:
                last = datetime.fromisoformat(job['last_run'])
                if (now - last).total_seconds() < 60:
                    return False
            return True

        elif sched['kind'] == 'at':
            target = datetime.fromisoformat(sched['time'])
            if job['last_run']:
                return False  # One-shot, already ran
            return now >= target

        return False

    async def tick(self):
        """Check and execute due jobs."""
        for job in self.jobs:
            if not self._should_run(job):
                continue
            log.info(f"⏰ LLM cron firing: {job['name']} ({job['id']})")
            try:
                from .engine import process_message
                response = await process_message(
                    f"cron-{job['id']}", job['prompt'],
                    model_override=job.get('model'))
                job['last_run'] = datetime.now(KST).isoformat()
                job['run_count'] = job.get('run_count', 0) + 1
                self.save_jobs()
                log.info(f"⏰ Cron completed: {job['name']} ({len(response)} chars)")

                # Notify via Telegram
                if job.get('notify') and _tg_bot and _tg_bot.token and _tg_bot.owner_id:
                    summary = response[:800] + ('...' if len(response) > 800 else '')
                    _tg_bot.send_message(
                        _tg_bot.owner_id,
                        f"⏰ SalmAlm scheduled task completed: {job['name']}\n\n{summary}")

                # One-shot jobs: auto-disable
                if job['schedule']['kind'] == 'at':
                    job['enabled'] = False
                    self.save_jobs()

            except Exception as e:
                log.error(f"LLM cron error ({job['name']}): {e}")
                job['last_run'] = datetime.now(KST).isoformat()
                self.save_jobs()


# ============================================================
# PLUGIN LOADER — Auto-load tools from plugins/ directory
class Session:
    def __init__(self, session_id: str):
        self.id = session_id
        self.messages: list = []
        self.created = time.time()
        self.last_active = time.time()

    def add_system(self, content: str):
        # Replace existing system message
        self.messages = [m for m in self.messages if m['role'] != 'system']
        self.messages.insert(0, {'role': 'system', 'content': content})

    def _persist(self):
        """Save session to SQLite (only text messages, skip image data)."""
        try:
            # Filter out large binary data from messages
            saveable = []
            for m in self.messages[-50:]:  # Keep last 50 messages
                if isinstance(m.get('content'), list):
                    # Multimodal — save text parts only
                    texts = [b for b in m['content'] if b.get('type') == 'text']
                    if texts:
                        saveable.append({**m, 'content': texts})
                elif isinstance(m.get('content'), str):
                    saveable.append(m)
            conn = sqlite3.connect(str(AUDIT_DB))
            conn.execute('INSERT OR REPLACE INTO session_store (session_id, messages, updated_at) VALUES (?,?,?)',
                         (self.id, json.dumps(saveable, ensure_ascii=False), datetime.now(KST).isoformat()))
            conn.commit()
            conn.close()
        except Exception as e:
            log.warning(f"Session persist error: {e}")

    def add_user(self, content: str):
        self.messages.append({'role': 'user', 'content': content})
        self.last_active = time.time()

    def add_assistant(self, content: str):
        self.messages.append({'role': 'assistant', 'content': content})
        self._persist()

    def add_tool_results(self, results: list):
        """Add tool results as a single user message with all results.
        results: list of {'tool_use_id': str, 'content': str}
        """
        content = [{'type': 'tool_result', 'tool_use_id': r['tool_use_id'],
                     'content': r['content']} for r in results]
        self.messages.append({'role': 'user', 'content': content})


_tg_bot = None  # Set during startup by telegram module
_sessions: dict[str, Session] = {}


def get_session(session_id: str) -> Session:
    if session_id not in _sessions:
        _sessions[session_id] = Session(session_id)
        # Try to restore from SQLite
        try:
            conn = sqlite3.connect(str(AUDIT_DB))
            row = conn.execute('SELECT messages FROM session_store WHERE session_id=?', (session_id,)).fetchone()
            conn.close()
            if row:
                restored = json.loads(row[0])
                _sessions[session_id].messages = restored
                log.info(f"📋 Session restored: {session_id} ({len(restored)} msgs)")
                # Refresh system prompt
                from .prompt import build_system_prompt
                _sessions[session_id].add_system(build_system_prompt(full=False))
                return _sessions[session_id]
        except Exception as e:
            log.warning(f"Session restore error: {e}")
        from .prompt import build_system_prompt
        _sessions[session_id].add_system(build_system_prompt(full=True))
        log.info(f"📋 New session: {session_id} (system prompt: {len(_sessions[session_id].messages[0]['content'])} chars)")
    return _sessions[session_id]



class CronScheduler:
    """Simple cron-like scheduler."""

    def __init__(self):
        self.jobs: list[dict] = []
        self._running = False

    def add_job(self, name: str, interval_seconds: int, callback, **kwargs):
        self.jobs.append({
            'name': name, 'interval': interval_seconds,
            'callback': callback, 'kwargs': kwargs,
            'last_run': 0, 'enabled': True
        })

    async def run(self):
        self._running = True
        log.info(f"⏰ Cron scheduler started ({len(self.jobs)} jobs)")
        while self._running:
            now = time.time()
            for job in self.jobs:
                if not job['enabled']:
                    continue
                if now - job['last_run'] >= job['interval']:
                    try:
                        log.info(f"⏰ Running cron: {job['name']}")
                        if asyncio.iscoroutinefunction(job['callback']):
                            await job['callback'](**job['kwargs'])
                        else:
                            job['callback'](**job['kwargs'])
                        job['last_run'] = now
                    except Exception as e:
                        log.error(f"Cron error ({job['name']}): {e}")
            await asyncio.sleep(10)

    def stop(self):
        self._running = False


cron = CronScheduler()


# ============================================================
# DAILY MEMORY LOG
# ============================================================
def write_daily_log(entry: str):
    """Append to today's memory log."""
    today = datetime.now(KST).strftime('%Y-%m-%d')
    log_file = MEMORY_DIR / f'{today}.md'
    MEMORY_DIR.mkdir(exist_ok=True)
    header = f'# {today} Daily Log\n\n' if not log_file.exists() else ''
    with open(log_file, 'a', encoding='utf-8') as f:
        ts = datetime.now(KST).strftime('%H:%M')
        f.write(f'{header}- [{ts}] {entry}\n')

# Re-export from agents.py
from .agents import SubAgent, SkillLoader, PluginLoader
