import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, writeFile, rm, utimes } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
  loadContainerGuidance,
  getContainerGuidance,
  buildSystemPrompt,
  buildSystemPromptWithFingerprint,
  _resetContainerGuidanceCacheForTests,
  _resetPrefixObserverForTests,
} from '../system-prompt.js';
import type { Agent, Project, Session } from '../../types/api.js';

// ---- fixtures ---------------------------------------------------------------

let tmpDir: string;

beforeEach(async () => {
  tmpDir = await mkdtemp(join(tmpdir(), 'system-prompt-test-'));
  _resetContainerGuidanceCacheForTests();
  _resetPrefixObserverForTests();
  delete process.env['CONTAINER_GUIDANCE_FILE'];
});

afterEach(async () => {
  delete process.env['CONTAINER_GUIDANCE_FILE'];
  _resetContainerGuidanceCacheForTests();
  _resetPrefixObserverForTests();
  await rm(tmpDir, { recursive: true, force: true });
});

function makeSession(overrides: Partial<Session> = {}): Session {
  return {
    id: 'sess',
    project_id: 'proj',
    name: 'test session',
    model: 'test-model',
    system_prompt: '',
    status: 'open',
    created_at: new Date(0).toISOString(),
    updated_at: new Date(0).toISOString(),
    agent_id: null,
    web_search_enabled: null,
    ...overrides,
  };
}

function makeProject(overrides: Partial<Project> = {}): Project {
  return {
    id: 'proj',
    name: 'test project',
    path: '/tmp/proj',
    added_at: new Date(0).toISOString(),
    last_session_id: null,
    status: 'open',
    gitea_remote: null,
    default_system_prompt: '',
    default_web_search_enabled: false,
    ...overrides,
  };
}

function makeAgent(overrides: Partial<Agent> = {}): Agent {
  return {
    id: 'agent-foo',
    name: 'foo',
    description: 'test agent',
    system_prompt: 'Speak in haiku.',
    temperature: 0.3,
    tools: ['view_file'],
    model: null,
    source: 'global',
    max_tool_calls: null,
    ...overrides,
  };
}

// ---- tests ------------------------------------------------------------------

describe('loadContainerGuidance', () => {
  it('returns file content when CONTAINER_GUIDANCE_FILE points to an existing file', async () => {
    const path = join(tmpDir, 'BOOCHAT.md');
    await writeFile(path, 'hello from BOOCHAT', 'utf8');
    process.env['CONTAINER_GUIDANCE_FILE'] = path;
    const result = await loadContainerGuidance();
    expect(result).toBe('hello from BOOCHAT');
  });

  it('returns null when the env var points to a non-existent file', async () => {
    process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'does-not-exist.md');
    const result = await loadContainerGuidance();
    expect(result).toBeNull();
  });

  it('returns null when the env var is unset and /app/BOOCHAT.md does not exist', async () => {
    // env var deleted in beforeEach; /app/BOOCHAT.md doesn't exist on the
    // host (the prod path only resolves inside the container).
    const result = await loadContainerGuidance();
    expect(result).toBeNull();
  });
});

describe('getContainerGuidance (mtime-watch cache)', () => {
  it('caches the content across calls when the file mtime is unchanged', async () => {
    const path = join(tmpDir, 'BOOCHAT.md');
    await writeFile(path, 'first content', 'utf8');
    // Pin mtime to a known Date BEFORE the first call so we can restore it
    // exactly after the rewrite. Capturing s.mtime then writing+restoring is
    // unreliable because Date round-trips truncate sub-millisecond precision
    // that the filesystem reports back via stat.mtimeMs.
    const fixedTime = new Date(2020, 0, 1, 12, 0, 0);
    await utimes(path, fixedTime, fixedTime);
    process.env['CONTAINER_GUIDANCE_FILE'] = path;

    const first = await getContainerGuidance();
    expect(first).toBe('first content');

    // Rewrite the file with different content, then restore mtime to the
    // same fixedTime. The cache must NOT re-read because the stat is
    // unchanged from its point of view.
    await writeFile(path, 'NEW content the cache must NOT see', 'utf8');
    await utimes(path, fixedTime, fixedTime);

    const second = await getContainerGuidance();
    expect(second).toBe('first content');
  });

  it('re-reads the file when the mtime changes', async () => {
    const path = join(tmpDir, 'BOOCHAT.md');
    await writeFile(path, 'first content', 'utf8');
    process.env['CONTAINER_GUIDANCE_FILE'] = path;
    const first = await getContainerGuidance();
    expect(first).toBe('first content');

    // Bump mtime explicitly so the test doesn't race the filesystem's mtime
    // resolution. Future time → guaranteed different from the cached value.
    await writeFile(path, 'edited content', 'utf8');
    const later = new Date(Date.now() + 60_000);
    await utimes(path, later, later);

    const second = await getContainerGuidance();
    expect(second).toBe('edited content');
  });
});

describe('buildSystemPrompt', () => {
  it('includes the guidance block between the base prompt and the agent overlay when guidance is non-null', async () => {
    const path = join(tmpDir, 'BOOCHAT.md');
    await writeFile(path, 'CONTAINER RULES GO HERE', 'utf8');
    process.env['CONTAINER_GUIDANCE_FILE'] = path;

    const session = makeSession();
    const project = makeProject({ path: '/tmp/test-proj' });
    const agent = makeAgent({ system_prompt: 'Speak in haiku.' });

    const prompt = await buildSystemPrompt(project, session, agent);

    const baseIdx = prompt.indexOf('/tmp/test-proj');
    const guidanceIdx = prompt.indexOf('CONTAINER RULES GO HERE');
    const agentIdx = prompt.indexOf('Speak in haiku.');
    expect(baseIdx).toBeGreaterThanOrEqual(0);
    expect(guidanceIdx).toBeGreaterThan(baseIdx);
    expect(agentIdx).toBeGreaterThan(guidanceIdx);
    expect(prompt).toContain('--- Container guidance ---');
    expect(prompt).toContain('--- end container guidance ---');
  });

  it('omits the guidance block entirely (no delimiters) when guidance is null', async () => {
    // Env var points to a non-existent file → getContainerGuidance returns null.
    process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'never-existed.md');

    const session = makeSession();
    const project = makeProject({ path: '/tmp/test-proj' });

    const prompt = await buildSystemPrompt(project, session, null);

    expect(prompt).toContain('/tmp/test-proj');
    expect(prompt).not.toContain('--- Container guidance ---');
    expect(prompt).not.toContain('--- end container guidance ---');
  });
});

// v1.13.8: byte-stability instrumentation surface.
describe('buildSystemPromptWithFingerprint (v1.13.8)', () => {
  it('returns byte-identical prompts for two consecutive calls with the same inputs', async () => {
    const path = join(tmpDir, 'BOOCHAT.md');
    await writeFile(path, 'stable guidance', 'utf8');
    process.env['CONTAINER_GUIDANCE_FILE'] = path;

    const session = makeSession();
    const project = makeProject({ path: '/tmp/stable-proj' });
    const agent = makeAgent({ system_prompt: 'be terse' });

    const first = await buildSystemPromptWithFingerprint(project, session, agent);
    const second = await buildSystemPromptWithFingerprint(project, session, agent);

    expect(first.prompt).toBe(second.prompt);
    expect(first.fingerprint.prefix_hash).toBe(second.fingerprint.prefix_hash);
    expect(first.fingerprint.prefix_length).toBe(second.fingerprint.prefix_length);
  });

  it('emits drift=null on the first call for a fresh session, then null again when nothing changes', async () => {
    process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md');
    const session = makeSession();
    const project = makeProject({ path: '/tmp/stable-proj' });

    const first = await buildSystemPromptWithFingerprint(project, session, null);
    expect(first.drift).toBeNull();

    const second = await buildSystemPromptWithFingerprint(project, session, null);
    expect(second.drift).toBeNull();
    expect(second.fingerprint.prefix_hash).toBe(first.fingerprint.prefix_hash);
  });

  it('emits drift with prev/new hashes and a changed_inputs entry when an input mutates', async () => {
    // Two BOOCHAT.md contents with different mtimes → guidance cache picks
    // up the change → fingerprint hash flips → drift fires.
    const path = join(tmpDir, 'BOOCHAT.md');
    await writeFile(path, 'first', 'utf8');
    process.env['CONTAINER_GUIDANCE_FILE'] = path;

    const session = makeSession();
    const project = makeProject({ path: '/tmp/stable-proj' });

    const first = await buildSystemPromptWithFingerprint(project, session, null);
    expect(first.drift).toBeNull();

    await writeFile(path, 'second — different content', 'utf8');
    const later = new Date(Date.now() + 60_000);
    await utimes(path, later, later);

    const second = await buildSystemPromptWithFingerprint(project, session, null);
    expect(second.drift).not.toBeNull();
    expect(second.drift!.prev_hash).toBe(first.fingerprint.prefix_hash);
    expect(second.drift!.new_hash).toBe(second.fingerprint.prefix_hash);
    expect(second.drift!.prev_hash).not.toBe(second.drift!.new_hash);
    expect(second.drift!.changed_inputs).toContain('mtime_boochat');
  });

  it('does not fire drift across distinct sessions even if their hashes differ', async () => {
    process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md');
    const sessionA = makeSession({ id: 'sess-A' });
    const sessionB = makeSession({ id: 'sess-B', system_prompt: 'B-only override' });
    const project = makeProject({ path: '/tmp/stable-proj' });

    const a = await buildSystemPromptWithFingerprint(project, sessionA, null);
    const b = await buildSystemPromptWithFingerprint(project, sessionB, null);

    expect(a.drift).toBeNull();
    expect(b.drift).toBeNull();
    expect(a.fingerprint.prefix_hash).not.toBe(b.fingerprint.prefix_hash);
  });
});
