/**
 * End-to-end coverage for the v8 Cortex path unification.
 *
 * Each test walks the same path the live UI takes:
 *   ScopedField onChange → PUT /api/config/scoped → updateConfig →
 *   saveConfig → Zod parse → write to myco.yaml (or local.yaml) →
 *   GET /api/config/merged → assert the value landed at the new path.
 *
 * If any of the renames in v8 missed a callsite, a path string drifted,
 * or the patch endpoint can't deep-merge into the new shape, one of
 * these will fail. This is the safety net Chris asked for: the unit
 * suite confirmed each layer in isolation; this confirms the layers
 * stack correctly when patched through the live HTTP surface.
 */

import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import YAML from 'yaml';
import {
  handleGetMergedConfig,
  handlePutGroveConfig,
  handlePutScopedConfig,
} from '@myco/daemon/api/config';
import { CORTEX_PATHS, NOTIFICATIONS_PATHS } from '@myco/config/paths';
import type { MycoConfig } from '@myco/config/schema';

function seedFreshVault(dir: string): void {
  // Minimal myco.yaml — schema defaults fill in everything else, just
  // like a real install-bootstrap output.
  fs.writeFileSync(
    path.join(dir, 'myco.yaml'),
    `version: 3\nembedding:\n  provider: ollama\n  model: bge-m3\n`,
  );
}

function readYaml(file: string): Record<string, unknown> {
  return YAML.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>;
}

/**
 * Drill into a config object via a dotted path. Used to assert against
 * effective values returned from /api/config/merged.
 */
function getAtPath(obj: unknown, dotted: string): unknown {
  return dotted.split('.').reduce<unknown>(
    (acc, key) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined),
    obj,
  );
}

/**
 * Convert a dotted path + leaf value into the nested object the patch
 * endpoint expects. e.g. ('a.b.c', 5) → { a: { b: { c: 5 } } }.
 */
function patchFromPath(dotted: string, value: unknown): Record<string, unknown> {
  const parts = dotted.split('.');
  const result: Record<string, unknown> = {};
  let cursor: Record<string, unknown> = result;
  for (let i = 0; i < parts.length - 1; i++) {
    const next: Record<string, unknown> = {};
    cursor[parts[i]!] = next;
    cursor = next;
  }
  cursor[parts[parts.length - 1]!] = value;
  return result;
}

describe('PUT /api/config/scoped — cortex paths land at the v8 shape', () => {
  let tmpDir: string;
  let prevMycoHome: string | undefined;
  beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'myco-cortex-paths-'));
    seedFreshVault(tmpDir);
    // The v8 merged-config loader walks Grove tiers anchored at MYCO_HOME;
    // without a sandbox it picks up the dev machine's Grove(s) and the
    // project tier disappears from the merge.
    prevMycoHome = process.env.MYCO_HOME;
    process.env.MYCO_HOME = path.join(tmpDir, '.myco-home');
    fs.mkdirSync(process.env.MYCO_HOME, { recursive: true });
  });
  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
    if (prevMycoHome === undefined) delete process.env.MYCO_HOME;
    else process.env.MYCO_HOME = prevMycoHome;
  });

  /** Each entry: a path string, a non-default value to patch, and the expected return type. */
  const cases: Array<{ path: string; value: unknown }> = [
    { path: CORTEX_PATHS.enabled, value: false },
    { path: CORTEX_PATHS.instructions.injectOnSessionStart, value: false },
    { path: CORTEX_PATHS.instructions.injectOnSubagentStart, value: false },
    { path: CORTEX_PATHS.digest.tier, value: 10000 },
    { path: CORTEX_PATHS.digest.injectOnSessionStart, value: true },
    { path: CORTEX_PATHS.spores.injectOnPromptSubmit, value: false },
    { path: CORTEX_PATHS.spores.maxPerPrompt, value: 7 },
    { path: CORTEX_PATHS.canopy.injectOnPreToolUse, value: false },
    { path: CORTEX_PATHS.canopy.minFileBytes, value: 1500 },
    { path: CORTEX_PATHS.canopy.refresh.backgroundEnabled, value: false },
    { path: CORTEX_PATHS.canopy.refresh.backgroundPeriodMinutes, value: 15 },
    { path: CORTEX_PATHS.canopy.exclude.patterns, value: ['fixtures/large/**'] },
  ];

  for (const { path: configPath, value } of cases) {
    it(`project scope: writes ${configPath} = ${JSON.stringify(value)} to myco.yaml`, async () => {
      const res = await handlePutScopedConfig(tmpDir, {
        scope: 'project',
        patch: patchFromPath(configPath, value),
      });
      expect(res.status === undefined || res.status < 400).toBe(true);

      // Disk: the YAML on disk has the value at the canonical path.
      const onDisk = readYaml(path.join(tmpDir, 'myco.yaml'));
      expect(getAtPath(onDisk, configPath)).toEqual(value);

      // Effective: GET /merged returns the same value.
      const merged = await handleGetMergedConfig(tmpDir);
      expect(getAtPath(merged.body, configPath)).toEqual(value);
    });

    it(`local scope: writes ${configPath} = ${JSON.stringify(value)} to local.yaml`, async () => {
      const res = await handlePutScopedConfig(tmpDir, {
        scope: 'local',
        patch: patchFromPath(configPath, value),
      });
      expect(res.status === undefined || res.status < 400).toBe(true);

      // Disk: local.yaml carries the override; myco.yaml is unchanged.
      const local = readYaml(path.join(tmpDir, 'local.yaml'));
      expect(getAtPath(local, configPath)).toEqual(value);

      // Merged: project (default) overlaid with local (override) yields the override.
      const merged = await handleGetMergedConfig(tmpDir);
      expect(getAtPath(merged.body, configPath)).toEqual(value);
    });
  }

  it('clearing a cortex.* override drops it back to the project value', async () => {
    // 1. Set a project value.
    await handlePutScopedConfig(tmpDir, {
      scope: 'project',
      patch: { cortex: { spores: { max_per_prompt: 5 } } },
    });
    // 2. Override at local.
    await handlePutScopedConfig(tmpDir, {
      scope: 'local',
      patch: { cortex: { spores: { max_per_prompt: 9 } } },
    });
    let merged = await handleGetMergedConfig(tmpDir);
    expect((merged.body as MycoConfig).cortex.spores.max_per_prompt).toBe(9);

    // 3. Clear the local override.
    await handlePutScopedConfig(tmpDir, {
      scope: 'local',
      clear: [CORTEX_PATHS.spores.maxPerPrompt],
    });
    merged = await handleGetMergedConfig(tmpDir);
    expect((merged.body as MycoConfig).cortex.spores.max_per_prompt).toBe(5);
  });

  it('rejects an invalid cortex.canopy.min_file_bytes (non-integer)', async () => {
    const res = await handlePutScopedConfig(tmpDir, {
      scope: 'project',
      patch: { cortex: { canopy: { min_file_bytes: 1.5 } } },
    });
    expect(res.status).toBe(400);
    expect((res.body as { error: string }).error).toBe('validation_failed');
  });

  it('rejects an invalid cortex.spores.max_per_prompt (out of range)', async () => {
    const res = await handlePutScopedConfig(tmpDir, {
      scope: 'project',
      patch: { cortex: { spores: { max_per_prompt: 11 } } },
    });
    expect(res.status).toBe(400);
    expect((res.body as { error: string }).error).toBe('validation_failed');
  });

  it('a cortex.* patch deep-merges with sibling features (no clobbering)', async () => {
    // Patch one digest field; spores.max_per_prompt stays at default.
    await handlePutScopedConfig(tmpDir, {
      scope: 'project',
      patch: { cortex: { digest: { tier: 1500 } } },
    });
    const merged = await handleGetMergedConfig(tmpDir);
    const cfg = merged.body as MycoConfig;
    expect(cfg.cortex.digest.tier).toBe(1500);
    expect(cfg.cortex.spores.max_per_prompt).toBe(3); // default preserved
    expect(cfg.cortex.instructions.inject_on_session_start).toBe(true); // default preserved
    expect(cfg.cortex.canopy.inject_on_pre_tool_use).toBe(true); // default preserved
  });
});

describe('non-cortex paths still work post-v8 (regression check)', () => {
  let tmpDir: string;
  let prevMycoHome: string | undefined;
  beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'myco-non-cortex-'));
    seedFreshVault(tmpDir);
    prevMycoHome = process.env.MYCO_HOME;
    process.env.MYCO_HOME = path.join(tmpDir, '.myco-home');
    fs.mkdirSync(process.env.MYCO_HOME, { recursive: true });
  });
  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
    if (prevMycoHome === undefined) delete process.env.MYCO_HOME;
    else process.env.MYCO_HOME = prevMycoHome;
  });

  it('notifications.enabled toggles cleanly via the personal overlay (notifications.* is now Machine-tier)', async () => {
    // notifications.* moved to Machine tier (2026-06 scope correction), so a
    // project scoped write would be stripped from myco.yaml. A user can still
    // override it through the personal (local) overlay, which merges on top of
    // the machine value — that's the scoped-handler path this guards.
    const res = await handlePutScopedConfig(tmpDir, {
      scope: 'local',
      patch: patchFromPath(NOTIFICATIONS_PATHS.enabled, false),
    });
    expect(res.status === undefined || res.status < 400).toBe(true);
    const merged = await handleGetMergedConfig(tmpDir);
    expect((merged.body as MycoConfig).notifications.enabled).toBe(false);
  });

  it('appearance.theme writes through Grove config', async () => {
    const groveId = 'grove_' + 'c'.repeat(32);
    const res = await handlePutGroveConfig(groveId, {
      patch: { appearance: { theme: 'moss' } },
    });
    expect(res.response.status).toBeUndefined();
    const merged = await handleGetMergedConfig(tmpDir, { groveId });
    expect((merged.body as MycoConfig).appearance.theme).toBe('moss');
  });
});
