"""Tests for memory safety checkers (MEM001-MEM004)."""

import pytest
from pathlib import Path

from stablestack.checkers.memory import (
    UnboundedQueryChecker,
    UnboundedEagerLoadChecker,
    InProcessAccumulatorChecker,
    GlobalStateSingletonChecker,
)


@pytest.fixture
def ts_file(tmp_path: Path):
    """Create a temp TypeScript file that won't be skipped as a test file."""
    def _create(content: str, name: str = "service.ts") -> Path:
        file_path = tmp_path / name
        file_path.write_text(content)
        return file_path
    return _create


@pytest.fixture
def js_file(tmp_path: Path):
    """Create a temp JavaScript file that won't be skipped as a test file."""
    def _create(content: str, name: str = "service.js") -> Path:
        file_path = tmp_path / name
        file_path.write_text(content)
        return file_path
    return _create


@pytest.fixture
def py_file(tmp_path: Path):
    """Create a temp Python file that won't be skipped as a test file."""
    def _create(content: str, name: str = "service.py") -> Path:
        file_path = tmp_path / name
        file_path.write_text(content)
        return file_path
    return _create


# ============================================================================
# MEM001: Unbounded Query
# ============================================================================


class TestUnboundedQueryChecker:
    """Tests for MEM001: unbounded-query."""

    @pytest.fixture
    def checker(self):
        return UnboundedQueryChecker()

    # ---- Prisma ----

    def test_flags_prisma_findmany_no_take(self, checker, ts_file):
        """Should flag .findMany() without take:."""
        code = '''
const users = await prisma.user.findMany({
  where: { active: true },
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM001"

    def test_allows_prisma_findmany_with_take(self, checker, ts_file):
        """Should not flag .findMany() with take:."""
        code = '''
const users = await prisma.user.findMany({
  where: { active: true },
  take: 100,
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_prisma_findmany_with_cursor(self, checker, ts_file):
        """Should not flag .findMany() with cursor pagination."""
        code = '''
const users = await prisma.user.findMany({
  cursor: { id: lastId },
  take: 50,
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- Mongoose ----

    def test_flags_mongoose_find_no_limit(self, checker, js_file):
        """Should flag .find({}) without .limit()."""
        code = '''
const users = await User.find({});
'''
        path = js_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM001"

    def test_allows_mongoose_find_with_limit(self, checker, js_file):
        """Should not flag .find({}).limit()."""
        code = '''
const users = await User.find({}).limit(100);
'''
        path = js_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- SQLAlchemy ----

    def test_flags_sqlalchemy_query_all(self, checker, py_file):
        """Should flag .query.all() without limit."""
        code = '''
users = User.query.all()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM001"

    def test_flags_sqlalchemy_filter_all(self, checker, py_file):
        """Should flag .filter().all() without limit."""
        code = '''
users = session.query(User).filter(User.active == True).all()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1

    def test_allows_sqlalchemy_with_limit(self, checker, py_file):
        """Should not flag .filter().limit().all()."""
        code = '''
users = session.query(User).filter(User.active == True).limit(100).all()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- Django ----

    def test_flags_django_objects_all(self, checker, py_file):
        """Should flag .objects.all() without slice."""
        code = '''
users = User.objects.all()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM001"

    def test_allows_django_objects_all_sliced(self, checker, py_file):
        """Should not flag .objects.all()[:100]."""
        code = '''
users = User.objects.all()[:100]
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- Sequelize ----

    def test_flags_sequelize_findall_no_limit(self, checker, ts_file):
        """Should flag .findAll() without limit:."""
        code = '''
const users = await User.findAll({
  where: { active: true },
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM001"

    def test_allows_sequelize_findall_with_limit(self, checker, ts_file):
        """Should not flag .findAll() with limit:."""
        code = '''
const users = await User.findAll({
  where: { active: true },
  limit: 100,
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- Drizzle ----

    def test_flags_drizzle_select_no_limit(self, checker, ts_file):
        """Should flag db.select().from() without .limit()."""
        code = '''
const users = await db.select().from(users);
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM001"

    def test_allows_drizzle_select_with_limit(self, checker, ts_file):
        """Should not flag db.select().from().limit()."""
        code = '''
const users = await db.select().from(users).limit(100);
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- Edge cases ----

    def test_skips_comments(self, checker, ts_file):
        """Should not flag patterns inside comments."""
        code = '''
// const users = await prisma.user.findMany({});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_skips_test_files(self, checker, tmp_path):
        """Should not flag test files."""
        code = '''
const users = await prisma.user.findMany({});
'''
        path = tmp_path / "users.test.ts"
        path.write_text(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_count_query(self, checker, ts_file):
        """Should not flag .findMany() followed by .count()."""
        code = '''
const count = await prisma.user.findMany({
  where: { active: true },
}).count();
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_does_not_flag_python_builtin_all(self, checker, py_file):
        """Should not flag Python's builtin all([...])."""
        code = '''
result = all([x > 0 for x in numbers])
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_first(self, checker, ts_file):
        """Should not flag queries with .first()."""
        code = '''
const users = await prisma.user.findMany({
  where: { active: true },
}).first();
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_paginate(self, checker, py_file):
        """Should not flag queries with .paginate()."""
        code = '''
users = User.query.all()
users.paginate(page=1, per_page=20)
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# MEM002: Unbounded Eager Load
# ============================================================================


class TestUnboundedEagerLoadChecker:
    """Tests for MEM002: unbounded-eager-load."""

    @pytest.fixture
    def checker(self):
        return UnboundedEagerLoadChecker()

    # ---- Prisma include ----

    def test_flags_prisma_include_true(self, checker, ts_file):
        """Should flag include: { contacts: true }."""
        code = '''
const user = await prisma.user.findUnique({
  where: { id },
  include: { contacts: true },
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM002"

    def test_allows_prisma_include_with_take(self, checker, ts_file):
        """Should not flag include with take:."""
        code = '''
const user = await prisma.user.findUnique({
  where: { id },
  include: { contacts: { take: 50 } },
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_prisma_include_with_select(self, checker, ts_file):
        """Should not flag include with select:."""
        code = '''
const user = await prisma.user.findUnique({
  where: { id },
  include: { contacts: { select: { id: true, name: true } } },
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_prisma_count(self, checker, ts_file):
        """Should not flag _count: usage."""
        code = '''
const user = await prisma.user.findUnique({
  where: { id },
  include: { _count: { select: { contacts: true } } },
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- Mongoose populate ----

    def test_flags_mongoose_bare_populate(self, checker, js_file):
        """Should flag .populate('relation') with bare string."""
        code = '''
const user = await User.findById(id).populate('contacts');
'''
        path = js_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM002"

    def test_allows_mongoose_populate_with_options(self, checker, js_file):
        """Should not flag .populate({ path: ..., match: ... })."""
        code = '''
const user = await User.findById(id).populate({
  path: 'contacts',
  match: { active: true },
  options: { limit: 50 },
});
'''
        path = js_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- SQLAlchemy eager loading ----

    def test_flags_sqlalchemy_joinedload(self, checker, py_file):
        """Should flag joinedload() without limit."""
        code = '''
from sqlalchemy.orm import joinedload
users = session.query(User).options(joinedload(User.contacts)).all()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM002"

    def test_flags_sqlalchemy_selectinload(self, checker, py_file):
        """Should flag selectinload() without limit."""
        code = '''
from sqlalchemy.orm import selectinload
users = session.query(User).options(selectinload(User.contacts)).all()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1

    def test_allows_sqlalchemy_with_limit(self, checker, py_file):
        """Should not flag joinedload with .limit()."""
        code = '''
from sqlalchemy.orm import joinedload
users = session.query(User).options(joinedload(User.contacts)).limit(100).all()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- TypeORM relations ----

    def test_flags_typeorm_relations_true(self, checker, ts_file):
        """Should flag relations: { contacts: true }."""
        code = '''
const users = await repo.find({
  relations: { contacts: true },
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM002"

    def test_allows_typeorm_relations_with_take(self, checker, ts_file):
        """Should not flag relations with take:."""
        code = '''
const users = await repo.find({
  relations: { contacts: true },
  take: 50,
});
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    # ---- Edge cases ----

    def test_skips_test_files(self, checker, tmp_path):
        """Should not flag test files."""
        code = '''
const user = await prisma.user.findUnique({
  include: { contacts: true },
});
'''
        path = tmp_path / "user.spec.ts"
        path.write_text(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_skips_comments(self, checker, ts_file):
        """Should not flag patterns in comments."""
        code = '''
// include: { contacts: true }
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# MEM003: In-Process Accumulator
# ============================================================================


class TestInProcessAccumulatorCron:
    """Tests for MEM003 Part A: in-process cron in server files."""

    @pytest.fixture
    def checker(self):
        return InProcessAccumulatorChecker()

    def test_flags_setinterval_in_express(self, checker, ts_file):
        """Should flag setInterval in an Express server file."""
        code = '''
import express from 'express';
const app = express();

setInterval(() => {
  syncData();
}, 60000);

app.listen(3000);
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM003"
        assert "scheduler" in findings[0].message.lower() or "accumulate" in findings[0].message.lower()

    def test_flags_cron_schedule_in_server(self, checker, js_file):
        """Should flag cron.schedule in a server file."""
        code = '''
const express = require('express');
const cron = require('node-cron');
const app = express();

cron.schedule('*/5 * * * *', () => {
  processQueue();
});

app.listen(3000);
'''
        path = js_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) >= 1
        assert any(f.rule_id == "MEM003" for f in findings)

    def test_flags_apscheduler_in_fastapi(self, checker, py_file):
        """Should flag APScheduler in a FastAPI file."""
        code = '''
from fastapi import FastAPI
from apscheduler.schedulers.background import BackgroundScheduler

app = FastAPI()
scheduler = BackgroundScheduler()

@app.on_event("startup")
def start_scheduler():
    scheduler.add_job(sync_data, 'interval', minutes=5)
    scheduler.start()
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) >= 1
        assert any(f.rule_id == "MEM003" for f in findings)

    def test_allows_setinterval_with_clearinterval(self, checker, ts_file):
        """Should not flag setInterval when clearInterval exists (cleanup)."""
        code = '''
import express from 'express';
const app = express();

const interval = setInterval(() => {
  syncData();
}, 60000);

process.on('SIGTERM', () => {
  clearInterval(interval);
});

app.listen(3000);
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_health_check_interval(self, checker, ts_file):
        """Should not flag setInterval for health checks."""
        code = '''
import express from 'express';
const app = express();

setInterval(healthCheck, 30000);

app.listen(3000);
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_skips_non_server_files(self, checker, ts_file):
        """Should not flag setInterval in non-server files."""
        code = '''
setInterval(() => {
  syncData();
}, 60000);
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        cron_findings = [f for f in findings if "scheduler" in f.message.lower() or "accumulate" in f.message.lower()]
        assert len(cron_findings) == 0

    def test_flags_nextjs_route_with_setinterval(self, checker, tmp_path):
        """Should flag setInterval in a Next.js API route."""
        code = '''
export async function GET(request: Request) {
  return Response.json({ ok: true });
}

setInterval(() => {
  refreshCache();
}, 60000);
'''
        path = tmp_path / "api" / "route.ts"
        path.parent.mkdir(parents=True)
        path.write_text(code)
        findings = checker.check_file(path, code)
        assert len(findings) >= 1
        assert any(f.rule_id == "MEM003" for f in findings)


class TestInProcessAccumulatorCache:
    """Tests for MEM003 Part B: unbounded global cache."""

    @pytest.fixture
    def checker(self):
        return InProcessAccumulatorChecker()

    def test_flags_js_global_map_with_writes(self, checker, ts_file):
        """Should flag const cache = new Map() with .set() and no cleanup."""
        code = '''
const cache = new Map();

function getUser(id: string) {
  if (cache.has(id)) return cache.get(id);
  const user = fetchUser(id);
  cache.set(id, user);
  return user;
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM003"
        assert "cache" in findings[0].message.lower()

    def test_flags_js_global_set_with_writes(self, checker, ts_file):
        """Should flag const seen = new Set() with .add() and no cleanup."""
        code = '''
const seen = new Set();

function processEvent(event: Event) {
  if (seen.has(event.id)) return;
  seen.add(event.id);
  handle(event);
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM003"

    def test_flags_python_global_dict_with_writes(self, checker, py_file):
        """Should flag module-level cache = {} with writes and no cleanup."""
        code = '''cache = {}

def get_user(user_id):
    if user_id in cache:
        return cache[user_id]
    user = fetch_user(user_id)
    cache[user_id] = user
    return user
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM003"

    def test_allows_map_with_delete(self, checker, ts_file):
        """Should not flag Map with .delete() (cleanup exists)."""
        code = '''
const cache = new Map();

function getUser(id: string) {
  cache.set(id, fetchUser(id));
}

function evict(id: string) {
  cache.delete(id);
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_map_with_clear(self, checker, ts_file):
        """Should not flag Map with .clear() (cleanup exists)."""
        code = '''
const cache = new Map();

function getUser(id: string) {
  cache.set(id, fetchUser(id));
}

setInterval(() => cache.clear(), 60000);
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_lru_cache(self, checker, py_file):
        """Should not flag functools.lru_cache."""
        code = '''from functools import lru_cache

@lru_cache(maxsize=128)
def get_user(user_id):
    return fetch_user(user_id)
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_ttl_cache(self, checker, py_file):
        """Should not flag TTLCache."""
        code = '''from cachetools import TTLCache
cache = TTLCache(maxsize=500, ttl=300)

def get_user(user_id):
    if user_id in cache:
        return cache[user_id]
    user = fetch_user(user_id)
    cache[user_id] = user
    return user
'''
        path = py_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_weakmap(self, checker, ts_file):
        """Should not flag WeakMap (auto-GC)."""
        code = '''
const cache = new WeakMap();

function associate(obj, data) {
  cache.set(obj, data);
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_allows_map_with_maxsize(self, checker, ts_file):
        """Should not flag if maxSize token exists in file."""
        code = '''
const cache = new Map();
const maxSize = 1000;

function getUser(id: string) {
  if (cache.size >= maxSize) cache.clear();
  cache.set(id, fetchUser(id));
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_skips_indented_map(self, checker, ts_file):
        """Should not flag Map declared inside a function (not module-level)."""
        code = '''
function handler() {
    const cache = new Map();
    cache.set('key', 'value');
    return cache;
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_no_flag_without_writes(self, checker, ts_file):
        """Should not flag Map with no .set()/.add() calls."""
        code = '''
const lookup = new Map();

function check(id: string) {
  return lookup.has(id);
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0


# ============================================================================
# MEM004: Global State Singleton
# ============================================================================


class TestGlobalStateSingletonChecker:
    """Tests for MEM004: global-state-singleton."""

    @pytest.fixture
    def checker(self):
        return GlobalStateSingletonChecker()

    def test_flags_globalthis_cast_pattern(self, checker, ts_file):
        """Should flag `const x = globalThis as unknown as { __something }`."""
        code = '''
class JobRegistry {
  private jobs = new Map<string, RegisteredJob>();
}

const globalForJobs = globalThis as unknown as { __jobRegistry?: JobRegistry };
export const jobRegistry = globalForJobs.__jobRegistry ?? (globalForJobs.__jobRegistry = new JobRegistry());
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM004"
        assert "globalThis" in findings[0].message

    def test_flags_direct_globalthis_assignment(self, checker, ts_file):
        """Should flag `(globalThis as any).__something = ...`."""
        code = '''
(globalThis as any).__myCounter = 0;

function increment() {
  (globalThis as any).__myCounter += 1;
}
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) >= 1
        assert all(f.rule_id == "MEM004" for f in findings)

    def test_flags_node_global_assignment(self, checker, ts_file):
        """Should flag `global.__something = ...`."""
        code = '''
global.__appState = { users: [] };
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM004"

    def test_skips_prisma_globalforprisma(self, checker, ts_file):
        """Should NOT flag Prisma's globalForPrisma — it's a standard HMR workaround."""
        code = '''
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_skips_test_files(self, checker, tmp_path):
        """Should not flag test files."""
        code = '''
const g = globalThis as unknown as { __testRegistry: Map<string, unknown> };
g.__testRegistry = new Map();
'''
        for ext in [".test.ts", ".spec.ts", ".test.js", ".spec.js"]:
            path = tmp_path / f"job-registry{ext}"
            path.write_text(code)
            findings = checker.check_file(path, code)
            assert len(findings) == 0, f"Should skip {ext}"

    def test_skips_comments(self, checker, ts_file):
        """Should not flag patterns in comments."""
        code = '''
// const g = globalThis as unknown as { __something: any };
/* (globalThis as any).__counter = 0; */
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 0

    def test_flags_globalthis_cast_with_typed_generic(self, checker, ts_file):
        """Should flag more complex globalThis cast patterns."""
        code = '''
const _global = globalThis as unknown as {
  __tokenManager?: TokenManager;
  __healthChecker?: HealthChecker;
};

export const tokenManager = _global.__tokenManager ?? (_global.__tokenManager = new TokenManager());
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM004"

    def test_flags_node_global_with_cast(self, checker, ts_file):
        """Should flag (global as any).__something = ..."""
        code = '''
(global as any).__appCache = new Map();
'''
        path = ts_file(code)
        findings = checker.check_file(path, code)
        assert len(findings) == 1
        assert findings[0].rule_id == "MEM004"
