#!/usr/bin/env ruby
# frozen_string_literal: true

# ==============================================================================
# Unified State Manager
# ==============================================================================
# Single source of truth for all hook state. Replaces 15+ scattered JSON files
# with one signed, locked state file.
#
# Features:
#   - Single .claude/state.json file
#   - File locking for concurrent access
#   - HMAC signing for tamper detection
#   - Atomic writes (tempfile + rename)
#   - Schema defaults for missing keys
#
# Usage:
#   require_relative 'core/state_manager'
#   StateManager.get(:circuit_breaker, :failures)  # => 0
#   StateManager.set(:circuit_breaker, :failures, 1)
#   StateManager.update(:edits) { |e| e[:count] += 1; e }
#   StateManager.reset(:research)
#
# Sections:
#   - circuit_breaker: Failure tracking, trip state
#   - requirements: What user requested, what's satisfied
#   - research: 4 category completion status
#   - edits: Edit count, unique files list
#   - saneloop: Active loop state, iteration, criteria
#   - enforcement: Enforcement breaker state
# ==============================================================================

require 'json'
require 'fileutils'
require 'tempfile'
require_relative '../state_signer'

module StateManager
  PROJECT_DIR = ENV['CLAUDE_PROJECT_DIR'] || Dir.pwd
  STATE_FILE = File.join(PROJECT_DIR, '.claude', 'state.json')
  LOCK_FILE = "#{STATE_FILE}.lock"

  # Schema with defaults for each section
  SCHEMA = {
    circuit_breaker: {
      failures: 0,
      tripped: false,
      tripped_at: nil,
      last_error: nil,
      error_signatures: {}
    },
    requirements: {
      requested: [],
      satisfied: [],
      is_task: false,
      is_big_task: false
    },
    # Dynamic key: pending_research_write (set by sanetools, read/cleared by sanetrack_research)
    research: {
      docs: nil,
      web: nil,
      github: nil,
      local: nil
    },
    edits: {
      count: 0,
      unique_files: [],
      last_file: nil
    },
    saneloop: {
      active: false,
      task: nil,
      iteration: 0,
      max_iterations: 20,
      acceptance_criteria: [],
      started_at: nil
    },
    enforcement: {
      blocks: [],       # Capped at 50 entries (trimmed at session start + sanetrack)
      halted: false,
      halted_at: nil,
      halted_reason: nil,
      session_started_at: nil  # Set by session_start.rb, used by sanestop.rb for session boundary
    },
    # Edit attempt tracking (prevents "no big deal" syndrome)
    edit_attempts: {
      count: 0,
      last_attempt: nil,
      reset_at: nil
    },
    # === INTELLIGENCE: Pattern learning sections ===
    action_log: [],  # Last 20 actions for correlation
    reminders: {},   # Reminder timestamps + counts (rewind/context/explore)
    learnings: [],   # Learned patterns from user corrections
    patterns: {
      weak_spots: {},     # { "rule_N" => count } - rules frequently violated
      triggers: {},       # { "word" => ["rule_N"] } - words that predict violations
      strengths: [],      # ["rule_N"] - rules with 100% compliance
      session_scores: []  # Last 10 SOP scores
    },
    # === VALIDATION: Objective productivity tracking ===
    # This data persists across sessions to measure if SaneProcess actually works
    validation: {
      sessions_total: 0,
      sessions_with_tests_passing: 0,
      sessions_with_breaker_trip: 0,
      blocks_that_were_correct: 0,    # User didn't override
      blocks_that_were_wrong: 0,      # User had to override/bypass
      doom_loops_caught: 0,           # Breaker tripped on repeated same error
      doom_loops_missed: 0,           # 3+ same errors before any intervention
      time_saved_estimates: [],       # Rough estimates when blocks prevent waste
      first_tracked: nil,
      last_updated: nil
    },
    # === MCP VERIFICATION SYSTEM ===
    # Tracks MCP health and ensures all MCPs verified before edits
    mcp_health: {
      verified_this_session: false,
      last_verified: nil,
      mcps: {
        apple_docs: { verified: false, last_success: nil, last_failure: nil, failure_count: 0 },
        context7: { verified: false, last_success: nil, last_failure: nil, failure_count: 0 },
        github: { verified: false, last_success: nil, last_failure: nil, failure_count: 0 }
      }
    },
    # === REFUSAL TO READ TRACKING ===
    # Detects when AI is blocked repeatedly for same reason but keeps trying
    # instead of reading the message and following instructions
    refusal_tracking: {},
    # === TASK CONTEXT TRACKING ===
    # Tracks what task research was done FOR, so research for Task A
    # doesn't unlock edits for Task B (the task-scope bug fix)
    task_context: {
      task_type: nil,        # :bug_fix, :new_feature, :refactor, etc.
      task_keywords: [],     # Key nouns from the task
      task_hash: nil,        # Hash of normalized task for comparison
      researched_at: nil     # When research was done
    },
    # === SESSION DOC ENFORCEMENT ===
    # Tracks which project docs must be read before editing
    session_docs: {
      required: [],       # Basenames discovered at session start
      read: [],           # Basenames confirmed read via PostToolUse
      enforced: true      # Active flag
    },
    # === VERIFICATION TRACKING (Rule #4 enforcement) ===
    # Tracks whether tests/verification ran after edits were made.
    # sanestop.rb blocks session end if edits > 0 and verified == false.
    verification: {
      tests_run: false,        # Any successful test command detected this session (legacy key)
      verification_run: false, # Any successful verification command detected this session (legacy key)
      tests_passed: false,     # Successful test command detected this session
      verification_succeeded: false, # Successful verification command detected this session
      last_test_at: nil,       # Timestamp of last test
      test_commands: [],       # What was run (for audit trail)
      edits_before_test: 0     # Edits made since last test (resets on test run)
    },
    # === VISUAL VERIFICATION TRACKING ===
    # Tracks customer-facing UI work that needs screenshot-backed inspection.
    # sanestop.rb blocks session end if visual verification is required but
    # screenshot evidence and a written audit receipt are missing.
    visual_verification: {
      required: false,
      reason: nil,
      required_files: [],
      evidence_commands: [],
      screenshot_paths: [],
      audit_recorded: false,
      audit_files: [],
      last_evidence_at: nil,
      last_audit_at: nil
    },
    # === PLANNING ENFORCEMENT ===
    # Forces plan-before-code workflow for task prompts
    planning: {
      required: false,        # Set true on task/big_task prompts
      plan_shown: false,      # Claude showed a plan
      plan_approved: false,   # User approved the plan
      replan_count: 0,        # Times forced to re-plan (after edit limit)
      forced_at: nil          # Timestamp when planning was required
    },
    # === SENSITIVE FILE APPROVALS ===
    # Tracks per-file approval for high-blast-radius files (CI/CD, entitlements, etc.)
    # First edit attempt blocks with explanation; retry proceeds (user saw the warning)
    sensitive_approvals: {},
    # === STARTUP GATE ===
    # Blocks substantive work (Task, Bash, Edit, Write) until mandatory startup steps complete.
    # Allows only tools needed for startup itself (Read, Grep, Glob, startup-specific Bash).
    startup_gate: {
      open: false,
      opened_at: nil,
      steps: {
        session_docs: false,
        skills_registry: false,
        validation_report: false,
        orphan_cleanup: false,
        system_clean: false
      },
      step_timestamps: {}
    },
    # === DEPLOYMENT SAFETY (session-scoped) ===
    # Tracks Sparkle signing and stapler verification to prevent shipping unsigned DMGs
    deployment: {
      sparkle_signed_dmgs: [],    # Filenames signed this session
      staple_verified_dmgs: []    # Filenames verified stapled this session
    },
    # === HANDOFF TRACKING ===
    # Ensures SESSION_HANDOFF.md and memory are updated when significant changes are made.
    # sanetrack.rb sets needs_update when non-trivial files are edited.
    # sanestop.rb blocks session end if updates are missing.
    handoff_tracking: {
      significant_edits: 0,       # Count of non-trivial edits that should affect handoff hygiene
      significant_files: [],      # Which files triggered the general significant-edit flag
      always_persist_required: false, # Tooling/durable-doc edits that must update handoff+memory even below threshold
      always_persist_files: [],   # Which tooling/durable-doc files triggered immediate persistence
      handoff_updated: false,     # SESSION_HANDOFF.md was edited this session
      memory_updated: false       # Any memory file was edited this session
    },
    # === SKILL ENFORCEMENT ===
    # Tracks when skills should be used and validates they were executed properly
    skill: {
      required: nil,           # Skill name that SHOULD be invoked (e.g., :docs_audit)
      required_prompt: nil,    # User prompt that triggered the skill requirement
      invoked: false,          # Whether Skill tool was actually called
      invoked_at: nil,         # Timestamp of invocation
      subagents_spawned: 0,    # Count of Task tool calls (subagents)
      runner_started: false,   # Whether a matching runner-backed workflow command was attempted
      runner_proved: false,    # Whether successful workflow proof/receipt was seen for the active skill
      runner_used: false,      # Back-compat alias for runner_proved
      runner_attempts: [],     # Matching runner command attempts and result status
      runner_commands: [],     # Matching runner/proof commands seen this session
      runner_proof: nil,       # Workflow-specific proof metadata
      files_read: [],          # Files read during skill execution
      satisfied: false,        # Whether skill requirements were met
      satisfaction_reason: nil # Why satisfied/unsatisfied
    }
  }.freeze

  class << self
    # Get a value from state
    # StateManager.get(:circuit_breaker, :failures) => 0
    # StateManager.get(:circuit_breaker) => { failures: 0, ... }
    def get(section, key = nil)
      state = load_state
      section_data = state[section] || SCHEMA[section].dup

      if key
        section_data[key]
      else
        section_data
      end
    end

    # Set a value in state
    # StateManager.set(:circuit_breaker, :failures, 1)
    def set(section, key, value)
      with_lock do
        state = load_state_unlocked
        state[section] ||= SCHEMA[section].dup
        state[section][key] = value
        save_state_unlocked(state)
      end
      value
    end

    # Update a section with a block
    # StateManager.update(:edits) { |e| e[:count] += 1; e }
    def update(section)
      with_lock do
        state = load_state_unlocked
        state[section] ||= SCHEMA[section].dup
        state[section] = yield(state[section])
        save_state_unlocked(state)
      end
    end

    # Reset a section to defaults
    # StateManager.reset(:research)
    def reset(section)
      with_lock do
        state = load_state_unlocked
        state[section] = SCHEMA[section].dup
        save_state_unlocked(state)
      end
    end

    # Reset all state (session start)
    def reset_all
      with_lock do
        state = {}
        SCHEMA.each { |k, v| state[k] = v.dup }
        save_state_unlocked(state)
      end
    end

    # Preserve some sections, reset others (session start pattern)
    def reset_except(*preserve)
      with_lock do
        old_state = load_state_unlocked
        state = {}
        SCHEMA.each do |k, v|
          state[k] = preserve.include?(k) ? (old_state[k] || v.dup) : v.dup
        end
        save_state_unlocked(state)
      end
    end

    # Check if state file exists
    def exists?
      File.exist?(STATE_FILE)
    end

    # Get full state (read-only copy)
    def to_h
      load_state.dup
    end

    # Debug: dump state
    def dump
      state = load_state
      JSON.pretty_generate(state)
    end

    private

    # Load state with file locking
    def load_state
      with_lock { load_state_unlocked }
    end

    # Load without lock (must be called within with_lock)
    def load_state_unlocked
      return initialize_state unless File.exist?(STATE_FILE)

      # Use stdlib symbolize_names: true via StateSigner
      data = StateSigner.read_verified(STATE_FILE, symbolize: true)
      return initialize_state unless data

      # Merge with schema defaults for any missing keys
      merge_with_defaults(data)
    rescue JSON::ParserError, StandardError
      initialize_state
    end

    # Save with atomic write (tempfile + rename)
    def save_state_unlocked(state)
      FileUtils.mkdir_p(File.dirname(STATE_FILE))

      # Write to temp file first
      temp = Tempfile.new('state', File.dirname(STATE_FILE))
      begin
        # Convert symbols to strings for JSON
        string_state = stringify_keys(state)
        StateSigner.write_signed(temp.path, string_state)
        temp.close

        # Atomic rename
        File.rename(temp.path, STATE_FILE)
      ensure
        temp.close rescue nil
        temp.unlink rescue nil
      end
    end

    # Initialize with schema defaults
    def initialize_state
      state = {}
      SCHEMA.each { |k, v| state[k] = v.dup }
      state
    end

    # File locking for concurrent access with timeout
    # Uses non-blocking lock with retry to prevent infinite hangs
    LOCK_TIMEOUT = 2.0  # seconds - must be less than hook timeout (5s)
    LOCK_RETRY_INTERVAL = 0.05  # 50ms between retries

    def with_lock
      FileUtils.mkdir_p(File.dirname(LOCK_FILE))
      File.open(LOCK_FILE, File::RDWR | File::CREAT, 0o644) do |f|
        deadline = Time.now + LOCK_TIMEOUT
        locked = false

        # Try non-blocking lock with timeout
        while Time.now < deadline
          # LOCK_NB = non-blocking, returns immediately
          if f.flock(File::LOCK_EX | File::LOCK_NB)
            locked = true
            break
          end
          sleep(LOCK_RETRY_INTERVAL)
        end

        unless locked
          # Timeout! Remove stale lock and try once more
          warn "⚠️  StateManager lock timeout - clearing stale lock"
          f.flock(File::LOCK_UN) rescue nil

          # Force acquire (last resort - better than hanging)
          f.flock(File::LOCK_EX | File::LOCK_NB) rescue nil
        end

        begin
          yield
        ensure
          f.flock(File::LOCK_UN) rescue nil
        end
      end
    end

    # Merge loaded data with schema defaults (data already has symbol keys from JSON.parse)
    def merge_with_defaults(data)
      SCHEMA.each_with_object({}) do |(section, defaults), state|
        state[section] = data[section] || defaults.dup
      end
    end

    # Convert symbol keys to strings for JSON
    def stringify_keys(hash)
      result = {}
      hash.each do |k, v|
        key = k.to_s
        result[key] = v.is_a?(Hash) ? stringify_keys(v) : v
      end
      result
    end
  end
end

# CLI mode for testing/debugging
if __FILE__ == $PROGRAM_NAME
  require 'optparse'

  options = {}
  OptionParser.new do |opts|
    opts.banner = 'Usage: state_manager.rb [options]'

    opts.on('-d', '--dump', 'Dump full state') { options[:dump] = true }
    opts.on('-g', '--get SECTION', 'Get section') { |s| options[:get] = s }
    opts.on('-r', '--reset SECTION', 'Reset section') { |s| options[:reset] = s }
    opts.on('--reset-all', 'Reset all state') { options[:reset_all] = true }
  end.parse!

  if options[:dump]
    puts StateManager.dump
  elsif options[:get]
    section = options[:get].to_sym
    data = StateManager.get(section)
    puts JSON.pretty_generate(data)
  elsif options[:reset]
    section = options[:reset].to_sym
    StateManager.reset(section)
    puts "Reset: #{section}"
  elsif options[:reset_all]
    StateManager.reset_all
    puts 'Reset all state'
  else
    puts StateManager.dump
  end
end
