import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
  decideHtmlArtifactWrite,
  deriveHtmlSlug,
  deriveHtmlTitle,
  deriveMarkdownSlug,
  detectHtmlArtifact,
  HTML_ARTIFACT_MAX_BYTES,
  writeHtmlArtifact,
  writeMarkdownArtifact,
} from '../artifacts.js';
import { PathScopeError } from '../path_guard.js';

describe('deriveMarkdownSlug', () => {
  it('uses the first # heading when present', () => {
    expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
  });

  it('falls back to first 6 words', () => {
    const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
    expect(s).toBe('the-quick-brown-fox-jumps-over');
  });

  it('returns "artifact" for empty input', () => {
    expect(deriveMarkdownSlug('')).toBe('artifact');
  });

  it('caps at 60 chars and lowercases', () => {
    const long = '# ' + 'A'.repeat(200);
    const s = deriveMarkdownSlug(long);
    expect(s.length).toBeLessThanOrEqual(60);
    expect(s).toMatch(/^[a-z0-9-]+$/);
  });

  it('strips trailing punctuation', () => {
    expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
  });
});

describe('deriveHtmlSlug', () => {
  it('prefers payload.title when set', () => {
    expect(
      deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
    ).toBe('my-title');
  });

  it('falls back to <title> tag', () => {
    expect(
      deriveHtmlSlug({
        html_content: '<html><head><title>Page Title</title></head></html>',
        title: null,
      }),
    ).toBe('page-title');
  });

  it('falls back to first <h1> when no <title>', () => {
    expect(
      deriveHtmlSlug({
        html_content: '<html><body><h1>Heading One</h1></body></html>',
        title: null,
      }),
    ).toBe('heading-one');
  });

  it('falls back to inner text words', () => {
    expect(
      deriveHtmlSlug({
        html_content: '<div>one two three four five six seven</div>',
        title: null,
      }),
    ).toBe('one-two-three-four-five-six');
  });
});

describe('deriveHtmlTitle', () => {
  it('returns <title> content', () => {
    expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
  });

  it('falls back to <h1>', () => {
    expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
  });

  it('falls back to first 80 chars of inner text', () => {
    const html = '<div>' + 'x '.repeat(100) + '</div>';
    const t = deriveHtmlTitle(html);
    expect(t).not.toBeNull();
    expect(t!.length).toBeLessThanOrEqual(80);
  });

  it('returns null for empty html', () => {
    expect(deriveHtmlTitle('')).toBeNull();
  });
});

describe('detectHtmlArtifact', () => {
  it('detects <!DOCTYPE html> prefix case-insensitively', () => {
    const html = '<!doctype HTML><html><body>x</body></html>';
    expect(detectHtmlArtifact(html)).toBe(html);
  });

  it('strips leading/trailing whitespace before matching', () => {
    const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
    expect(detectHtmlArtifact(html)).toBe(html.trim());
  });

  it('detects fenced ```html block wrapping entire message', () => {
    const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
    expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
  });

  it('rejects plain markdown', () => {
    expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
  });

  it('rejects message with prose before the doctype', () => {
    expect(
      detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
    ).toBeNull();
  });

  it('rejects empty input', () => {
    expect(detectHtmlArtifact('')).toBeNull();
    expect(detectHtmlArtifact('   \n  ')).toBeNull();
  });

  it('rejects fenced block without doctype/<html>', () => {
    expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
  });

  it('accepts fenced block containing <html> tag (no doctype)', () => {
    const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
    expect(r).toContain('<html>');
  });
});

describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
  let projectRoot: string;
  beforeEach(async () => {
    projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
  });
  afterEach(async () => {
    await rm(projectRoot, { recursive: true, force: true });
  });

  it('writes a markdown artifact under .boocode/artifacts/', async () => {
    const result = await writeMarkdownArtifact(
      { content: '# Hello\n\nbody' },
      { projectId: 'pid', projectRoot },
    );
    expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
    expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
    const written = await readFile(result.path, 'utf8');
    expect(written).toBe('# Hello\n\nbody');
  });

  it('writes an html artifact', async () => {
    const result = await writeHtmlArtifact(
      {
        html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
        char_count: 56,
        title: 'X',
      },
      { projectId: 'pid', projectRoot },
    );
    expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
    const written = await readFile(result.path, 'utf8');
    expect(written).toContain('<!DOCTYPE html>');
  });

  it('creates the artifacts directory if absent', async () => {
    // Confirm the writer mkdir-recursive's the artifacts dir on first call.
    const result = await writeMarkdownArtifact(
      { content: '# T' },
      { projectId: 'pid', projectRoot },
    );
    expect(result.path).toContain('.boocode/artifacts');
  });
});

describe('1MB cap behavior', () => {
  it('reports the correct byte threshold', () => {
    expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
  });

  it('exceeds threshold for oversize payload', () => {
    const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
    expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
      HTML_ARTIFACT_MAX_BYTES,
    );
  });

  it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
    // Detection is content-shape; the cap check lives in finalizeCompletion
    // (error-handler.ts). This test pins that contract: the helper does not
    // silently drop oversize payloads on the floor.
    const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
    expect(detectHtmlArtifact(big)).not.toBeNull();
  });
});

describe('decideHtmlArtifactWrite', () => {
  // Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
  // the warn-and-skip decision without mocking the full InferenceContext.
  it('returns write=true for payloads under the cap', () => {
    const html = '<!DOCTYPE html><html></html>';
    const decision = decideHtmlArtifactWrite(html);
    expect(decision.write).toBe(true);
    expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
  });

  it('returns write=false with cap_exceeded reason for oversize payloads', () => {
    const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
    const decision = decideHtmlArtifactWrite(big);
    expect(decision.write).toBe(false);
    if (!decision.write) {
      expect(decision.reason).toBe('cap_exceeded');
      expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
    }
  });

  it('accepts payload exactly at the cap (boundary)', () => {
    // byteLen === cap should write; only strictly greater skips.
    const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
    const decision = decideHtmlArtifactWrite(exact);
    expect(decision.write).toBe(true);
    expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
  });
});

describe('symlink escape protection', () => {
  // Closes the gap where `.boocode/artifacts` is a symlink pointing
  // outside the project root. The lexical prefix check on the resolved
  // candidate path passes (it's under projectRoot textually), but the
  // post-mkdir realpath verification must catch the escape.
  let projectRoot: string;
  let outside: string;
  beforeEach(async () => {
    projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
    outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
  });
  afterEach(async () => {
    await rm(projectRoot, { recursive: true, force: true });
    await rm(outside, { recursive: true, force: true });
  });

  it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
    // Create .boocode dir, then make `artifacts` a symlink pointing outside.
    await mkdir(join(projectRoot, '.boocode'), { recursive: true });
    await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
    await expect(
      writeMarkdownArtifact(
        { content: '# Hello' },
        { projectId: 'pid', projectRoot },
      ),
    ).rejects.toBeInstanceOf(PathScopeError);
  });
});
