import sinon from 'sinon';

import { assert } from 'chai';
import { Email } from '../lib/client/email-client.js';
import { ProgressReporter } from '../lib/reporter/progress-reporter.js';
import { TrashKeyword, TrashCleaner, TrashCleanerFactory, LlmTrashRule, KeywordTrashRule } from '../lib/trash-cleaner.js';

describe('TrashKeyword', () => {
  describe('constructor', () => {
    it('throws when value is not set', () => {
      assert.throws(() => new TrashKeyword(null as any, ['*'], ['spam']), /Invalid keyword/);
    });

    it('throws when fields are not set', () => {
      assert.throws(() => new TrashKeyword('apple', null as any, ['*']), /Invalid keyword/);
    });

    it('throws when labels are not set', () => {
      assert.throws(() => new TrashKeyword('apple', ['*'], null as any), /Invalid keyword/);
    });

    it('does not throw when value and labels are set', () => {
      assert.doesNotThrow(() => new TrashKeyword('apple', ['*'], ['spam']));
    });

    it('defaults action to delete', () => {
      const keyword = new TrashKeyword('apple', ['*'], ['spam']);
      assert.equal(keyword.action, 'delete');
    });

    it('accepts valid action', () => {
      const keyword = new TrashKeyword('apple', ['*'], ['spam'], 'archive');
      assert.equal(keyword.action, 'archive');
    });

    it('throws for invalid action', () => {
      assert.throws(() => new TrashKeyword('apple', ['*'], ['spam'], 'invalid'), /Invalid action/);
    });

    it('defaults type to keyword', () => {
      const keyword = new TrashKeyword('apple', ['*'], ['spam']);
      assert.equal(keyword.type, 'keyword');
    });

    it('accepts llm type with provider', () => {
      const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'delete', 'llm', undefined, 'claude');
      assert.equal(keyword.type, 'llm');
      assert.equal(keyword.llm, 'claude');
    });
  });
});

describe('TrashCleaner', () => {
  let client: any, email: Email, reporter: ProgressReporter;

  before(() => {
    sinon.stub(console, 'log');
  });

  after(() => {
    (console.log as sinon.SinonStub).restore();
  });

  beforeEach(() => {
    email = new Email();

    client = {
      getUnreadEmails: sinon.stub().returns([email]),
      deleteEmails: sinon.stub(),
      archiveEmails: sinon.stub(),
      markAsReadEmails: sinon.stub()
    };

    reporter = new ProgressReporter();
  });

  describe('cleanTrash', () => {
    [
      { match: 'keyword', value: 'orange', fields: ['*'], labels: ['spam'] },
      { match: 'field', value: 'apple', fields: ['subject'], labels: ['spam'] },
      { match: 'label', value: 'apple', fields: ['*'], labels: ['inbox'] },
    ].forEach(data =>
      it(`does not find spam when ${data.match} does not match`, async () => {
        email.body = 'apple';
        email.labels = ['spam'];

        const cleaner = new TrashCleaner(client, [{
          value: data.value, fields: data.fields, labels: data.labels
        }], reporter);

        await cleaner.cleanTrash();

        sinon.assert.notCalled(client.deleteEmails);
      }));

    it('uses regex', async () => {
      email.body = 'orange';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'mango|apple|orange', fields: ['*'], labels: ['spam']
      }], reporter);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.deleteEmails, [email]);
    });

    it('finds spam with diacritics', async () => {
      email.body = 'Ápplé';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'apple', fields: ['*'], labels: ['spam']
      }], reporter);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.deleteEmails, [email]);
    });

    it('finds spam with wildcard label', async () => {
      email.body = 'apple';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'apple', fields: ['*'], labels: ['*']
      }], reporter);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.deleteEmails, [email]);
    });

    it('succeeds when there are no emails', async () => {
      client.getUnreadEmails.returns([]);

      const cleaner = new TrashCleaner(client, [{
        value: 'apple', fields: ['*'], labels: ['inbox']
      }], reporter);

      await cleaner.cleanTrash();

      sinon.assert.notCalled(client.deleteEmails);
    });

    it('is case insensitive', async () => {
      client.getUnreadEmails.returns([email]);

      const testData = [
        { keyword: 'apple', label: 'spam', emailBody: 'APPLE', emailLabel: 'SPAM' },
        { keyword: 'APPLE', label: 'spam', emailBody: 'apple', emailLabel: 'spam' },
        { keyword: 'apple', label: 'SPAM', emailBody: 'apple', emailLabel: 'spam' },
      ];

      for (const data of testData) {
        email.body = data.emailBody;
        email.labels = [data.emailLabel];

        const cleaner = new TrashCleaner(client, [{
          value: data.keyword, fields: ['*'], labels: [data.label]
        }], reporter);

        await cleaner.cleanTrash();

        sinon.assert.calledWith(client.deleteEmails, [email]);

        client.deleteEmails.reset();
      }
    });

    ['from', 'subject', 'snippet', 'body'].forEach(field =>
      it(`finds spam in ${field} field`, async () => {
        (email as any)[field] = 'apple';
        email.labels = ['spam'];

        const cleaner = new TrashCleaner(client, [{
          value: 'apple', fields: [field], labels: ['spam']
        }], reporter);

        await cleaner.cleanTrash();

        sinon.assert.calledWith(client.deleteEmails, [email]);
      }));

    it('archives emails when action is archive', async () => {
      email.body = 'newsletter content';
      email.labels = ['inbox'];

      const cleaner = new TrashCleaner(client, [{
        value: 'newsletter', fields: ['*'], labels: ['*'], action: 'archive'
      }], reporter);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.archiveEmails, [email]);
      sinon.assert.notCalled(client.deleteEmails);
    });

    it('marks emails as read when action is mark-as-read', async () => {
      email.body = 'notification update';
      email.labels = ['inbox'];

      const cleaner = new TrashCleaner(client, [{
        value: 'notification', fields: ['*'], labels: ['*'], action: 'mark-as-read'
      }], reporter);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.markAsReadEmails, [email]);
      sinon.assert.notCalled(client.deleteEmails);
    });

    it('groups emails by action and processes each group', async () => {
      const email2 = new Email();
      email.body = 'casino spam';
      email.labels = ['spam'];
      email2.body = 'newsletter digest';
      email2.labels = ['inbox'];

      client.getUnreadEmails.returns([email, email2]);

      const cleaner = new TrashCleaner(client, [
        { value: 'casino', fields: ['*'], labels: ['*'], action: 'delete' },
        { value: 'newsletter', fields: ['*'], labels: ['*'], action: 'archive' }
      ], reporter);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.deleteEmails, [email]);
      sinon.assert.calledWith(client.archiveEmails, [email2]);
    });

    it('does not execute actions in dry-run mode', async () => {
      email.body = 'newsletter content';
      email.labels = ['inbox'];

      const cleaner = new TrashCleaner(client, [{
        value: 'newsletter', fields: ['*'], labels: ['*'], action: 'archive'
      }], reporter);

      await cleaner.cleanTrash(true /*dryRun*/);

      sinon.assert.notCalled(client.archiveEmails);
      sinon.assert.notCalled(client.deleteEmails);
    });
  });

  describe('allowlist', () => {
    it('protects allowlisted sender from deletion', async () => {
      email.body = 'casino spam';
      email.from = 'boss@example.com';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, ['boss@example\\.com']);

      await cleaner.cleanTrash();

      sinon.assert.notCalled(client.deleteEmails);
    });

    it('allows non-allowlisted sender to be deleted', async () => {
      email.body = 'casino spam';
      email.from = 'spammer@evil.com';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, ['boss@example\\.com']);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.deleteEmails, [email]);
    });

    it('allowlist patterns are case-insensitive', async () => {
      email.body = 'promo offer';
      email.from = 'BOSS@Example.COM';
      email.labels = ['inbox'];

      const cleaner = new TrashCleaner(client, [{
        value: 'promo', fields: ['*'], labels: ['*']
      }], reporter, ['boss@example\\.com']);

      await cleaner.cleanTrash();

      sinon.assert.notCalled(client.deleteEmails);
    });

    it('supports regex patterns in allowlist', async () => {
      email.body = 'newsletter';
      email.from = 'news@trusted-domain.org';
      email.labels = ['inbox'];

      const cleaner = new TrashCleaner(client, [{
        value: 'newsletter', fields: ['*'], labels: ['*']
      }], reporter, ['@trusted-domain\\.org']);

      await cleaner.cleanTrash();

      sinon.assert.notCalled(client.deleteEmails);
    });

    it('works with empty allowlist', async () => {
      email.body = 'casino';
      email.from = 'anyone@test.com';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, []);

      await cleaner.cleanTrash();

      sinon.assert.calledWith(client.deleteEmails, [email]);
    });
  });

  describe('minAge filter', () => {
    it('skips emails newer than minAge', async () => {
      email.body = 'casino offer';
      email.labels = ['spam'];
      email.date = new Date(); // now — too new

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, [], null, 7);

      await cleaner.cleanTrash();

      sinon.assert.notCalled(client.deleteEmails);
    });

    it('includes emails older than minAge', async () => {
      email.body = 'casino offer';
      email.labels = ['spam'];
      email.date = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); // 10 days ago

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, [], null, 7);

      await cleaner.cleanTrash();

      sinon.assert.calledOnce(client.deleteEmails);
    });

    it('includes emails when minAge is not set', async () => {
      email.body = 'casino offer';
      email.labels = ['spam'];
      email.date = new Date(); // now — but no age filter

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter);

      await cleaner.cleanTrash();

      sinon.assert.calledOnce(client.deleteEmails);
    });

    it('includes emails with no date when minAge is set', async () => {
      email.body = 'casino offer';
      email.labels = ['spam'];
      email.date = null as any;

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, [], null, 7);

      await cleaner.cleanTrash();

      sinon.assert.calledOnce(client.deleteEmails);
    });
  });

  describe('error handling', () => {
    it('throws when getUnreadEmails fails', async () => {
      client.getUnreadEmails.rejects(new Error('API timeout'));

      const cleaner = new TrashCleaner(client, [{
        value: 'test', fields: ['*'], labels: ['*']
      }], reporter);

      try {
        await cleaner.cleanTrash();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /Failed to get trash emails/);
      }
    });
  });

  describe('filterTrashEmails', () => {
    it('returns matching emails without fetching', async () => {
      email.body = 'casino offer';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter);

      const result = await cleaner.filterTrashEmails([email]);

      assert.deepEqual(result, [email]);
    });

    it('normalizes diacritics before matching', async () => {
      email.body = 'cásìnó';
      email.labels = ['spam'];

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter);

      const result = await cleaner.filterTrashEmails([email]);

      assert.deepEqual(result, [email]);
    });

    it('skips emails older than lastRun when seenCache is set', async () => {
      email.body = 'casino offer';
      email.labels = ['spam'];
      email.date = new Date('2026-05-10T08:00:00Z');

      const seenCache = {
        isSeen: sinon.stub().returns(true)
      };

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, [], null, null, seenCache);

      const result = await cleaner.filterTrashEmails([email]);

      assert.equal(result.length, 0);
    });

    it('evaluates emails newer than lastRun', async () => {
      email.body = 'casino offer';
      email.labels = ['spam'];
      email.date = new Date('2026-05-13T08:00:00Z');

      const seenCache = {
        isSeen: sinon.stub().returns(false)
      };

      const cleaner = new TrashCleaner(client, [{
        value: 'casino', fields: ['*'], labels: ['*']
      }], reporter, [], null, null, seenCache);

      const result = await cleaner.filterTrashEmails([email]);

      assert.deepEqual(result, [email]);
    });
  });
});

describe('TrashCleanerFactory', () => {
  describe('readKeywords', () => {
    it('parses keywords from config store', async () => {
      const configStore = {
        getJson: sinon.stub().returns([
          { value: 'casino', fields: 'subject,body', labels: 'spam' }
        ])
      };

      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
      const { keywords } = await factory.readKeywords();

      assert.equal(keywords.length, 1);
      assert.equal(keywords[0].value, 'casino');
      assert.deepEqual(keywords[0].fields, ['subject', 'body']);
      assert.deepEqual(keywords[0].labels, ['spam']);
    });

    it('uses wildcard default when fields are missing', async () => {
      const configStore = {
        getJson: sinon.stub().returns([
          { value: 'test' }
        ])
      };

      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
      const { keywords } = await factory.readKeywords();

      assert.deepEqual(keywords[0].fields, ['*']);
      assert.deepEqual(keywords[0].labels, ['*']);
    });
  });

  describe('splitAndTrim', () => {
    it('splits comma-separated values', () => {
      const factory = new TrashCleanerFactory({} as any, {} as any, false);
      const result = factory.splitAndTrim('a, b, c', ',', '*');

      assert.deepEqual(result, ['a', 'b', 'c']);
    });

    it('uses default when value is null', () => {
      const factory = new TrashCleanerFactory({} as any, {} as any, false);
      const result = factory.splitAndTrim(null, ',', '*');

      assert.deepEqual(result, ['*']);
    });

    it('filters empty tokens', () => {
      const factory = new TrashCleanerFactory({} as any, {} as any, false);
      const result = factory.splitAndTrim('a,,b', ',', '*');

      assert.deepEqual(result, ['a', 'b']);
    });
  });

  describe('getInstance', () => {
    it('returns a TrashCleaner instance', async () => {
      const configStore: any = {
        getJson: sinon.stub(),
        putJson: sinon.stub()
      };
      configStore.getJson.withArgs('keywords.json').returns([
        { value: 'test', fields: '*', labels: 'spam' }
      ]);
      configStore.getJson.withArgs('allowlist.json').returns(['safe@test.com']);
      configStore.getJson.withArgs('seen.json').returns(null);

      const factory = new TrashCleanerFactory(configStore, {} as any, false);
      const cleaner = await factory.getInstance();

      assert.instanceOf(cleaner, TrashCleaner);
    });
  });

  describe('readAllowlist', () => {
    it('reads allowlist from config store', async () => {
      const configStore = {
        getJson: sinon.stub().withArgs('allowlist.json').returns(['sender@test.com'])
      };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
      const allowlist = await factory.readAllowlist();

      assert.deepEqual(allowlist, ['sender@test.com']);
    });

    it('returns empty array when file does not exist', async () => {
      const configStore = {
        getJson: sinon.stub().withArgs('allowlist.json').throws(new Error('File not found'))
      };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
      const allowlist = await factory.readAllowlist();

      assert.deepEqual(allowlist, []);
    });

    it('throws when file contains invalid JSON', async () => {
      const configStore = {
        getJson: sinon.stub().withArgs('allowlist.json')
          .throws(new Error('Unexpected token in JSON'))
      };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readAllowlist();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /Unexpected token/);
      }
    });

    it('throws when allowlist is not an array', async () => {
      const configStore = {
        getJson: sinon.stub().withArgs('allowlist.json').returns({ sender: 'test' })
      };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readAllowlist();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /must contain a JSON array/);
      }
    });
  });

  describe('config validation', () => {
    it('rejects non-array config', async () => {
      const configStore = { getJson: sinon.stub().returns({}) };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readKeywords();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /must contain a JSON array/);
      }
    });

    it('returns empty array for empty keywords config', async () => {
      const configStore = { getJson: sinon.stub().returns([]) };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      const { keywords } = await factory.readKeywords();
      assert.deepEqual(keywords, []);
    });

    it('returns empty array when keywords.json does not exist', async () => {
      const configStore = { getJson: sinon.stub().rejects(new Error('ENOENT: no such file')) };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      const { keywords } = await factory.readKeywords();
      assert.deepEqual(keywords, []);
    });

    it('rejects entry without value', async () => {
      const configStore = { getJson: sinon.stub().returns([{ fields: '*' }]) };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readKeywords();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /missing a valid "value" field/);
      }
    });

    it('rejects non-string fields', async () => {
      const configStore = { getJson: sinon.stub().returns([{ value: 'test', fields: ['*'] }]) };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readKeywords();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /"fields" must be a comma-separated string/);
      }
    });

    it('rejects non-string labels', async () => {
      const configStore = { getJson: sinon.stub().returns([{ value: 'test', labels: ['spam'] }]) };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readKeywords();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /"labels" must be a comma-separated string/);
      }
    });

    it('rejects invalid action', async () => {
      const configStore = { getJson: sinon.stub().returns([{ value: 'test', action: 'explode' }]) };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readKeywords();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /invalid action "explode"/);
      }
    });

    it('accepts valid config with all fields', async () => {
      const configStore = {
        getJson: sinon.stub().returns([
          { value: 'test', fields: 'subject', labels: 'spam', action: 'archive' }
        ])
      };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      const { keywords } = await factory.readKeywords();
      assert.equal(keywords.length, 1);
      assert.equal(keywords[0].action, 'archive');
    });

    it('includes index in error for bad entry', async () => {
      const configStore = {
        getJson: sinon.stub().returns([
          { value: 'ok' },
          { value: '' }
        ])
      };
      const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

      try {
        await factory.readKeywords();
        assert.fail('should throw');
      } catch (err: any) {
        assert.match(err.message, /index 1/);
      }
    });
  });
});

describe('LlmTrashRule', () => {
  const mockProvider = { command: 'echo', args: ['{{prompt}}'] };

  it('stores label and action from keyword', () => {
    const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'archive', 'llm', undefined, 'claude');
    const rule = new LlmTrashRule(keyword, mockProvider);
    assert.equal(rule.label, 'marketing email');
    assert.equal(rule.action, 'archive');
    assert.deepEqual(rule.labels, ['*']);
  });

  it('defaults action to delete', () => {
    const keyword = new TrashKeyword('spam content', ['*'], ['inbox'], undefined, 'llm', undefined, 'claude');
    const rule = new LlmTrashRule(keyword, mockProvider);
    assert.equal(rule.action, 'delete');
  });

  it('stores provider config', () => {
    const keyword = new TrashKeyword('promo', ['*'], ['*'], 'delete', 'llm', undefined, 'claude');
    const rule = new LlmTrashRule(keyword, mockProvider);
    assert.equal(rule.provider, mockProvider);
  });
});

describe('TrashCleanerFactory with LLM rules', () => {
  it('parses llm type from config', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'marketing email', labels: '*', type: 'llm', llm: 'claude', action: 'archive' }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    const { keywords } = await factory.readKeywords();

    assert.equal(keywords.length, 1);
    assert.equal(keywords[0].type, 'llm');
    assert.equal(keywords[0].value, 'marketing email');
    assert.equal(keywords[0].action, 'archive');
    assert.equal(keywords[0].llm, 'claude');
  });

  it('defaults type to keyword when not specified', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'casino', fields: 'subject', labels: 'spam' }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    const { keywords } = await factory.readKeywords();

    assert.equal(keywords[0].type, 'keyword');
  });

  it('rejects invalid type', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'test', type: 'invalid' }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

    try {
      await factory.readKeywords();
      assert.fail('should throw');
    } catch (err: any) {
      assert.match(err.message, /invalid type/);
    }
  });

  it('requires llm field for type llm', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'marketing', type: 'llm', labels: '*' }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

    try {
      await factory.readKeywords();
      assert.fail('should throw');
    } catch (err: any) {
      assert.match(err.message, /LLM rules require a "llm" field/);
    }
  });

  it('creates LlmTrashRule for llm type keywords', () => {
    const reporter = new ProgressReporter();
    const keywords = [
      new TrashKeyword('marketing', ['*'], ['*'], 'archive', 'llm', undefined, 'claude'),
      new TrashKeyword('casino', ['*'], ['*'], 'delete', 'keyword')
    ];
    const llmProviders = { claude: { command: 'claude', args: ['--print', '{{prompt}}'] } };
    const cleaner = new TrashCleaner({} as any, keywords, reporter, [], null, null, null, llmProviders);
    assert.instanceOf((cleaner as any)._rules[0], LlmTrashRule);
    assert.notInstanceOf((cleaner as any)._rules[1], LlmTrashRule);
  });

  it('throws when LLM provider not found', () => {
    const reporter = new ProgressReporter();
    const keywords = [
      new TrashKeyword('marketing', ['*'], ['*'], 'archive', 'llm', undefined, 'missing')
    ];
    assert.throws(
      () => new TrashCleaner({} as any, keywords, reporter, [], null, null, null, {}),
      /LLM provider "missing" not found/
    );
  });
});

describe('TrashCleanerFactory.readLlmProviders', () => {
  it('reads providers from config', async () => {
    const configStore = {
      getJson: sinon.stub().resolves({
        claude: { command: 'claude', args: ['--print', '{{prompt}}'] }
      })
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    const providers = await factory.readLlmProviders();
    assert.deepEqual(providers.claude, { command: 'claude', args: ['--print', '{{prompt}}'] });
  });

  it('returns empty object when file not found', async () => {
    const configStore = {
      getJson: sinon.stub().rejects(new Error('ENOENT'))
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    const providers = await factory.readLlmProviders();
    assert.deepEqual(providers, {});
  });

  it('rejects provider missing command', async () => {
    const configStore = {
      getJson: sinon.stub().resolves({
        bad: { args: ['{{prompt}}'] }
      })
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    try {
      await factory.readLlmProviders();
      assert.fail('should throw');
    } catch (err: any) {
      assert.match(err.message, /missing a valid "command"/);
    }
  });

  it('rejects provider missing args', async () => {
    const configStore = {
      getJson: sinon.stub().resolves({
        bad: { command: 'echo' }
      })
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    try {
      await factory.readLlmProviders();
      assert.fail('should throw');
    } catch (err: any) {
      assert.match(err.message, /missing an "args" array/);
    }
  });

  it('rejects provider args without prompt placeholder', async () => {
    const configStore = {
      getJson: sinon.stub().resolves({
        bad: { command: 'echo', args: ['hello'] }
      })
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    try {
      await factory.readLlmProviders();
      assert.fail('should throw');
    } catch (err: any) {
      assert.match(err.message, /must contain a "{{prompt}}" placeholder/);
    }
  });
});

describe('Rule title', () => {
  it('KeywordTrashRule uses title from keyword when provided', () => {
    const keyword = new TrashKeyword('casino', ['*'], ['*'], 'delete', 'keyword', 'Casino spam');
    const rule = new KeywordTrashRule(keyword);
    assert.equal(rule.title, 'Casino spam');
  });

  it('KeywordTrashRule defaults title to value when not provided', () => {
    const keyword = new TrashKeyword('casino', ['*'], ['*']);
    const rule = new KeywordTrashRule(keyword);
    assert.equal(rule.title, 'casino');
  });

  it('LlmTrashRule uses title from keyword when provided', () => {
    const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'archive', 'llm', 'Marketing emails', 'claude');
    const rule = new LlmTrashRule(keyword, { command: 'echo', args: ['{{prompt}}'] });
    assert.equal(rule.title, 'Marketing emails');
  });

  it('LlmTrashRule defaults title to value when not provided', () => {
    const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'archive', 'llm', undefined, 'claude');
    const rule = new LlmTrashRule(keyword, { command: 'echo', args: ['{{prompt}}'] });
    assert.equal(rule.title, 'marketing email');
  });

  it('_isTrashEmail sets _rule on matched email', async () => {
    const email = { from: 'test', subject: 'casino offer', snippet: '', body: 'casino offer', labels: ['spam'] } as any;
    const keyword = new TrashKeyword('casino', ['*'], ['*'], 'delete', 'keyword', 'Casino spam');
    const reporter = new ProgressReporter();
    const cleaner = new TrashCleaner({} as any, [keyword], reporter);

    const result = await cleaner.filterTrashEmails([email]);
    assert.equal(result.length, 1);
    assert.equal(result[0]._rule, 'Casino spam');
  });

  it('TrashKeyword stores title', () => {
    const keyword = new TrashKeyword('test', ['*'], ['*'], 'delete', 'keyword', 'My Rule');
    assert.equal(keyword.title, 'My Rule');
  });

  it('TrashKeyword title defaults to undefined when not provided', () => {
    const keyword = new TrashKeyword('test', ['*'], ['*']);
    assert.equal(keyword.title, undefined);
  });

  it('readKeywords parses title from config', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'casino', fields: '*', labels: 'spam', title: 'Casino spam' }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    const { keywords } = await factory.readKeywords();
    assert.equal(keywords[0].title, 'Casino spam');
  });

  it('validation rejects non-string title', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'test', title: 123 }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

    try {
      await factory.readKeywords();
      assert.fail('should throw');
    } catch (err: any) {
      assert.match(err.message, /"title" must be a non-empty string/);
    }
  });

  it('validation rejects empty title', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'test', title: '  ' }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);

    try {
      await factory.readKeywords();
      assert.fail('should throw');
    } catch (err: any) {
      assert.match(err.message, /"title" must be a non-empty string/);
    }
  });

  it('validation accepts missing title', async () => {
    const configStore = {
      getJson: sinon.stub().returns([
        { value: 'test' }
      ])
    };
    const factory = new TrashCleanerFactory(configStore as any, {} as any, false);
    const { keywords } = await factory.readKeywords();
    assert.equal(keywords.length, 1);
  });
});
