import type Database from 'better-sqlite3';
import { mkdirSync, writeFileSync, readFileSync, renameSync, existsSync, readdirSync, statSync } from 'fs';
import { join, resolve, relative, isAbsolute } from 'path';
import { createHash } from 'crypto';
import { parseNoteFm, NoteFm } from './fm.js';
import { NotesRepository, NoteIndexRow } from './notes-repository.js';
import { userRoot } from '../user-folder/paths.js';

export interface NotesServiceDeps {
  db: Database.Database;
  repo: NotesRepository;
  userFolderRoot: string;
  getUserOrgIds: (userId: string) => string[];
  audit?: (action: string, actor: string, target: string) => void;
  injectConfig?: { perNoteMaxKb?: number; totalMaxKb?: number };
}

export interface WriteNoteArgs {
  ownerId: string;
  folder: string;
  fileName: string;
  content: string;
}

export interface DeleteNoteArgs {
  ownerId: string;
  folder: string;
  fileName: string;
}

/** Allowed characters for folder and file name components. */
const NAME_RE = /^[a-zA-Z0-9._-]+$/;

function validateName(name: string, kind: 'folder' | 'file_name'): void {
  if (!name || !NAME_RE.test(name)) {
    throw new Error(`invalid ${kind}: ${JSON.stringify(name)} (allowed characters: [a-zA-Z0-9._-])`);
  }
  if (name === '.' || name === '..' || name.startsWith('.')) {
    throw new Error(`invalid ${kind}: ${JSON.stringify(name)} (dotfile or path traversal)`);
  }
}

export class NotesService {
  constructor(private readonly deps: NotesServiceDeps) {}

  /**
   * Resolve and validate the full path to a note.
   * Enforces depth-2: {userFolderRoot}/{ownerId}/notes/<folder>/<file>.
   * Throws on path traversal or wrong depth.
   */
  private notePath(ownerId: string, folder: string, fileName: string): string {
    const base = resolve(userRoot(this.deps.userFolderRoot, ownerId), 'notes');
    const full = resolve(base, folder, fileName);
    const rel = relative(base, full);
    if (rel.startsWith('..') || isAbsolute(rel) || rel.split(/[\\/]/).length !== 2) {
      throw new Error('path traversal or wrong depth: notes structure must be notes/<folder>/<file>');
    }
    return full;
  }

  /**
   * Write a Markdown note. Validates name, FM, and org membership.
   * Atomically writes to disk (.tmp → rename), upserts note_index,
   * and auto-creates a self subscription for new folders.
   */
  writeNote(args: WriteNoteArgs): { path: string; row: NoteIndexRow } {
    validateName(args.folder, 'folder');
    validateName(args.fileName, 'file_name');
    if (!args.fileName.endsWith('.md')) {
      throw new Error('file_name must end with .md');
    }

    // Parse and validate frontmatter
    const parsed = parseNoteFm(args.content);

    // For org visibility, assert scope_org_id is in publisher's orgs
    if (parsed.fm.visibility === 'org') {
      const orgs = this.deps.getUserOrgIds(args.ownerId);
      if (!parsed.fm.scope_org_id || !orgs.includes(parsed.fm.scope_org_id)) {
        throw new Error(
          `scope_org_id ${JSON.stringify(parsed.fm.scope_org_id)} is not in publisher's orgs (${orgs.join(', ') || 'none'})`
        );
      }
    }

    // Resolve full path (throws on traversal / wrong depth)
    const fullPath = this.notePath(args.ownerId, args.folder, args.fileName);
    const dir = resolve(userRoot(this.deps.userFolderRoot, args.ownerId), 'notes', args.folder);
    mkdirSync(dir, { recursive: true, mode: 0o700 });

    // Atomic write: write to .tmp then rename
    const tmpPath = `${fullPath}.tmp`;
    writeFileSync(tmpPath, args.content, { encoding: 'utf-8', mode: 0o600 });
    renameSync(tmpPath, fullPath);

    // Compute content metrics
    const contentBuf = Buffer.from(args.content, 'utf-8');
    const row: NoteIndexRow = {
      owner_id: args.ownerId,
      folder: args.folder,
      file_name: args.fileName,
      title: parsed.fm.title,
      visibility: parsed.fm.visibility,
      visibility_scope_org_id: parsed.fm.scope_org_id,
      mode_hint: parsed.fm.mode_hint,
      tags_json: JSON.stringify(parsed.fm.tags),
      content_size: contentBuf.byteLength,
      content_hash: createHash('sha256').update(contentBuf).digest('hex'),
      body: parsed.body,
      updated_at: Date.now(),
    };

    try {
      this.deps.repo.upsertNote(row);
    } catch (err) {
      this.deps.repo.addPendingReindex(
        args.ownerId,
        args.folder,
        args.fileName,
        `upsert failed: ${(err as Error).message}`
      );
      throw err;
    }

    // Auto-create self subscription for new folder (search mode, enabled)
    const existing = this.deps.db
      .prepare(
        `SELECT 1 FROM note_subscriptions WHERE consumer_user_id=? AND publisher_user_id=? AND folder=?`
      )
      .get(args.ownerId, args.ownerId, args.folder);
    if (!existing) {
      this.deps.repo.upsertSubscription({
        consumer_user_id: args.ownerId,
        publisher_user_id: args.ownerId,
        folder: args.folder,
        mode: 'search',
        enabled: 1,
        created_at: Date.now(),
      });
    }

    return { path: fullPath, row };
  }

  /**
   * Read a note from disk, parsing its frontmatter.
   * Returns null if the file does not exist.
   */
  readNote(
    ownerId: string,
    folder: string,
    fileName: string
  ): { fm: NoteFm; body: string; content: string } | null {
    const fullPath = this.notePath(ownerId, folder, fileName);
    if (!existsSync(fullPath)) return null;
    const content = readFileSync(fullPath, 'utf-8');
    const parsed = parseNoteFm(content);
    return { fm: parsed.fm, body: parsed.body, content };
  }

  /**
   * Discover notes visible to `user`. If `q` is provided, uses FTS5 full-text
   * search (without subscription restriction); otherwise falls back to a
   * visibility-filtered listing via `repo.discover()`.
   */
  discoverNotes(opts: {
    user: Express.User;
    ownerId?: string;
    folder?: string;
    q?: string;
    limit?: number;
    offset?: number;
  }): NoteIndexRow[] {
    if (opts.q && opts.q.trim().length > 0) {
      // Wrap in FTS5 phrase quotes to prevent query-syntax injection from raw REST API input
      const q = '"' + String(opts.q).replace(/"/g, '""') + '"';
      return this.deps.repo.search({
        user: opts.user,
        query: q,
        folder: opts.folder,
        limit: opts.limit,
        // consumerSubscribed omitted (false) — browse use-case, not agent search
      });
    }
    return this.deps.repo.discover({
      user: opts.user,
      ownerId: opts.ownerId,
      folder: opts.folder,
      limit: opts.limit,
      offset: opts.offset,
    });
  }

  /**
   * Read a note owned by another user, subject to visibility check.
   * Returns null if the note does not exist or is not visible to `user`.
   * Fires the `audit` callback on successful reads.
   */
  getCrossUserNote(opts: {
    user: Express.User;
    ownerId: string;
    folder: string;
    fileName: string;
  }): { fm: NoteFm; body: string; content: string } | null {
    // Confirm the index row exists at all
    const row = this.deps.repo.getNote(opts.ownerId, opts.folder, opts.fileName);
    if (!row) return null;
    // Visibility check: direct single-row query (no LIMIT, avoids pagination-based false 404s)
    if (!this.deps.repo.isVisible(opts.user, opts.ownerId, opts.folder, opts.fileName)) return null;
    const out = this.readNote(opts.ownerId, opts.folder, opts.fileName);
    if (out && this.deps.audit) {
      this.deps.audit('read_note', opts.user.id, `${opts.ownerId}/${opts.folder}/${opts.fileName}`);
    }
    return out;
  }

  /**
   * Rebuild the note_index for `ownerId` from disk.
   * Walks {userFolderRoot}/{ownerId}/notes/{folder}/*.md, upserts valid rows,
   * removes stale index rows whose files no longer exist, then clears
   * pending_reindex for this owner.
   * Does NOT validate scope_org_id against orgs — this is a recovery operation.
   */
  reindex(ownerId: string): { indexed: number; removed: number } {
    const root = resolve(userRoot(this.deps.userFolderRoot, ownerId), 'notes');
    let indexed = 0;
    let removed = 0;
    const seenKeys = new Set<string>();

    if (existsSync(root)) {
      for (const folder of readdirSync(root)) {
        const folderPath = join(root, folder);
        if (!statSync(folderPath).isDirectory()) continue;
        if (!NAME_RE.test(folder) || folder === '.' || folder === '..' || folder.startsWith('.')) continue;
        for (const fileName of readdirSync(folderPath)) {
          if (!fileName.endsWith('.md')) continue;
          if (!NAME_RE.test(fileName) || fileName.startsWith('.')) continue;
          const full = join(folderPath, fileName);
          try {
            const content = readFileSync(full, 'utf-8');
            const parsed = parseNoteFm(content);
            const contentBuf = Buffer.from(content, 'utf-8');
            const row: NoteIndexRow = {
              owner_id: ownerId,
              folder,
              file_name: fileName,
              title: parsed.fm.title,
              visibility: parsed.fm.visibility,
              visibility_scope_org_id: parsed.fm.scope_org_id,
              mode_hint: parsed.fm.mode_hint,
              tags_json: JSON.stringify(parsed.fm.tags),
              content_size: contentBuf.byteLength,
              content_hash: createHash('sha256').update(contentBuf).digest('hex'),
              body: parsed.body,
              updated_at: Date.now(),
            };
            this.deps.repo.upsertNote(row);
            indexed++;
            seenKeys.add(`${folder}/${fileName}`);

            // Auto-recreate missing self-subscription for this folder
            const existingSub = this.deps.db
              .prepare(`SELECT 1 FROM note_subscriptions WHERE consumer_user_id=? AND publisher_user_id=? AND folder=?`)
              .get(ownerId, ownerId, folder);
            if (!existingSub) {
              this.deps.repo.upsertSubscription({
                consumer_user_id: ownerId,
                publisher_user_id: ownerId,
                folder,
                mode: 'search',
                enabled: 1,
                created_at: Date.now(),
              });
            }
          } catch (err) {
            this.deps.repo.addPendingReindex(ownerId, folder, fileName, `parse: ${(err as Error).message}`);
          }
        }
      }
    }

    // Remove stale index rows whose files were not seen on disk
    const existing = this.deps.db
      .prepare(`SELECT folder, file_name FROM note_index WHERE owner_id=?`)
      .all(ownerId) as Array<{ folder: string; file_name: string }>;
    for (const r of existing) {
      if (!seenKeys.has(`${r.folder}/${r.file_name}`)) {
        this.deps.repo.deleteNote(ownerId, r.folder, r.file_name);
        removed++;
      }
    }

    // Clear all pending_reindex entries for this owner
    this.deps.db.prepare(`DELETE FROM pending_reindex WHERE owner_id=?`).run(ownerId);

    return { indexed, removed };
  }

  /**
   * Search notes via FTS5, restricted to the consumer's search-mode subscriptions
   * when consumerSubscribed is true. Thin pass-through to repo.search().
   */
  searchNotes(opts: {
    user: Express.User;
    query: string;
    folder?: string;
    limit?: number;
    consumerSubscribed?: boolean;
  }) {
    return this.deps.repo.search(opts);
  }

  listSubscriptions(consumerUserId: string) {
    return this.deps.repo.listSubscriptions(consumerUserId);
  }

  upsertSubscription(opts: { consumerUser: Express.User; publisherUserId: string; folder: string; mode: 'search' | 'inject'; enabled: 0 | 1 }) {
    // Verify the consumer can see at least 1 note in the folder
    const visible = this.deps.repo.discover({ user: opts.consumerUser, ownerId: opts.publisherUserId, folder: opts.folder, limit: 1 });
    if (visible.length === 0) {
      throw new Error('no visible notes in this publisher/folder for consumer');
    }
    this.deps.repo.upsertSubscription({
      consumer_user_id: opts.consumerUser.id,
      publisher_user_id: opts.publisherUserId,
      folder: opts.folder,
      mode: opts.mode,
      enabled: opts.enabled,
      created_at: Date.now(),
    });
  }

  deleteSubscription(opts: { consumerUserId: string; publisherUserId: string; folder: string }) {
    this.deps.repo.deleteSubscription(opts.consumerUserId, opts.publisherUserId, opts.folder);
  }

  listInjectableForConsumer(user: Express.User) {
    return this.deps.repo.injectableForConsumer(user);
  }

  /**
   * Preview what would be injected into the system prompt for `user`.
   * Returns filtered list of inject-subscribed notes that fit within
   * per-note and total budget limits.
   */
  injectPreview(user: Express.User): {
    items: Array<{ owner_id: string; publisher_name: string | null; folder: string; file_name: string; size_kb: number }>;
    total_kb: number;
    budget_kb: number;
    per_note_max_kb: number;
  } {
    const rows = this.deps.repo.injectableForConsumer(user);
    const perNoteMaxKb = this.deps.injectConfig?.perNoteMaxKb ?? 8;
    const totalMaxKb = this.deps.injectConfig?.totalMaxKb ?? 32;
    let totalBytes = 0;
    const items: Array<{ owner_id: string; publisher_name: string | null; folder: string; file_name: string; size_kb: number }> = [];
    for (const row of rows) {
      const kb = row.content_size / 1024;
      if (kb > perNoteMaxKb) continue;
      if ((totalBytes + row.content_size) / 1024 > totalMaxKb) break;
      totalBytes += row.content_size;
      items.push({
        owner_id: row.owner_id,
        publisher_name: row.publisher_name,
        folder: row.folder,
        file_name: row.file_name,
        size_kb: +kb.toFixed(2),
      });
    }
    return {
      items,
      total_kb: +(totalBytes / 1024).toFixed(2),
      budget_kb: totalMaxKb,
      per_note_max_kb: perNoteMaxKb,
    };
  }

  /**
   * Soft-delete a note: move the file to the user's trash directory (with
   * timestamp prefix), then remove its row from note_index.
   */
  deleteNote(args: DeleteNoteArgs): void {
    validateName(args.folder, 'folder');
    validateName(args.fileName, 'file_name');
    const fullPath = this.notePath(args.ownerId, args.folder, args.fileName);

    if (existsSync(fullPath)) {
      const ts = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
      const rand = Math.random().toString(16).slice(2, 6);
      const trashDir = resolve(userRoot(this.deps.userFolderRoot, args.ownerId), 'trash');
      mkdirSync(trashDir, { recursive: true, mode: 0o700 });
      const trashPath = join(
        trashDir,
        `${ts}-${rand}-notes-${args.folder}-${args.fileName}`
      );
      renameSync(fullPath, trashPath);
    }

    this.deps.repo.deleteNote(args.ownerId, args.folder, args.fileName);
  }
}
