/**
 * Tests for the event-driven plan capture module.
 *
 * Covers:
 * - isInPlanDirectory: absolute path match, relative path match, non-match, multiple watch dirs
 * - isPlanWriteEvent: tool name filtering, file_path extraction, path extraction, non-plan dirs
 * - parsePlanTitle: heading extraction, filename fallback, null when neither
 * - capturePlan: creates plan with session association, updates on re-capture, does NOT reset embedded flag when content unchanged
 */

import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import os from 'node:os';
import path from 'node:path';
import { setupTestDb, cleanTestDb, teardownTestDb } from '../helpers/db';
import { upsertSession } from '@myco/db/queries/sessions.js';
import { getPlan, listPlansBySession } from '@myco/db/queries/plans.js';
import { insertBatch } from '@myco/db/queries/batches.js';
import { getDatabase } from '@myco/db/client.js';
import {
  isInPlanDirectory,
  isPlanWriteEvent,
  parsePlanTitle,
  capturePlan,
  captureTaggedPlan,
  persistPlan,
  resolvePlanWatchDir,
  extractTaggedPlans,
  selectAuthoredPlanWrites,
  type PlanWatchConfig,
  type PlanWriteActivity,
} from '@myco/daemon/plan-capture.js';
import { buildPathPlanLogicalKey, buildFilePlanLogicalKey } from '@myco/plans/identity.js';
import type { Logger } from '@myco/daemon/logger.js';
import { ALL_PROJECTS_SCOPE, projectScope, type GroveProjectId } from '@myco/grove/ids.js';

/** Epoch seconds helper. */
const epochNow = () => Math.floor(Date.now() / 1000);

// ---------------------------------------------------------------------------
// isInPlanDirectory
// ---------------------------------------------------------------------------

describe('isInPlanDirectory', () => {
  const projectRoot = '/home/user/myproject';
  const watchDirs = ['docs/plans', 'tasks'];

  it('matches an absolute path inside a watch dir', () => {
    expect(isInPlanDirectory('/home/user/myproject/docs/plans/sprint.md', watchDirs, projectRoot)).toBe(true);
  });

  it('matches a relative path inside a watch dir', () => {
    expect(isInPlanDirectory('docs/plans/sprint.md', watchDirs, projectRoot)).toBe(true);
  });

  it('returns false for a path outside all watch dirs', () => {
    expect(isInPlanDirectory('/home/user/myproject/src/index.ts', watchDirs, projectRoot)).toBe(false);
  });

  it('matches the second of multiple watch dirs', () => {
    expect(isInPlanDirectory('tasks/todo.md', watchDirs, projectRoot)).toBe(true);
  });

  it('returns false for a path that only partially matches a watch dir name', () => {
    // docs/plans-extra is NOT inside docs/plans
    expect(isInPlanDirectory('/home/user/myproject/docs/plans-extra/file.md', watchDirs, projectRoot)).toBe(false);
  });

  it('matches a deeply nested file inside a watch dir', () => {
    expect(isInPlanDirectory('docs/plans/2026/q1/sprint.md', watchDirs, projectRoot)).toBe(true);
  });

  it('returns false when watchDirs is empty', () => {
    expect(isInPlanDirectory('docs/plans/sprint.md', [], projectRoot)).toBe(false);
  });

  it('uses an absolute path for the watch dir itself', () => {
    const absDirs = ['/home/user/myproject/docs/plans'];
    expect(isInPlanDirectory('/home/user/myproject/docs/plans/sprint.md', absDirs, projectRoot)).toBe(true);
  });
});

describe('resolvePlanWatchDir', () => {
  it('expands home-relative watch dirs', () => {
    expect(resolvePlanWatchDir('~/plans', '/tmp/project')).toBe(path.join(os.homedir(), 'plans'));
  });
});

// ---------------------------------------------------------------------------
// isPlanWriteEvent
// ---------------------------------------------------------------------------

describe('isPlanWriteEvent', () => {
  const base: PlanWatchConfig = {
    watchDirs: ['docs/plans'],
    projectRoot: '/home/user/myproject',
  };

  it('returns file_path for a Write tool targeting a plan dir', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/sprint.md' }, base))
      .toBe('/home/user/myproject/docs/plans/sprint.md');
  });

  it('returns file path for an Edit tool', () => {
    expect(isPlanWriteEvent('Edit', { file_path: '/home/user/myproject/docs/plans/sprint.md' }, base))
      .toBe('/home/user/myproject/docs/plans/sprint.md');
  });

  it('returns file path for a Create tool', () => {
    expect(isPlanWriteEvent('Create', { file_path: 'docs/plans/new.md' }, base))
      .toBe('docs/plans/new.md');
  });

  it('extracts path from toolInput.path when file_path is absent', () => {
    expect(isPlanWriteEvent('Write', { path: 'docs/plans/sprint.md' }, base))
      .toBe('docs/plans/sprint.md');
  });

  it('returns null for a Read tool (non-write)', () => {
    expect(isPlanWriteEvent('Read', { file_path: '/home/user/myproject/docs/plans/sprint.md' }, base))
      .toBeNull();
  });

  it('returns null for a Bash tool', () => {
    expect(isPlanWriteEvent('Bash', { command: 'echo hello' }, base)).toBeNull();
  });

  it('returns null when file path is outside plan dirs', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/src/index.ts' }, base))
      .toBeNull();
  });

  it('returns null when toolInput is undefined', () => {
    expect(isPlanWriteEvent('Write', undefined, base)).toBeNull();
  });

  it('returns null when toolInput has no file_path or path', () => {
    expect(isPlanWriteEvent('Write', { content: 'hello' }, base)).toBeNull();
  });

  it('prefers file_path over path when both are present', () => {
    expect(isPlanWriteEvent('Write', { file_path: 'docs/plans/a.md', path: 'docs/plans/b.md' }, base))
      .toBe('docs/plans/a.md');
  });

  // Extension filtering

  it('returns file path when extension matches the allowed list', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/sprint.md' }, { ...base, extensions: ['.md'] }))
      .toBe('/home/user/myproject/docs/plans/sprint.md');
  });

  it('returns null when extension does not match the allowed list', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/sprint.ts' }, { ...base, extensions: ['.md'] }))
      .toBeNull();
  });

  it('extension check is case-insensitive', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/sprint.MD' }, { ...base, extensions: ['.md'] }))
      .toBe('/home/user/myproject/docs/plans/sprint.MD');
  });

  it('returns file path for any extension when extensions list is empty', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/sprint.ts' }, { ...base, extensions: [] }))
      .toBe('/home/user/myproject/docs/plans/sprint.ts');
  });

  it('returns file path for any extension when extensions not set', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/sprint.ts' }, base))
      .toBe('/home/user/myproject/docs/plans/sprint.ts');
  });

  it('matches when multiple extensions are allowed', () => {
    const cfg = { ...base, extensions: ['.md', '.txt', '.yaml'] };
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/a.txt' }, cfg))
      .toBe('/home/user/myproject/docs/plans/a.txt');
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/b.yaml' }, cfg))
      .toBe('/home/user/myproject/docs/plans/b.yaml');
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/c.ts' }, cfg))
      .toBeNull();
  });

  // opencode tool conventions: lowercase tool names, camelCase filePath
  // Without these, opencode Plan mode writes to .opencode/plans/*.md are silently dropped.

  it('matches opencode lowercase write tool', () => {
    expect(isPlanWriteEvent('write', { file_path: 'docs/plans/sprint.md' }, base))
      .toBe('docs/plans/sprint.md');
  });

  it('matches opencode lowercase edit tool', () => {
    expect(isPlanWriteEvent('edit', { file_path: 'docs/plans/sprint.md' }, base))
      .toBe('docs/plans/sprint.md');
  });

  it('matches opencode patch tool (unified-diff applier)', () => {
    expect(isPlanWriteEvent('patch', { file_path: 'docs/plans/sprint.md' }, base))
      .toBe('docs/plans/sprint.md');
  });

  it('matches opencode lowercase create tool', () => {
    expect(isPlanWriteEvent('create', { file_path: 'docs/plans/new.md' }, base))
      .toBe('docs/plans/new.md');
  });

  it('extracts path from camelCase filePath field (opencode convention)', () => {
    expect(isPlanWriteEvent('write', { filePath: 'docs/plans/sprint.md' }, base))
      .toBe('docs/plans/sprint.md');
  });

  it('combined: opencode lowercase tool + camelCase filePath field', () => {
    expect(isPlanWriteEvent('edit', { filePath: 'docs/plans/feature.md' }, base))
      .toBe('docs/plans/feature.md');
  });

  it('still prefers snake_case file_path over camelCase filePath when both present', () => {
    expect(isPlanWriteEvent('write', {
      file_path: 'docs/plans/a.md',
      filePath: 'docs/plans/b.md',
    }, base)).toBe('docs/plans/a.md');
  });

  it('regression: Claude Code PascalCase Write still works alongside lowercase addition', () => {
    expect(isPlanWriteEvent('Write', { file_path: '/home/user/myproject/docs/plans/sprint.md' }, base))
      .toBe('/home/user/myproject/docs/plans/sprint.md');
  });
});

// ---------------------------------------------------------------------------
// parsePlanTitle
// ---------------------------------------------------------------------------

describe('parsePlanTitle', () => {
  it('extracts title from the first # heading', () => {
    const content = '# My Sprint Plan\n\nSome content here.';
    expect(parsePlanTitle(content)).toBe('My Sprint Plan');
  });

  it('ignores ## and deeper headings', () => {
    const content = '## Not a top-level heading\n\nSome content.';
    expect(parsePlanTitle(content)).toBeNull();
  });

  it('falls back to filename when no heading is found', () => {
    const content = 'Just some plain text without a heading.';
    expect(parsePlanTitle(content, 'sprint.md')).toBe('sprint.md');
  });

  it('returns null when content has no heading and no filename given', () => {
    const content = 'Just plain text.';
    expect(parsePlanTitle(content)).toBeNull();
  });

  it('trims whitespace around the heading text', () => {
    const content = '#   My Plan Title   \n\nContent.';
    expect(parsePlanTitle(content)).toBe('My Plan Title');
  });

  it('uses heading even when filename is also provided', () => {
    const content = '# Heading Title\n\nContent.';
    expect(parsePlanTitle(content, 'fallback.md')).toBe('Heading Title');
  });

  it('handles empty content with filename fallback', () => {
    expect(parsePlanTitle('', 'myplan.md')).toBe('myplan.md');
  });

  it('returns null for empty content with no filename', () => {
    expect(parsePlanTitle('')).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// capturePlan
// ---------------------------------------------------------------------------

describe('capturePlan', () => {
  beforeAll(() => { setupTestDb(); });
  afterAll(() => { teardownTestDb(); });
  beforeEach(() => { cleanTestDb(); });

  it('creates a plan row with correct fields', () => {
    const sessionId = 'test-capture-plan-001';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const result = capturePlan({
      sourcePath: '/home/user/myproject/docs/plans/sprint.md',
      content: '# Sprint Plan\n\nThis is the plan.',
      sessionId,
    });

    expect(result.id).toHaveLength(16);
    expect(result.logical_key).toBe(`session:${sessionId}:file:/home/user/myproject/docs/plans/sprint.md`);
    expect(result.title).toBe('Sprint Plan');
    expect(result.content).toBe('# Sprint Plan\n\nThis is the plan.');
    expect(result.source_path).toBe('/home/user/myproject/docs/plans/sprint.md');
    expect(result.session_id).toBe(sessionId);
    expect(result.prompt_batch_id).toBeNull();
    expect(result.content_hash).toBeTruthy();
    expect(result.status).toBe('active');
    expect(result.embedded).toBe(0);
  });

  it('keys a captured file plan under the session-scoped file namespace', () => {
    const sessionId = 'test-capture-plan-file-key';
    const now = epochNow();
    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });
    const result = capturePlan({
      sourcePath: 'docs/plans/alpha.md',
      projectRoot: '/tmp/project',
      content: '# Alpha',
      sessionId,
    });
    expect(result.logical_key).toBe(buildFilePlanLogicalKey(sessionId, 'docs/plans/alpha.md'));
    expect(result.source_path).toBe('docs/plans/alpha.md');
  });

  it('stores the plan in the database', () => {
    const sessionId = 'test-capture-plan-002';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const result = capturePlan({
      sourcePath: '/home/user/myproject/docs/plans/q1.md',
      content: '# Q1 Plan\n\nContent.',
      sessionId,
    });

    const stored = getPlan(result.id, ALL_PROJECTS_SCOPE);
    expect(stored).not.toBeNull();
    expect(stored!.id).toBe(result.id);
    expect(stored!.title).toBe('Q1 Plan');
  });

  it('is associated with the session', () => {
    const sessionId = 'test-capture-plan-003';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    capturePlan({
      sourcePath: '/home/user/myproject/docs/plans/sprint.md',
      content: '# Sprint\n\nContent.',
      sessionId,
    });

    const plans = listPlansBySession(sessionId, ALL_PROJECTS_SCOPE);
    expect(plans.length).toBe(1);
    expect(plans[0].session_id).toBe(sessionId);
  });

  it('uses deterministic ID based on sourcePath — same file upserts', () => {
    const sessionId = 'test-capture-plan-004';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const sourcePath = '/home/user/myproject/docs/plans/sprint.md';

    const first = capturePlan({
      sourcePath,
      content: '# Sprint\n\nOriginal content.',
      sessionId,
    });

    const second = capturePlan({
      sourcePath,
      content: '# Sprint\n\nUpdated content.',
      sessionId,
    });

    // Same ID because same sourcePath
    expect(second.id).toBe(first.id);

    // Content is updated
    const stored = getPlan(first.id, ALL_PROJECTS_SCOPE);
    expect(stored!.content).toBe('# Sprint\n\nUpdated content.');
  });

  it('updates content_hash when content changes', () => {
    const sessionId = 'test-capture-plan-005';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const sourcePath = '/home/user/myproject/docs/plans/sprint.md';

    const first = capturePlan({
      sourcePath,
      content: '# Sprint\n\nOriginal.',
      sessionId,
    });

    const second = capturePlan({
      sourcePath,
      content: '# Sprint\n\nChanged.',
      sessionId,
    });

    expect(second.content_hash).not.toBe(first.content_hash);
  });

  it('does NOT reset embedded flag when content is unchanged', () => {
    const sessionId = 'test-capture-plan-006';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const sourcePath = '/home/user/myproject/docs/plans/sprint.md';
    const content = '# Sprint\n\nSame content.';

    const first = capturePlan({ sourcePath, content, sessionId });

    // Manually mark as embedded
    getDatabase().prepare('UPDATE plans SET embedded = 1 WHERE id = ?').run(first.id);

    // Re-capture with identical content
    const second = capturePlan({ sourcePath, content, sessionId });

    // embedded flag should remain 1 (not reset) because content_hash is unchanged
    expect(second.embedded).toBe(1);
  });

  it('resets embedded flag when content changes', () => {
    const sessionId = 'test-capture-plan-007';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const sourcePath = '/home/user/myproject/docs/plans/sprint.md';

    const first = capturePlan({
      sourcePath,
      content: '# Sprint\n\nOriginal.',
      sessionId,
    });

    // Manually mark as embedded
    getDatabase().prepare('UPDATE plans SET embedded = 1 WHERE id = ?').run(first.id);

    // Re-capture with changed content — should reset embedded
    const second = capturePlan({
      sourcePath,
      content: '# Sprint\n\nChanged content!',
      sessionId,
    });

    expect(second.embedded).toBe(0);
  });

  it('stores prompt_batch_id when provided', () => {
    const sessionId = 'test-capture-plan-008';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    // Insert a real batch so the FK constraint is satisfied
    const batch = insertBatch({
      session_id: sessionId,
      prompt_number: 1,
      user_prompt: 'test prompt',
      started_at: now,
      created_at: now,
    });

    const result = capturePlan({
      sourcePath: '/home/user/myproject/docs/plans/sprint.md',
      content: '# Sprint\n\nContent.',
      sessionId,
      promptBatchId: batch.id,
    });

    expect(result.prompt_batch_id).toBe(batch.id);
  });

  it('stores Grove project scope for captured file plans and transcript tags', () => {
    const sessionId = 'test-capture-plan-project';
    const now = epochNow();
    const projectId = 'proj_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as GroveProjectId;

    upsertSession({
      id: sessionId,
      project_id: projectId,
      agent: 'claude-code',
      started_at: now,
      created_at: now,
    });

    const filePlan = capturePlan({
      sourcePath: '/home/user/myproject/docs/plans/scoped.md',
      content: '# Scoped Plan\n\nContent.',
      sessionId,
      projectId,
    });
    const taggedPlan = captureTaggedPlan({
      tag: 'proposed_plan',
      content: 'Tag content.',
      sessionId,
      projectId,
    });

    expect(filePlan.project_id).toBe(projectId);
    expect(taggedPlan.project_id).toBe(projectId);
    expect(getPlan(filePlan.id, projectScope(projectId))?.project_id).toBe(projectId);
    expect(getPlan(taggedPlan.id, projectScope(projectId))?.project_id).toBe(projectId);
  });

  it('falls back to filename as title when no heading present', () => {
    const sessionId = 'test-capture-plan-009';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const result = capturePlan({
      sourcePath: '/home/user/myproject/docs/plans/roadmap.md',
      content: 'No heading here, just plain content.',
      sessionId,
    });

    expect(result.title).toBe('roadmap.md');
  });

  it('captures transcript tags as session-scoped logical keys', () => {
    const sessionId = 'test-capture-plan-010';
    const now = epochNow();

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const result = captureTaggedPlan({
      tag: 'proposed_plan',
      content: 'No heading, just content.',
      sessionId,
    });

    expect(result.source_path).toBe('transcript:proposed_plan');
    expect(result.logical_key).toBe(`session:${sessionId}:tag:proposed_plan`);
    expect(result.title).toBe('Proposed Plan');
  });

  it('preserves an explicit non-active status across file recapture', () => {
    const sessionId = 'test-capture-plan-011';
    const now = epochNow();
    const sourcePath = '/home/user/myproject/docs/plans/sprint.md';

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    persistPlan({
      sessionId,
      content: '# Sprint\n\nInitial content.',
      logicalKey: buildFilePlanLogicalKey(sessionId, sourcePath),
      sourcePath,
      status: 'completed',
    });

    const recaptured = capturePlan({
      sourcePath,
      content: '# Sprint\n\nUpdated content.',
      sessionId,
    });

    expect(recaptured.status).toBe('completed');
  });
});

// ---------------------------------------------------------------------------
// Cross-channel overwrite detection (Finding #11)
// ---------------------------------------------------------------------------

describe('persistPlan cross-channel overwrite detection', () => {
  beforeAll(() => { setupTestDb(); });
  afterAll(() => { teardownTestDb(); });
  beforeEach(() => { cleanTestDb(); });

  function makeLogger(): Logger {
    return {
      debug: () => {},
      info: () => {},
      warn: (_kind: string, _message: string, data?: Record<string, unknown>) => {
        warnCalls.push([_kind, _message, data]);
      },
      error: () => {},
    };
  }
  let warnCalls: unknown[][] = [];

  it('short-circuits as a no-op when the same content+title is written twice on the same logical_key', () => {
    const sessionId = 'test-overwrite-noop';
    const now = epochNow();
    const sourcePath = '/home/user/myproject/docs/plans/sprint.md';

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    const first = persistPlan({
      sessionId,
      content: '# Sprint\n\nSame content.',
      logicalKey: buildPathPlanLogicalKey(sourcePath),
      sourcePath,
    });
    const firstUpdatedAt = first.updated_at;

    const db = getDatabase();
    const countOutboxRows = (): number => {
      const row = db
        .prepare("SELECT COUNT(*) AS n FROM team_outbox WHERE table_name = 'plans' AND row_id = ?")
        .get(first.id) as { n: number };
      return row.n;
    };
    const outboxBefore = countOutboxRows();

    // Sleep a beat so updated_at would change if the write did run.
    const second = persistPlan({
      sessionId,
      content: '# Sprint\n\nSame content.',
      logicalKey: buildPathPlanLogicalKey(sourcePath),
      sourcePath,
      updatedAt: (firstUpdatedAt ?? now) + 10,
    });

    // No-op: the second call returns the existing row (same updated_at).
    expect(second.updated_at).toBe(firstUpdatedAt);
    expect(second.content_hash).toBe(first.content_hash);
    // And the no-op MUST NOT enqueue a new outbox row.
    expect(countOutboxRows()).toBe(outboxBefore);
  });

  it('warn-logs when the same logical_key is overwritten by a different source_path', () => {
    warnCalls = [];
    const logger = makeLogger();
    const sessionId = 'test-overwrite-warn';
    const now = epochNow();
    const logicalKey = `session:${sessionId}:key:primary`;

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    // First write: via MCP save_plan — no source_path (plan_key only)
    persistPlan({
      sessionId,
      content: 'Version A of the plan.',
      logicalKey,
      sourcePath: null,
      planKey: 'primary',
      logger,
    });

    // Second write: same logical_key, different content AND different source_path
    persistPlan({
      sessionId,
      content: 'Version B of the plan — completely different.',
      logicalKey,
      sourcePath: '/docs/plans/primary.md',
      planKey: 'primary',
      logger,
    });

    expect(warnCalls.length).toBeGreaterThanOrEqual(1);
    const firstWarn = warnCalls[0];
    expect(firstWarn[1]).toBe('Plan overwritten mid-session');
    const warnPayload = firstWarn[2] as Record<string, unknown>;
    expect(warnPayload.logical_key).toBe(logicalKey);
    expect(warnPayload.prior_source).toBeNull();
    expect(warnPayload.new_source).toBe('/docs/plans/primary.md');
  });

  it('does not warn when content changes but source_path is unchanged (normal edit)', () => {
    warnCalls = [];
    const logger = makeLogger();
    const sessionId = 'test-overwrite-sameroute';
    const now = epochNow();
    const sourcePath = '/docs/plans/primary.md';

    upsertSession({ id: sessionId, agent: 'claude-code', started_at: now, created_at: now });

    persistPlan({
      sessionId,
      content: 'Original.',
      logicalKey: buildPathPlanLogicalKey(sourcePath),
      sourcePath,
      logger,
    });
    persistPlan({
      sessionId,
      content: 'Updated content.',
      logicalKey: buildPathPlanLogicalKey(sourcePath),
      sourcePath,
      logger,
    });

    expect(warnCalls).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// extractTaggedPlans
// ---------------------------------------------------------------------------

describe('extractTaggedPlans', () => {
  it('extracts content from a single proposed_plan tag', () => {
    const text = 'Some preamble.\n<proposed_plan>\n# My Plan\n\n## Steps\n1. Do this\n</proposed_plan>\nSome epilogue.';
    const results = extractTaggedPlans(text, ['proposed_plan']);
    expect(results).toHaveLength(1);
    expect(results[0].tag).toBe('proposed_plan');
    expect(results[0].content).toBe('# My Plan\n\n## Steps\n1. Do this');
  });

  it('returns empty array when no tags match', () => {
    const text = 'Just regular text with no plan tags.';
    const results = extractTaggedPlans(text, ['proposed_plan']);
    expect(results).toEqual([]);
  });

  it('returns empty array when tags list is empty', () => {
    const text = '<proposed_plan>\n# Plan\n</proposed_plan>';
    const results = extractTaggedPlans(text, []);
    expect(results).toEqual([]);
  });

  it('extracts from multiple different tag names', () => {
    const text = '<proposed_plan>\n# Plan A\n</proposed_plan>\n\n<implementation_plan>\n# Plan B\n</implementation_plan>';
    const results = extractTaggedPlans(text, ['proposed_plan', 'implementation_plan']);
    expect(results).toHaveLength(2);
    expect(results[0].tag).toBe('proposed_plan');
    expect(results[0].content).toBe('# Plan A');
    expect(results[1].tag).toBe('implementation_plan');
    expect(results[1].content).toBe('# Plan B');
  });

  it('skips empty tag content', () => {
    const text = '<proposed_plan>\n\n</proposed_plan>';
    const results = extractTaggedPlans(text, ['proposed_plan']);
    expect(results).toEqual([]);
  });

  it('handles tag content without leading/trailing newlines', () => {
    const text = '<proposed_plan># Direct Plan</proposed_plan>';
    const results = extractTaggedPlans(text, ['proposed_plan']);
    expect(results).toHaveLength(1);
    expect(results[0].content).toBe('# Direct Plan');
  });

  it('handles multi-line markdown plan content', () => {
    const planMd = '# Comprehensive Plan\n\n## Summary\n\nBuild the thing.\n\n## Steps\n\n1. Step one\n2. Step two\n\n## Notes\n\n- Note A\n- Note B';
    const text = `<proposed_plan>\n${planMd}\n</proposed_plan>`;
    const results = extractTaggedPlans(text, ['proposed_plan']);
    expect(results).toHaveLength(1);
    expect(results[0].content).toBe(planMd);
  });

  it('returns all occurrences when same tag appears multiple times', () => {
    const text = '<proposed_plan>\n# Original Plan\n</proposed_plan>\n\nSome discussion.\n\n<proposed_plan>\n# Revised Plan\n</proposed_plan>';
    const results = extractTaggedPlans(text, ['proposed_plan']);
    expect(results).toHaveLength(2);
    expect(results[0].content).toBe('# Original Plan');
    expect(results[1].content).toBe('# Revised Plan');
  });
});

// ---------------------------------------------------------------------------
// selectAuthoredPlanWrites — authorship-from-activities (the regression guard)
// ---------------------------------------------------------------------------

describe('selectAuthoredPlanWrites', () => {
  const config: PlanWatchConfig = {
    watchDirs: ['docs/plans'],
    projectRoot: '/home/user/proj',
    extensions: ['.md'],
  };

  const act = (over: Partial<PlanWriteActivity>): PlanWriteActivity => ({
    tool_name: 'Write',
    file_path: 'docs/plans/sprint.md',
    prompt_batch_id: 1,
    timestamp: 100,
    ...over,
  });

  it('selects a plan-dir write activity as authorship', () => {
    const result = selectAuthoredPlanWrites([act({})], config);
    expect(result).toEqual([{ filePath: 'docs/plans/sprint.md', promptBatchId: 1 }]);
  });

  it('ignores writes outside any plan directory', () => {
    const result = selectAuthoredPlanWrites(
      [act({ file_path: 'src/index.ts' })],
      config,
    );
    expect(result).toEqual([]);
  });

  it('ignores non-write tools even when targeting a plan file (Read is not authorship)', () => {
    const result = selectAuthoredPlanWrites([act({ tool_name: 'Read' })], config);
    expect(result).toEqual([]);
  });

  it('ignores activities with no file_path', () => {
    const result = selectAuthoredPlanWrites([act({ file_path: null })], config);
    expect(result).toEqual([]);
  });

  it('dedupes repeated edits of one file, keeping the latest write batch', () => {
    const result = selectAuthoredPlanWrites(
      [
        act({ prompt_batch_id: 1, timestamp: 100 }),
        act({ tool_name: 'Edit', prompt_batch_id: 2, timestamp: 200 }),
        act({ tool_name: 'Edit', prompt_batch_id: 3, timestamp: 300 }),
      ],
      config,
    );
    expect(result).toEqual([{ filePath: 'docs/plans/sprint.md', promptBatchId: 3 }]);
  });

  it('returns one entry per distinct authored plan file', () => {
    const result = selectAuthoredPlanWrites(
      [
        act({ file_path: 'docs/plans/a.md', prompt_batch_id: 1 }),
        act({ file_path: 'docs/plans/b.md', prompt_batch_id: 2 }),
      ],
      config,
    );
    expect(result).toHaveLength(2);
    expect(result.map((w) => w.filePath).sort()).toEqual(['docs/plans/a.md', 'docs/plans/b.md']);
  });

  it('honors the extension filter (non-.md plan-dir writes are not plans)', () => {
    const result = selectAuthoredPlanWrites(
      [act({ file_path: 'docs/plans/notes.txt' })],
      config,
    );
    expect(result).toEqual([]);
  });

  it('detects authorship across symbionts (lowercase write tools, raw absolute paths)', () => {
    // opencode emits lowercase tool names; a symbiont may record an absolute path.
    const result = selectAuthoredPlanWrites(
      [
        act({ tool_name: 'write', file_path: '/home/user/proj/docs/plans/x.md', prompt_batch_id: 7 }),
        act({ tool_name: 'patch', file_path: '/home/user/proj/docs/plans/x.md', prompt_batch_id: 8, timestamp: 250 }),
      ],
      config,
    );
    expect(result).toEqual([
      { filePath: '/home/user/proj/docs/plans/x.md', promptBatchId: 8 },
    ]);
  });
});
