import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync as fsWrite, mkdirSync as fsMkdir } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { runMigrations } from '../db/migrate.js';
import { NotesRepository } from './notes-repository.js';
import { NotesService } from './notes-service.js';

describe('NotesService.writeNote', () => {
  let tmpRoot: string;
  let db: Database.Database;
  let service: NotesService;

  beforeEach(() => {
    tmpRoot = mkdtempSync(join(tmpdir(), 'notes-svc-test-'));
    db = new Database(join(tmpRoot, 'test.db'));
    runMigrations(db);
    db.prepare(`INSERT INTO users (id, email) VALUES ('alice','alice@x.com')`).run();
    const repo = new NotesRepository(db);
    service = new NotesService({ db, repo, userFolderRoot: tmpRoot, getUserOrgIds: () => ['team1'] });
  });

  afterEach(() => {
    db.close();
    rmSync(tmpRoot, { recursive: true, force: true });
  });

  it('writes file to disk and upserts note_index', () => {
    const md = `---
title: T
visibility: public
---
body content`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md });
    const filePath = join(tmpRoot, 'alice', 'notes', 'cve', 'foo.md');
    expect(existsSync(filePath)).toBe(true);
    expect(readFileSync(filePath, 'utf-8')).toContain('body content');
    const row = db.prepare(`SELECT * FROM note_index WHERE owner_id='alice'`).get() as any;
    expect(row.visibility).toBe('public');
    expect(row.title).toBe('T');
  });

  it('auto-creates self subscription for new folder (search mode default)', () => {
    const md = `---
visibility: private
---
body`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md });
    const sub: any = db.prepare(`SELECT * FROM note_subscriptions WHERE consumer_user_id='alice' AND publisher_user_id='alice' AND folder='cve'`).get();
    expect(sub).toBeDefined();
    expect(sub.mode).toBe('search');
    expect(sub.enabled).toBe(1);
  });

  it('does not re-create subscription on second write to same folder', () => {
    const md1 = `---
visibility: private
---
body 1`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'a.md', content: md1 });
    // Pretend user changed mode to inject
    db.prepare(`UPDATE note_subscriptions SET mode='inject' WHERE consumer_user_id='alice'`).run();
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'b.md', content: md1 });
    const sub: any = db.prepare(`SELECT * FROM note_subscriptions WHERE consumer_user_id='alice'`).get();
    expect(sub.mode).toBe('inject'); // preserved
  });

  it('rejects org visibility when scope_org_id is not in publisher orgs', () => {
    const md = `---
visibility: org
scope_org_id: not-my-team
---
body`;
    expect(() => service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md })).toThrow(/scope_org_id/);
  });

  it('accepts org visibility when scope_org_id is in publisher orgs', () => {
    const md = `---
visibility: org
scope_org_id: team1
---
body`;
    expect(() => service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md })).not.toThrow();
  });

  it('rejects invalid folder/file name (path traversal)', () => {
    expect(() => service.writeNote({ ownerId: 'alice', folder: '../etc', fileName: 'passwd', content: 'x' })).toThrow();
    expect(() => service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: '../foo.md', content: 'x' })).toThrow();
    expect(() => service.writeNote({ ownerId: 'alice', folder: 'cve/sub', fileName: 'x.md', content: 'x' })).toThrow();
  });

  it('rejects folder/file name with invalid characters', () => {
    expect(() => service.writeNote({ ownerId: 'alice', folder: 'cve!', fileName: 'x.md', content: 'x' })).toThrow();
    expect(() => service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'x.md.bad name', content: 'x' })).toThrow();
  });

  it('rejects invalid ownerId (path traversal)', () => {
    const md = `---
visibility: private
---
body`;
    expect(() => service.writeNote({ ownerId: '../etc', folder: 'cve', fileName: 'foo.md', content: md })).toThrow(/ownerId|invalid/);
    expect(() => service.writeNote({ ownerId: 'has/slash', folder: 'cve', fileName: 'foo.md', content: md })).toThrow(/ownerId|invalid/);
  });

  it('defaults visibility to private when FM absent', () => {
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: 'plain body' });
    const row: any = db.prepare(`SELECT visibility FROM note_index WHERE owner_id='alice'`).get();
    expect(row.visibility).toBe('private');
  });
});

describe('NotesService.deleteNote', () => {
  let tmpRoot: string;
  let db: Database.Database;
  let service: NotesService;

  beforeEach(() => {
    tmpRoot = mkdtempSync(join(tmpdir(), 'notes-svc-test-'));
    db = new Database(join(tmpRoot, 'test.db'));
    runMigrations(db);
    db.prepare(`INSERT INTO users (id, email) VALUES ('alice','alice@x.com')`).run();
    const repo = new NotesRepository(db);
    service = new NotesService({ db, repo, userFolderRoot: tmpRoot, getUserOrgIds: () => ['team1'] });
  });

  afterEach(() => {
    db.close();
    rmSync(tmpRoot, { recursive: true, force: true });
  });

  it('soft-deletes file and removes index row', () => {
    const md = `---
visibility: private
---
body`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md });
    service.deleteNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md' });
    expect(db.prepare(`SELECT * FROM note_index WHERE owner_id='alice'`).get()).toBeUndefined();
    // file moved to trash
    const filePath = join(tmpRoot, 'alice', 'notes', 'cve', 'foo.md');
    expect(existsSync(filePath)).toBe(false);
  });
});

describe('NotesService.discoverNotes / getCrossUserNote', () => {
  let tmpRoot: string;
  let db: Database.Database;
  let service: NotesService;
  let auditCalls: Array<{ action: string; actor: string; target: string }>;

  beforeEach(() => {
    tmpRoot = mkdtempSync(join(tmpdir(), 'notes-svc-cross-test-'));
    db = new Database(join(tmpRoot, 'test.db'));
    runMigrations(db);
    db.prepare(`INSERT INTO users (id, email) VALUES ('alice','alice@x.com'),('bob','bob@x.com')`).run();
    const repo = new NotesRepository(db);
    auditCalls = [];
    service = new NotesService({
      db, repo, userFolderRoot: tmpRoot,
      getUserOrgIds: () => ['team1'],
      audit: (action, actor, target) => auditCalls.push({ action, actor, target }),
    });
  });

  afterEach(() => {
    db.close();
    rmSync(tmpRoot, { recursive: true, force: true });
  });

  it('discoverNotes returns visible notes only', () => {
    const md = `---
visibility: public
---
body`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md });
    const rows = service.discoverNotes({ user: { id: 'bob', role: 'user', orgIds: [] } as any });
    expect(rows.map((r) => r.file_name)).toEqual(['foo.md']);
  });

  it('discoverNotes supports keyword via FTS', () => {
    const md = `---
title: kubernetes pod crash
visibility: public
---
body`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md });
    const rows = service.discoverNotes({ user: { id: 'bob', role: 'user', orgIds: [] } as any, q: 'kubernetes' });
    expect(rows).toHaveLength(1);
    const none = service.discoverNotes({ user: { id: 'bob', role: 'user', orgIds: [] } as any, q: 'unmatched' });
    expect(none).toHaveLength(0);
  });

  it('discoverNotes does not crash on FTS special characters (injection guard)', () => {
    const md = `---
title: a normal note
visibility: public
---
safe content`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'safe.md', content: md });
    // These strings would crash or return wrong results if passed raw to FTS5 MATCH
    const dangerous = [
      'foo "bar" OR baz',
      'OR',
      'AND NOT',
      '"unclosed',
      'a* OR b* AND',
    ];
    for (const q of dangerous) {
      expect(() =>
        service.discoverNotes({ user: { id: 'bob', role: 'user', orgIds: [] } as any, q })
      ).not.toThrow();
    }
  });

  it('getCrossUserNote logs audit and returns file', () => {
    const md = `---
visibility: public
---
body`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md });
    const out = service.getCrossUserNote({
      user: { id: 'bob', role: 'user', orgIds: [] } as any,
      ownerId: 'alice', folder: 'cve', fileName: 'foo.md',
    });
    expect(out).not.toBeNull();
    expect(out!.body).toContain('body');
    expect(auditCalls).toHaveLength(1);
    expect(auditCalls[0]).toMatchObject({ action: 'read_note', actor: 'bob', target: 'alice/cve/foo.md' });
  });

  it('getCrossUserNote returns null and no audit if not visible', () => {
    const md = `---
visibility: private
---
body`;
    service.writeNote({ ownerId: 'alice', folder: 'cve', fileName: 'foo.md', content: md });
    const out = service.getCrossUserNote({
      user: { id: 'bob', role: 'user', orgIds: [] } as any,
      ownerId: 'alice', folder: 'cve', fileName: 'foo.md',
    });
    expect(out).toBeNull();
    expect(auditCalls).toHaveLength(0);
  });

  it('getCrossUserNote finds note beyond LIMIT 50 (pagination bug fix)', () => {
    // Create 52 public notes so the 51st and 52nd exceed discover()'s implicit LIMIT 50
    const md = (i: number) => `---
title: note-${i}
visibility: public
---
body of note ${i}`;
    for (let i = 1; i <= 52; i++) {
      service.writeNote({
        ownerId: 'alice',
        folder: 'many',
        fileName: `note-${String(i).padStart(3, '0')}.md`,
        content: md(i),
      });
    }
    // The 51st note (note-051.md) would be invisible via the old discover() LIMIT 50 path
    const out = service.getCrossUserNote({
      user: { id: 'bob', role: 'user', orgIds: [] } as any,
      ownerId: 'alice',
      folder: 'many',
      fileName: 'note-051.md',
    });
    expect(out).not.toBeNull();
    expect(out!.body).toContain('body of note 51');
    expect(auditCalls.some((a) => a.target === 'alice/many/note-051.md')).toBe(true);
  });
});

describe('NotesService.reindex', () => {
  let tmpRoot: string;
  let db: Database.Database;
  let service: NotesService;

  beforeEach(() => {
    tmpRoot = mkdtempSync(join(tmpdir(), 'notes-svc-reindex-test-'));
    db = new Database(join(tmpRoot, 'test.db'));
    runMigrations(db);
    db.prepare(`INSERT INTO users (id, email) VALUES ('alice','alice@x.com')`).run();
    const repo = new NotesRepository(db);
    service = new NotesService({ db, repo, userFolderRoot: tmpRoot, getUserOrgIds: () => ['team1'] });
  });

  afterEach(() => {
    db.close();
    rmSync(tmpRoot, { recursive: true, force: true });
  });

  it('walks disk and creates note_index rows', () => {
    const dir = join(tmpRoot, 'alice', 'notes', 'cve');
    fsMkdir(dir, { recursive: true });
    fsWrite(join(dir, 'foo.md'), `---
visibility: public
---
body of foo`);
    fsWrite(join(dir, 'bar.md'), `---
visibility: private
---
body of bar`);
    const stats = service.reindex('alice');
    expect(stats.indexed).toBe(2);
    const rows = db.prepare(`SELECT file_name FROM note_index WHERE owner_id='alice' ORDER BY file_name`).all() as any[];
    expect(rows.map((r) => r.file_name)).toEqual(['bar.md', 'foo.md']);
  });

  it('removes stale rows whose files no longer exist', () => {
    const dir = join(tmpRoot, 'alice', 'notes', 'cve');
    fsMkdir(dir, { recursive: true });
    fsWrite(join(dir, 'foo.md'), `---
visibility: public
---
body`);
    service.reindex('alice');
    // Delete file but leave DB row
    const fs = require('fs');
    fs.unlinkSync(join(dir, 'foo.md'));
    const stats = service.reindex('alice');
    expect(stats.removed).toBe(1);
    expect(db.prepare(`SELECT COUNT(*) c FROM note_index WHERE owner_id='alice'`).get()).toMatchObject({ c: 0 });
  });

  it('reindex creates self-subscriptions for folders that did not have them', () => {
    const dir = join(tmpRoot, 'alice', 'notes', 'runbooks');
    fsMkdir(dir, { recursive: true });
    fsWrite(join(dir, 'failover.md'), `---
visibility: private
---
failover body`);
    // No subscriptions exist yet
    expect(db.prepare(`SELECT COUNT(*) c FROM note_subscriptions WHERE consumer_user_id='alice'`).get()).toMatchObject({ c: 0 });
    service.reindex('alice');
    const sub: any = db.prepare(`SELECT * FROM note_subscriptions WHERE consumer_user_id='alice' AND publisher_user_id='alice' AND folder='runbooks'`).get();
    expect(sub).toBeDefined();
    expect(sub.mode).toBe('search');
    expect(sub.enabled).toBe(1);
  });

  it('reindex does not overwrite existing self-subscription mode', () => {
    const dir = join(tmpRoot, 'alice', 'notes', 'tips');
    fsMkdir(dir, { recursive: true });
    fsWrite(join(dir, 'a.md'), `---\nvisibility: private\n---\nbody`);
    service.reindex('alice');
    // Change mode to inject
    db.prepare(`UPDATE note_subscriptions SET mode='inject' WHERE consumer_user_id='alice' AND folder='tips'`).run();
    service.reindex('alice');
    const sub: any = db.prepare(`SELECT mode FROM note_subscriptions WHERE consumer_user_id='alice' AND folder='tips'`).get();
    expect(sub.mode).toBe('inject'); // preserved — upsertSubscription only creates if missing
  });

  it('clears pending_reindex entries for the owner', () => {
    const dir = join(tmpRoot, 'alice', 'notes', 'cve');
    fsMkdir(dir, { recursive: true });
    fsWrite(join(dir, 'foo.md'), `---
visibility: public
---
body`);
    db.prepare(`INSERT INTO pending_reindex (owner_id, folder, file_name, reason, created_at) VALUES ('alice','cve','foo.md','test',1)`).run();
    service.reindex('alice');
    expect(db.prepare(`SELECT COUNT(*) c FROM pending_reindex WHERE owner_id='alice'`).get()).toMatchObject({ c: 0 });
  });
});
