const { Constants, actionDelimiter, actionDomainSeparator } = require('librechat-data-provider');
const { domainParser, legacyDomainEncode, validateAndUpdateTool } = require('./ActionService');

jest.mock('keyv');

jest.mock('~/models', () => ({
  getActions: jest.fn(),
  deleteActions: jest.fn(),
}));

const { getActions } = require('~/models');

let mockDomainCache = {};
jest.mock('~/cache/getLogStores', () => {
  return jest.fn().mockImplementation(() => ({
    get: async (key) => mockDomainCache[key] ?? null,
    set: async (key, value) => {
      mockDomainCache[key] = value;
      return true;
    },
  }));
});

beforeEach(() => {
  mockDomainCache = {};
  getActions.mockReset();
});

const SEP = actionDomainSeparator;
const DELIM = actionDelimiter;
const MAX = Constants.ENCODED_DOMAIN_LENGTH;
const domainSepRegex = new RegExp(SEP, 'g');

describe('domainParser', () => {
  describe('nullish input', () => {
    it.each([null, undefined, ''])('returns undefined for %j', async (input) => {
      expect(await domainParser(input, true)).toBeUndefined();
      expect(await domainParser(input, false)).toBeUndefined();
    });
  });

  describe('short-path encoding (hostname ≤ threshold)', () => {
    it.each([
      ['examp.com', `examp${SEP}com`],
      ['swapi.tech', `swapi${SEP}tech`],
      ['a.b', `a${SEP}b`],
    ])('replaces dots in %s → %s', async (domain, expected) => {
      expect(await domainParser(domain, true)).toBe(expected);
    });

    it('handles domain exactly at threshold length', async () => {
      const domain = 'a'.repeat(MAX - 4) + '.com';
      expect(domain).toHaveLength(MAX);
      const result = await domainParser(domain, true);
      expect(result).toBe(domain.replace(/\./g, SEP));
    });
  });

  describe('base64-path encoding (hostname > threshold)', () => {
    it('produces a key of exactly ENCODED_DOMAIN_LENGTH chars', async () => {
      const result = await domainParser('api.example.com', true);
      expect(result).toHaveLength(MAX);
    });

    it('encodes hostname, not full URL', async () => {
      const hostname = 'api.example.com';
      const expectedKey = Buffer.from(hostname).toString('base64').substring(0, MAX);
      expect(await domainParser(hostname, true)).toBe(expectedKey);
    });

    it('populates decode cache for round-trip', async () => {
      const hostname = 'longdomainname.com';
      const key = await domainParser(hostname, true);

      expect(mockDomainCache[key]).toBe(Buffer.from(hostname).toString('base64'));
      expect(await domainParser(key, false)).toBe(hostname);
    });
  });

  describe('protocol stripping', () => {
    it('https:// URL and bare hostname produce identical encoding', async () => {
      const encoded = await domainParser('https://swapi.tech', true);
      expect(encoded).toBe(await domainParser('swapi.tech', true));
      expect(encoded).toBe(`swapi${SEP}tech`);
    });

    it('http:// URL and bare hostname produce identical encoding', async () => {
      const encoded = await domainParser('http://api.example.com', true);
      expect(encoded).toBe(await domainParser('api.example.com', true));
    });

    it('different https:// domains produce unique keys', async () => {
      const keys = await Promise.all([
        domainParser('https://api.example.com', true),
        domainParser('https://api.weather.com', true),
        domainParser('https://data.github.com', true),
      ]);
      const unique = new Set(keys);
      expect(unique.size).toBe(keys.length);
    });

    it('long hostname after stripping still uses base64 path', async () => {
      const result = await domainParser('https://api.example.com', true);
      expect(result).toHaveLength(MAX);
      expect(result).not.toContain(SEP);
    });

    it('short hostname after stripping uses dot-replacement path', async () => {
      const result = await domainParser('https://a.b.c', true);
      expect(result).toBe(`a${SEP}b${SEP}c`);
    });

    it('strips path and query from full URL before encoding', async () => {
      const result = await domainParser('https://api.example.com/v1/endpoint?foo=bar', true);
      expect(result).toBe(await domainParser('api.example.com', true));
    });
  });

  describe('unicode domains', () => {
    it('encodes unicode hostname via base64 path', async () => {
      const domain = 'täst.example.com';
      const result = await domainParser(domain, true);
      expect(result).toHaveLength(MAX);
      expect(result).toBe(Buffer.from(domain).toString('base64').substring(0, MAX));
    });

    it('round-trips unicode hostname through encode then decode', async () => {
      const domain = 'täst.example.com';
      const key = await domainParser(domain, true);
      expect(await domainParser(key, false)).toBe(domain);
    });

    it('strips protocol before encoding unicode hostname', async () => {
      const withProto = 'https://täst.example.com';
      const bare = 'täst.example.com';
      expect(await domainParser(withProto, true)).toBe(await domainParser(bare, true));
    });
  });

  describe('decode path', () => {
    it('short-path encoded domain decodes via separator replacement', async () => {
      expect(await domainParser(`examp${SEP}com`, false)).toBe('examp.com');
    });

    it('base64-path encoded domain decodes via cache lookup', async () => {
      const hostname = 'api.example.com';
      const key = await domainParser(hostname, true);
      expect(await domainParser(key, false)).toBe(hostname);
    });

    it('returns input unchanged for unknown non-separator strings', async () => {
      expect(await domainParser('not_base64_encoded', false)).toBe('not_base64_encoded');
    });

    it('returns a string without throwing for corrupt cache entries', async () => {
      mockDomainCache['corrupt_key'] = '!!!';
      const result = await domainParser('corrupt_key', false);
      expect(typeof result).toBe('string');
    });
  });
});

describe('legacyDomainEncode', () => {
  it.each(['', null, undefined])('returns empty string for %j', (input) => {
    expect(legacyDomainEncode(input)).toBe('');
  });

  it('is synchronous (returns a string, not a Promise)', () => {
    const result = legacyDomainEncode('examp.com');
    expect(result).toBe(`examp${SEP}com`);
    expect(result).not.toBeInstanceOf(Promise);
  });

  it('uses dot-replacement for short domains', () => {
    expect(legacyDomainEncode('examp.com')).toBe(`examp${SEP}com`);
  });

  it('uses base64 prefix of full input for long domains', () => {
    const domain = 'https://swapi.tech';
    const expected = Buffer.from(domain).toString('base64').substring(0, MAX);
    expect(legacyDomainEncode(domain)).toBe(expected);
  });

  it('all https:// URLs collide to the same key', () => {
    const results = [
      legacyDomainEncode('https://api.example.com'),
      legacyDomainEncode('https://api.weather.com'),
      legacyDomainEncode('https://totally.different.host'),
    ];
    expect(new Set(results).size).toBe(1);
  });

  it('matches what old domainParser would have produced', () => {
    const domain = 'https://api.example.com';
    const legacy = legacyDomainEncode(domain);
    expect(legacy).toBe(Buffer.from(domain).toString('base64').substring(0, MAX));
  });

  it('produces same result as new domainParser for short bare hostnames', async () => {
    const domain = 'swapi.tech';
    expect(legacyDomainEncode(domain)).toBe(await domainParser(domain, true));
  });
});

describe('validateAndUpdateTool', () => {
  const mockReq = { user: { id: 'user123' } };

  it('returns tool unchanged when name passes tool-name regex', async () => {
    const tool = { function: { name: 'getPeople_action_swapi---tech' } };
    const result = await validateAndUpdateTool({
      req: mockReq,
      tool,
      assistant_id: 'asst_1',
    });
    expect(result).toEqual(tool);
    expect(getActions).not.toHaveBeenCalled();
  });

  it('matches action when metadata.domain has https:// prefix and tool domain is bare hostname', async () => {
    getActions.mockResolvedValue([{ metadata: { domain: 'https://api.example.com' } }]);

    const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
    const result = await validateAndUpdateTool({
      req: mockReq,
      tool,
      assistant_id: 'asst_1',
    });

    expect(result).not.toBeNull();
    expect(result.function.name).toMatch(/^getPeople_action_/);
    expect(result.function.name).not.toContain('.');
  });

  it('matches action when metadata.domain has no protocol', async () => {
    getActions.mockResolvedValue([{ metadata: { domain: 'api.example.com' } }]);

    const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
    const result = await validateAndUpdateTool({
      req: mockReq,
      tool,
      assistant_id: 'asst_1',
    });

    expect(result).not.toBeNull();
    expect(result.function.name).toMatch(/^getPeople_action_/);
  });

  it('returns null when no action matches the domain', async () => {
    getActions.mockResolvedValue([{ metadata: { domain: 'https://other.domain.com' } }]);

    const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
    const result = await validateAndUpdateTool({
      req: mockReq,
      tool,
      assistant_id: 'asst_1',
    });

    expect(result).toBeNull();
  });

  it('returns null when action has no metadata', async () => {
    getActions.mockResolvedValue([{ metadata: null }]);

    const tool = { function: { name: `getPeople${DELIM}api.example.com` } };
    const result = await validateAndUpdateTool({
      req: mockReq,
      tool,
      assistant_id: 'asst_1',
    });

    expect(result).toBeNull();
  });
});

describe('backward-compatible tool name matching', () => {
  function normalizeToolName(name) {
    return name.replace(domainSepRegex, '_');
  }

  function buildToolName(functionName, encodedDomain) {
    return `${functionName}${DELIM}${encodedDomain}`;
  }

  describe('definition-phase matching', () => {
    it('new encoding matches agent tools stored with new encoding', async () => {
      const metadataDomain = 'https://swapi.tech';
      const encoded = await domainParser(metadataDomain, true);
      const normalized = normalizeToolName(encoded);

      const storedTool = buildToolName('getPeople', encoded);
      const defToolName = `getPeople${DELIM}${normalized}`;

      expect(normalizeToolName(storedTool)).toBe(defToolName);
    });

    it('legacy encoding matches agent tools stored with legacy encoding', async () => {
      const metadataDomain = 'https://swapi.tech';
      const legacy = legacyDomainEncode(metadataDomain);
      const legacyNormalized = normalizeToolName(legacy);

      const storedTool = buildToolName('getPeople', legacy);
      const legacyDefName = `getPeople${DELIM}${legacyNormalized}`;

      expect(normalizeToolName(storedTool)).toBe(legacyDefName);
    });

    it('new definition matches old stored tools via legacy fallback', async () => {
      const metadataDomain = 'https://swapi.tech';
      const newDomain = await domainParser(metadataDomain, true);
      const legacyDomain = legacyDomainEncode(metadataDomain);
      const newNorm = normalizeToolName(newDomain);
      const legacyNorm = normalizeToolName(legacyDomain);

      const oldStoredTool = buildToolName('getPeople', legacyDomain);
      const newToolName = `getPeople${DELIM}${newNorm}`;
      const legacyToolName = `getPeople${DELIM}${legacyNorm}`;

      const storedNormalized = normalizeToolName(oldStoredTool);
      const hasMatch = storedNormalized === newToolName || storedNormalized === legacyToolName;
      expect(hasMatch).toBe(true);
    });

    it('pre-normalized Set eliminates per-tool normalization', async () => {
      const metadataDomain = 'https://api.example.com';
      const domain = await domainParser(metadataDomain, true);
      const legacyDomain = legacyDomainEncode(metadataDomain);
      const normalizedDomain = normalizeToolName(domain);
      const legacyNormalized = normalizeToolName(legacyDomain);

      const storedTools = [
        buildToolName('getWeather', legacyDomain),
        buildToolName('getForecast', domain),
      ];

      const preNormalized = new Set(storedTools.map((t) => normalizeToolName(t)));

      const toolName = `getWeather${DELIM}${normalizedDomain}`;
      const legacyToolName = `getWeather${DELIM}${legacyNormalized}`;
      expect(preNormalized.has(toolName) || preNormalized.has(legacyToolName)).toBe(true);
    });
  });

  describe('execution-phase tool lookup', () => {
    it('model-called tool name resolves via normalizedToDomain map (new encoding)', async () => {
      const metadataDomain = 'https://api.example.com';
      const domain = await domainParser(metadataDomain, true);
      const normalized = normalizeToolName(domain);

      const normalizedToDomain = new Map();
      normalizedToDomain.set(normalized, domain);

      const modelToolName = `getWeather${DELIM}${normalized}`;

      let matched = '';
      for (const [norm, canonical] of normalizedToDomain.entries()) {
        if (modelToolName.includes(norm)) {
          matched = canonical;
          break;
        }
      }

      expect(matched).toBe(domain);

      const functionName = modelToolName.replace(`${DELIM}${normalizeToolName(matched)}`, '');
      expect(functionName).toBe('getWeather');
    });

    it('model-called tool name resolves via legacy entry in normalizedToDomain map', async () => {
      const metadataDomain = 'https://api.example.com';
      const domain = await domainParser(metadataDomain, true);
      const legacyDomain = legacyDomainEncode(metadataDomain);
      const legacyNorm = normalizeToolName(legacyDomain);

      const normalizedToDomain = new Map();
      normalizedToDomain.set(normalizeToolName(domain), domain);
      normalizedToDomain.set(legacyNorm, domain);

      const legacyModelToolName = `getWeather${DELIM}${legacyNorm}`;

      let matched = '';
      for (const [norm, canonical] of normalizedToDomain.entries()) {
        if (legacyModelToolName.includes(norm)) {
          matched = canonical;
          break;
        }
      }

      expect(matched).toBe(domain);
    });

    it('legacy guard skips duplicate map entry for short bare hostnames', async () => {
      const domain = 'swapi.tech';
      const newEncoding = await domainParser(domain, true);
      const legacyEncoding = legacyDomainEncode(domain);

      expect(newEncoding).toBe(legacyEncoding);

      const normalizedToDomain = new Map();
      normalizedToDomain.set(newEncoding, newEncoding);
      if (legacyEncoding !== newEncoding) {
        normalizedToDomain.set(legacyEncoding, newEncoding);
      }
      expect(normalizedToDomain.size).toBe(1);
    });
  });

  describe('processRequiredActions matching (assistants path)', () => {
    it('legacy tool from OpenAI matches via normalizedToDomain with both encodings', async () => {
      const metadataDomain = 'https://swapi.tech';
      const domain = await domainParser(metadataDomain, true);
      const legacyDomain = legacyDomainEncode(metadataDomain);

      const normalizedToDomain = new Map();
      normalizedToDomain.set(domain, domain);
      if (legacyDomain !== domain) {
        normalizedToDomain.set(legacyDomain, domain);
      }

      const legacyToolName = buildToolName('getPeople', legacyDomain);

      let currentDomain = '';
      let matchedKey = '';
      for (const [key, canonical] of normalizedToDomain.entries()) {
        if (legacyToolName.includes(key)) {
          currentDomain = canonical;
          matchedKey = key;
          break;
        }
      }

      expect(currentDomain).toBe(domain);
      expect(matchedKey).toBe(legacyDomain);

      const functionName = legacyToolName.replace(`${DELIM}${matchedKey}`, '');
      expect(functionName).toBe('getPeople');
    });

    it('new tool name matches via the canonical domain key', async () => {
      const metadataDomain = 'https://swapi.tech';
      const domain = await domainParser(metadataDomain, true);
      const legacyDomain = legacyDomainEncode(metadataDomain);

      const normalizedToDomain = new Map();
      normalizedToDomain.set(domain, domain);
      if (legacyDomain !== domain) {
        normalizedToDomain.set(legacyDomain, domain);
      }

      const newToolName = buildToolName('getPeople', domain);

      let currentDomain = '';
      let matchedKey = '';
      for (const [key, canonical] of normalizedToDomain.entries()) {
        if (newToolName.includes(key)) {
          currentDomain = canonical;
          matchedKey = key;
          break;
        }
      }

      expect(currentDomain).toBe(domain);
      expect(matchedKey).toBe(domain);

      const functionName = newToolName.replace(`${DELIM}${matchedKey}`, '');
      expect(functionName).toBe('getPeople');
    });
  });

  describe('save-route cleanup', () => {
    it('tool filter removes tools matching new encoding', async () => {
      const metadataDomain = 'https://swapi.tech';
      const domain = await domainParser(metadataDomain, true);
      const legacyDomain = legacyDomainEncode(metadataDomain);

      const tools = [
        buildToolName('getPeople', domain),
        buildToolName('unrelated', 'other---domain'),
      ];

      const filtered = tools.filter((t) => !t.includes(domain) && !t.includes(legacyDomain));

      expect(filtered).toEqual([buildToolName('unrelated', 'other---domain')]);
    });

    it('tool filter removes tools matching legacy encoding', async () => {
      const metadataDomain = 'https://swapi.tech';
      const domain = await domainParser(metadataDomain, true);
      const legacyDomain = legacyDomainEncode(metadataDomain);

      const tools = [
        buildToolName('getPeople', legacyDomain),
        buildToolName('unrelated', 'other---domain'),
      ];

      const filtered = tools.filter((t) => !t.includes(domain) && !t.includes(legacyDomain));

      expect(filtered).toEqual([buildToolName('unrelated', 'other---domain')]);
    });
  });

  describe('delete-route domain extraction', () => {
    it('domain extracted from actions array is usable as-is for tool filtering', async () => {
      const metadataDomain = 'https://api.example.com';
      const domain = await domainParser(metadataDomain, true);
      const actionId = 'abc123';
      const actionEntry = `${domain}${DELIM}${actionId}`;

      const [storedDomain] = actionEntry.split(DELIM);
      expect(storedDomain).toBe(domain);

      const tools = [buildToolName('getWeather', domain), buildToolName('getPeople', 'other')];

      const filtered = tools.filter((t) => !t.includes(storedDomain));
      expect(filtered).toEqual([buildToolName('getPeople', 'other')]);
    });
  });

  describe('multi-action agents (collision scenario)', () => {
    it('two https:// actions now produce distinct tool names', async () => {
      const domain1 = await domainParser('https://api.weather.com', true);
      const domain2 = await domainParser('https://api.spacex.com', true);

      const tool1 = buildToolName('getData', domain1);
      const tool2 = buildToolName('getData', domain2);

      expect(tool1).not.toBe(tool2);
    });

    it('two https:// actions used to collide in legacy encoding', () => {
      const legacy1 = legacyDomainEncode('https://api.weather.com');
      const legacy2 = legacyDomainEncode('https://api.spacex.com');

      const tool1 = buildToolName('getData', legacy1);
      const tool2 = buildToolName('getData', legacy2);

      expect(tool1).toBe(tool2);
    });
  });
});
