/**
 * uat/cli/lib/plantest-schema.ts — The `.plantest.yml` contract (Zod) + invariants.
 *
 * `.plantest.yml` is the route-major, agent-legible test plan that `/uat`
 * generates (uat-plan) and the UI run agent consumes. This module is the single
 * source of truth for its shape and the structural invariants a plan MUST hold
 * before any run. PURE: no I/O — parse a candidate object, get back either a
 * typed plan or human-readable errors + invariant violations.
 *
 * Ported & generalised from SmartStack.app's `test-app-plan` plan-schema.md (the
 * IA-runner contract): route-major + per-role visibility, plus two /uat additions
 * — `endpoints[]` (deterministic API axis) and `execution.perf` (per-page timings).
 */
import { z } from 'zod';

/**
 * Page/endpoint access verdict for a role. `partial` is NEVER stored — it is a
 * DERIVED display label (page allowed + >=1 subset legitimately hidden), computed
 * in access-classify.ts. The plan only encodes the three ground-truth verdicts.
 */
export const AccessVerdictSchema = z.enum(['allowed', 'denied', 'redirect_login']);
export type AccessVerdict = z.infer<typeof AccessVerdictSchema>;

/** Semantic taxonomy from the brief (onglet/edit/create/list/detail/action). */
export const SubsetTypeSchema = z.enum(['onglet', 'list', 'detail', 'create', 'edit', 'action']);
/** Mechanical nature of the subset node in the DOM. */
export const SubsetKindSchema = z.enum(['tab', 'button', 'form', 'list_item']);
/** What the runner DOES with the subset when it is visible for a role. */
export const ActionKindSchema = z.enum([
  'navigate',
  'click',
  'open_modal',
  'fill',
  'fill_dryrun',
  'select',
  'assert',
  'screenshot',
  'log_only',
]);
export const NavigationStrategySchema = z.enum(['menu_click', 'goto', 'click_row_in_parent']);
export const ExecutionModeSchema = z.enum(['bfs', 'goto']);

const RoleNameSchema = z.string().min(1);

const FormFieldSchema = z.object({
  name: z.string().min(1),
  type: z.string().min(1), // text | select | number | date | ...
});

const SubsetSchema = z.object({
  id: z.string().min(1),
  type: SubsetTypeSchema,
  kind: SubsetKindSchema,
  /** Action performed when the subset IS visible for the role under test. */
  action_kind: ActionKindSchema.default('click'),
  selector: z.string().min(1),
  /** Selector for the HIDDEN-assertion when a role is NOT in visible_for.
   *  Falls back to `selector` when absent. */
  assert_selector: z.string().optional(),
  /** Required when action_kind === 'open_modal'. */
  modal_indicator: z.string().optional(),
  /** Destructive subsets are PROVEN-visible but NEVER clicked (action_kind log_only). */
  is_destructive: z.boolean().default(false),
  /** Required when kind === 'form'. */
  fields: z.array(FormFieldSchema).optional(),
  /** For action_kind === 'navigate': where it should land. */
  navigates_to: z.string().optional(),
  expect_url: z.string().optional(),
  /** Roles for which this subset MUST be visible. Must be a subset of plan.roles. */
  visible_for: z.array(RoleNameSchema),
  /** Explicit expected result for this testable unit (D1 — every unit carries an expect). */
  expect: z.string().min(1),
});
export type Subset = z.infer<typeof SubsetSchema>;

const RouteExpectSchema = z.object({
  access_by_role: z.boolean().default(true),
  title: z.string().optional(),
  no_console_error: z.boolean().default(true),
});

const RouteSchema = z.object({
  id: z.string().min(1),
  component_key: z.string().optional(),
  route: z.string().min(1),
  permission: z.string().optional(),
  navigation_strategy: NavigationStrategySchema.default('menu_click'),
  /** Required when navigation_strategy === 'click_row_in_parent'. */
  parent_route: z.string().optional(),
  /** click_row_in_parent on an empty list yields INDETERMINATE, never `denied`. */
  on_empty_list: z.literal('INDETERMINATE').optional(),
  expect: RouteExpectSchema.default({ access_by_role: true, no_console_error: true }),
  /** Ground-truth verdict per role (every role present — invariant 3). */
  access: z.record(RoleNameSchema, AccessVerdictSchema),
  subsets: z.array(SubsetSchema).default([]),
});
export type Route = z.infer<typeof RouteSchema>;

const EndpointSecuritySchema = z.object({
  idor: z.object({ enabled: z.boolean(), strategy: z.string().optional() }).optional(),
  jwt: z.object({ enabled: z.boolean(), cases: z.array(z.string()).optional() }).optional(),
  xss: z.object({ enabled: z.boolean(), fields: z.array(z.string()).optional() }).optional(),
});

const EndpointSchema = z.object({
  id: z.string().min(1),
  method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
  route: z.string().min(1),
  controller: z.string().optional(),
  permission: z.string().optional(),
  /** Expected HTTP status per role (every role present — invariant 3). */
  expected_by_role: z.record(RoleNameSchema, z.number().int()),
  security: EndpointSecuritySchema.optional(),
});
export type Endpoint = z.infer<typeof EndpointSchema>;

const SourceSignatureSchema = z.object({
  nav_sha: z.string().optional(),
  rbac_sha: z.string().optional(),
  registry_sha: z.string().optional(),
});

const PerRoleSchema = z.object({
  mode: ExecutionModeSchema.optional(),
  expect: AccessVerdictSchema.optional(),
});

const ExecutionSchema = z.object({
  modes: z.array(ExecutionModeSchema).default(['bfs', 'goto']),
  caps: z
    .object({
      tabs: z.number().int().default(8),
      buttons: z.number().int().default(10),
      forms: z.number().int().default(3),
      depth: z.number().int().default(3),
      actions_per_role: z.number().int().default(500),
    })
    // Full literal (not `{}`): zod v4's `.default()` does NOT re-parse the default,
    // so an empty object would leave every field undefined at runtime. A complete
    // literal is correct under BOTH zod v3 (re-parsed) and v4 (returned as-is).
    .default({ tabs: 8, buttons: 10, forms: 3, depth: 3, actions_per_role: 500 }),
  /** ANTIDOTE to false positives (D-exec): wait for full readiness before asserting. */
  readiness: z
    .object({
      strategy: z.literal('fully_ready').default('fully_ready'),
      timeout_ms: z.number().int().default(12000),
      retry_on_not_ready: z.number().int().default(2),
    })
    .default({ strategy: 'fully_ready', timeout_ms: 12000, retry_on_not_ready: 2 }),
  /** Per-page display timings, measured AFTER fully_ready. Thresholds are WARNING. */
  perf: z
    .object({
      capture: z.array(z.enum(['ttfp_ms', 'fully_ready_ms', 'nav_ms'])).default(['ttfp_ms', 'fully_ready_ms', 'nav_ms']),
      warn_ms: z.number().int().default(3000),
      slow_ms: z.number().int().default(8000),
    })
    .default({ capture: ['ttfp_ms', 'fully_ready_ms', 'nav_ms'], warn_ms: 3000, slow_ms: 8000 }),
  screenshots: z.object({
    /** dev-browser sandbox dir the screenshots are written to first (F2). */
    sandbox_tmp: z.string().default('~/.dev-browser/tmp'),
    /** Project dir the screenshots are COPIED to after capture. */
    out_dir: z.string().min(1),
  }),
  per_role: z.record(RoleNameSchema, PerRoleSchema).default({}),
});

const DriftRuleSchema = z.object({
  severity: z.enum(['WARN', 'FAIL', 'BLOCKER']),
  fail_run: z.boolean().default(false),
  suggest: z.string().optional(),
});

const DriftPolicySchema = z
  .object({
    extra_subset_in_dom: DriftRuleSchema.default({ severity: 'WARN', fail_run: false }),
    missing_subset_in_dom: DriftRuleSchema.default({ severity: 'FAIL', fail_run: false }),
    overexposed_subset: DriftRuleSchema.default({ severity: 'BLOCKER', fail_run: false }),
    signature_mismatch: DriftRuleSchema.default({ severity: 'BLOCKER', fail_run: true }),
  })
  .default({
    extra_subset_in_dom: { severity: 'WARN', fail_run: false },
    missing_subset_in_dom: { severity: 'FAIL', fail_run: false },
    overexposed_subset: { severity: 'BLOCKER', fail_run: false },
    signature_mismatch: { severity: 'BLOCKER', fail_run: true },
  });

export const PlanTestSchema = z.object({
  schema_version: z.string().default('1.0.0'),
  meta: z.object({
    application: z.string().min(1),
    path: z.string().min(1),
    generated_at: z.string().optional(),
    source_signature: SourceSignatureSchema.default({}),
  }),
  roles: z.array(RoleNameSchema).min(1),
  execution: ExecutionSchema,
  routes: z.array(RouteSchema).default([]),
  endpoints: z.array(EndpointSchema).default([]),
  drift_policy: DriftPolicySchema,
});
export type PlanTest = z.infer<typeof PlanTestSchema>;

export interface InvariantViolation {
  /** Invariant number (1..8) — see validateInvariants(). */
  invariant: number;
  /** Locator, e.g. "USERS_LIST.btn_create_user". */
  where: string;
  message: string;
}

const UPPER_SNAKE = /^[A-Z0-9_]+$/;

/**
 * The structural invariants a `.plantest.yml` MUST hold before any run.
 *
 * Enforced here (1..8, structural):
 *   1. route ids are UPPER_SNAKE
 *   2. route.id, subset.id and endpoint.id are unique within their scope
 *   3. `access` / `expected_by_role` covers EVERY role
 *   4. subset.visible_for is a subset of plan.roles
 *   5. destructive subset => action_kind 'log_only'
 *   6. kind 'form' => non-empty fields[]
 *   7. action_kind 'open_modal' => modal_indicator set
 *   8. action_kind 'navigate' => expect_url OR navigates_to
 *
 * Invariants 9 (screenshot naming) and 10 (signature freshness) are RUNTIME
 * checks the run agent performs against live SHAs/output — not structural.
 */
export function validateInvariants(plan: PlanTest): InvariantViolation[] {
  const v: InvariantViolation[] = [];
  const roles = new Set(plan.roles);
  const routeIds = new Set<string>();

  for (const r of plan.routes) {
    if (!UPPER_SNAKE.test(r.id)) {
      v.push({ invariant: 1, where: r.id, message: `route id must be UPPER_SNAKE: "${r.id}"` });
    }
    if (routeIds.has(r.id)) {
      v.push({ invariant: 2, where: r.id, message: `duplicate route id "${r.id}"` });
    }
    routeIds.add(r.id);

    for (const role of plan.roles) {
      if (!(role in r.access)) {
        v.push({ invariant: 3, where: r.id, message: `access missing role "${role}"` });
      }
    }

    if (r.navigation_strategy === 'click_row_in_parent' && !r.parent_route) {
      v.push({ invariant: 8, where: r.id, message: `click_row_in_parent requires parent_route` });
    }

    const seen = new Set<string>();
    for (const s of r.subsets) {
      if (seen.has(s.id)) {
        v.push({ invariant: 2, where: `${r.id}.${s.id}`, message: `duplicate subset id "${s.id}"` });
      }
      seen.add(s.id);

      for (const role of s.visible_for) {
        if (!roles.has(role)) {
          v.push({ invariant: 4, where: `${r.id}.${s.id}`, message: `visible_for role "${role}" not in plan.roles` });
        }
      }

      if (s.is_destructive && s.action_kind !== 'log_only') {
        v.push({
          invariant: 5,
          where: `${r.id}.${s.id}`,
          message: `destructive subset must be action_kind 'log_only' (got "${s.action_kind}")`,
        });
      }

      if (s.kind === 'form' && (!s.fields || s.fields.length === 0)) {
        v.push({ invariant: 6, where: `${r.id}.${s.id}`, message: `form subset requires non-empty fields[]` });
      }

      if (s.action_kind === 'open_modal' && !s.modal_indicator) {
        v.push({ invariant: 7, where: `${r.id}.${s.id}`, message: `open_modal requires modal_indicator` });
      }

      if (s.action_kind === 'navigate' && !s.expect_url && !s.navigates_to) {
        v.push({ invariant: 8, where: `${r.id}.${s.id}`, message: `navigate requires expect_url or navigates_to` });
      }
    }
  }

  const endpointIds = new Set<string>();
  for (const e of plan.endpoints) {
    if (endpointIds.has(e.id)) {
      v.push({ invariant: 2, where: e.id, message: `duplicate endpoint id "${e.id}"` });
    }
    endpointIds.add(e.id);
    for (const role of plan.roles) {
      if (!(role in e.expected_by_role)) {
        v.push({ invariant: 3, where: e.id, message: `expected_by_role missing role "${role}"` });
      }
    }
  }

  return v;
}

export type ParsePlanResult =
  | { ok: true; plan: PlanTest; violations: InvariantViolation[] }
  | { ok: false; errors: string[] };

/**
 * Parse + structurally validate a candidate plan object. Schema errors return
 * `ok:false` with mapped messages; a schema-valid plan returns the typed plan plus
 * any invariant violations (empty array == fully valid).
 */
export function parsePlan(raw: unknown): ParsePlanResult {
  const parsed = PlanTestSchema.safeParse(raw);
  if (!parsed.success) {
    return { ok: false, errors: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) };
  }
  return { ok: true, plan: parsed.data, violations: validateInvariants(parsed.data) };
}
