"""SQLite schema creation and database access."""

from __future__ import annotations

import logging
import sqlite3
import time

from .. import config

logger = logging.getLogger(__name__)

# Max retries and backoff for database-locked errors across processes.
# With 8 retries starting at 0.1s the total window is ~25s (0.1+0.2+...+12.8)
# plus random jitter to avoid thundering-herd collisions.
_DB_RETRY_MAX = 8
_DB_RETRY_BACKOFF = 0.1  # seconds, doubles each attempt

# Singleton connection — avoids opening multiple connections per process
# which causes "database is locked" errors with WAL mode.
_conn: sqlite3.Connection | None = None


class _UnclosableConnection:
    """Wrapper that prevents closing the shared singleton connection.

    Existing code calls db.close() after each query. With a singleton
    connection this would break subsequent queries. This wrapper makes
    close() a no-op while proxying everything else to the real connection.

    Additionally, execute(), executemany(), executescript(), and commit()
    automatically retry on "database is locked" errors with exponential
    backoff to handle concurrent MCP server processes.
    """

    __slots__ = ("_real",)

    def __init__(self, conn: sqlite3.Connection) -> None:
        object.__setattr__(self, "_real", conn)

    def close(self) -> None:  # noqa: D102
        pass  # No-op: keep the singleton alive

    # -- Retry wrappers for write operations --

    def _retry(self, method_name: str, *args, **kwargs):  # noqa: ANN001, ANN002, ANN003, ANN202
        import random

        real = object.__getattribute__(self, "_real")
        method = getattr(real, method_name)
        for attempt in range(_DB_RETRY_MAX):
            try:
                return method(*args, **kwargs)
            except sqlite3.OperationalError as exc:
                if "locked" not in str(exc).lower():
                    raise
                if attempt == _DB_RETRY_MAX - 1:
                    raise
                # Exponential backoff with jitter (50%-150%) to avoid thundering herd
                base_wait = _DB_RETRY_BACKOFF * (2 ** attempt)
                wait = base_wait * (0.5 + random.random())
                logger.warning("DB locked on %s, retry %d/%d after %.2fs", method_name, attempt + 1, _DB_RETRY_MAX, wait)
                time.sleep(wait)

    def execute(self, *args, **kwargs):  # noqa: ANN002, ANN003, ANN201
        return self._retry("execute", *args, **kwargs)

    def executemany(self, *args, **kwargs):  # noqa: ANN002, ANN003, ANN201
        return self._retry("executemany", *args, **kwargs)

    def executescript(self, *args, **kwargs):  # noqa: ANN002, ANN003, ANN201
        return self._retry("executescript", *args, **kwargs)

    def commit(self, *args, **kwargs):  # noqa: ANN002, ANN003, ANN201
        return self._retry("commit", *args, **kwargs)

    def __getattr__(self, name: str):  # noqa: ANN204
        return getattr(object.__getattribute__(self, "_real"), name)

    def __setattr__(self, name: str, value: object) -> None:
        setattr(object.__getattribute__(self, "_real"), name, value)


_SCHEMA_SQL = """
-- Core settings (voice signature, preferences, etc.)
CREATE TABLE IF NOT EXISTS settings (
    key   TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- Campaigns
CREATE TABLE IF NOT EXISTS campaigns (
    id          TEXT PRIMARY KEY,
    name        TEXT NOT NULL,
    icp_json    TEXT,
    status      TEXT NOT NULL DEFAULT 'draft',
    mode        TEXT NOT NULL DEFAULT 'copilot',
    config_json TEXT,
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    updated_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- Contacts (prospects)
CREATE TABLE IF NOT EXISTS contacts (
    id            TEXT PRIMARY KEY,
    campaign_id   TEXT,
    name          TEXT,
    title         TEXT,
    company       TEXT,
    linkedin_url  TEXT,
    linkedin_id   TEXT,
    profile_json  TEXT,
    analysis_json TEXT,
    fit_score     REAL DEFAULT 0.0,
    status        TEXT NOT NULL DEFAULT 'pending',
    created_at    INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    updated_at    INTEGER,
    FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
);

-- Outreaches (one per contact per campaign)
CREATE TABLE IF NOT EXISTS outreaches (
    id              TEXT PRIMARY KEY,
    campaign_id     TEXT NOT NULL,
    contact_id      TEXT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'pending',
    next_action     TEXT,
    scheduled_at    INTEGER,
    followup_count  INTEGER NOT NULL DEFAULT 0,
    outcome_json    TEXT,
    invited_at      INTEGER,
    accepted_at     INTEGER,
    first_reply_at  INTEGER,
    created_at      INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    updated_at      INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
    FOREIGN KEY (contact_id) REFERENCES contacts(id)
);

-- Messages (conversation thread)
CREATE TABLE IF NOT EXISTS messages (
    id          TEXT PRIMARY KEY,
    outreach_id TEXT NOT NULL,
    role        TEXT NOT NULL,  -- 'sdr' or 'prospect'
    text        TEXT NOT NULL,
    sentiment   TEXT,
    read_at     INTEGER,  -- Unix timestamp when prospect read our message (NULL = unread/unknown)
    timestamp   INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (outreach_id) REFERENCES outreaches(id)
);

-- Actions log (audit trail)
CREATE TABLE IF NOT EXISTS actions_log (
    id           TEXT PRIMARY KEY,
    outreach_id  TEXT,
    action_type  TEXT NOT NULL,
    result       TEXT,
    details_json TEXT,
    timestamp    INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (outreach_id) REFERENCES outreaches(id)
);

-- Rate limits (per day)
CREATE TABLE IF NOT EXISTS rate_limits (
    id          TEXT PRIMARY KEY,
    date        TEXT NOT NULL UNIQUE,
    sent        INTEGER NOT NULL DEFAULT 0,
    accepted    INTEGER NOT NULL DEFAULT 0,
    daily_limit INTEGER NOT NULL DEFAULT 15,
    blocked     INTEGER NOT NULL DEFAULT 0,
    updated_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- Usage tracking (free tier monthly counters)
CREATE TABLE IF NOT EXISTS usage (
    month              TEXT PRIMARY KEY,  -- 'YYYY-MM'
    invitations_sent   INTEGER NOT NULL DEFAULT 0,
    messages_sent      INTEGER NOT NULL DEFAULT 0,
    campaigns_created  INTEGER NOT NULL DEFAULT 0,
    icps_generated     INTEGER NOT NULL DEFAULT 0,
    engagements_sent   INTEGER NOT NULL DEFAULT 0,
    updated_at         INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- ICPs (persisted Ideal Customer Profiles)
CREATE TABLE IF NOT EXISTS icps (
    id          TEXT PRIMARY KEY,
    name        TEXT NOT NULL,
    icp_json    TEXT NOT NULL,      -- full IcpResult serialized as JSON
    target_desc TEXT,               -- original target description
    source_url  TEXT,               -- company URL if provided
    status      TEXT NOT NULL DEFAULT 'active',
    confidence  REAL DEFAULT 0.5,   -- best ICP confidence score
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    updated_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- ICP data sources (websites, text, KB)
CREATE TABLE IF NOT EXISTS icp_sources (
    id          TEXT PRIMARY KEY,
    icp_id      TEXT,
    source_type TEXT NOT NULL,      -- 'website', 'text', 'kb'
    uri         TEXT,
    title       TEXT,
    content_hash TEXT,
    chunk_count INTEGER DEFAULT 0,
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (icp_id) REFERENCES icps(id)
);

-- ICP chunks (for RAG, Phase 2+)
CREATE TABLE IF NOT EXISTS icp_chunks (
    id          TEXT PRIMARY KEY,
    source_id   TEXT NOT NULL,
    text        TEXT NOT NULL,
    ctx_text    TEXT,               -- chunk + surrounding context
    header_path TEXT DEFAULT '',
    position    INTEGER DEFAULT 0,
    embedding   BLOB,              -- numpy float32 array (Phase 3+)
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (source_id) REFERENCES icp_sources(id)
);

-- Engagements (post comments and reactions)
CREATE TABLE IF NOT EXISTS engagements (
    id            TEXT PRIMARY KEY,
    outreach_id   TEXT,
    action_type   TEXT NOT NULL,       -- 'comment' or 'react'
    post_id       TEXT NOT NULL,       -- LinkedIn post ID/URN
    post_text     TEXT,                -- Snapshot of the post text (for context)
    text          TEXT,                -- Comment text (null for reactions)
    reaction_type TEXT,                -- 'LIKE', 'CELEBRATE', etc. (null for comments)
    status        TEXT NOT NULL DEFAULT 'sent',  -- 'sent', 'pending_review', 'failed'
    reasoning     TEXT,                -- JSON: LLM reasoning for comment choice
    created_at    INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (outreach_id) REFERENCES outreaches(id)
);

-- Scheduler jobs (Sprint 17: autonomous scheduling)
CREATE TABLE IF NOT EXISTS scheduler_jobs (
    id           TEXT PRIMARY KEY,
    campaign_id  TEXT,              -- NULL for account-level (non-campaign) jobs
    outreach_id  TEXT,
    job_type     TEXT NOT NULL,     -- 'invite', 'followup', 'engage', 'check_replies'
    status       TEXT NOT NULL DEFAULT 'pending',  -- 'pending', 'running', 'completed', 'failed'
    scheduled_at INTEGER NOT NULL,
    started_at   INTEGER,
    completed_at INTEGER,
    retry_count  INTEGER NOT NULL DEFAULT 0,
    error        TEXT,
    created_at   INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
    FOREIGN KEY (outreach_id) REFERENCES outreaches(id)
);

-- Experiments (PM hypothesis tracking)
CREATE TABLE IF NOT EXISTS experiments (
    id          TEXT PRIMARY KEY,
    snapshot    TEXT NOT NULL,
    result_json TEXT NOT NULL,
    campaign_ids TEXT,
    status      TEXT NOT NULL DEFAULT 'pending',
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- A/B Tests (controlled experiments on messaging variants)
CREATE TABLE IF NOT EXISTS ab_tests (
    id           TEXT PRIMARY KEY,
    campaign_id  TEXT NOT NULL,
    name         TEXT NOT NULL,          -- e.g., "Pain-first vs Question-first hook"
    hypothesis   TEXT,                   -- what we expect to learn
    variant_a    TEXT NOT NULL,          -- description of variant A (control)
    variant_b    TEXT NOT NULL,          -- description of variant B (treatment)
    status       TEXT NOT NULL DEFAULT 'running',  -- running, completed, cancelled
    winner       TEXT,                   -- 'A', 'B', or 'inconclusive'
    result_json  TEXT,                   -- per-variant stats at completion
    created_at   INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    completed_at INTEGER,
    FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
);

-- Inbound signals (connection requests, unsolicited DMs, post comments)
CREATE TABLE IF NOT EXISTS inbound_signals (
    id                 TEXT PRIMARY KEY,
    signal_type        TEXT NOT NULL,       -- 'invitation', 'message', 'comment'
    sender_name        TEXT,
    sender_id          TEXT,                -- LinkedIn provider_id
    sender_headline    TEXT,
    sender_company     TEXT,
    sender_url         TEXT,                -- LinkedIn profile URL
    content            TEXT,                -- invite message, DM text, or comment text
    post_id            TEXT,                -- only for comments (our post)
    profile_json       TEXT,                -- full profile if fetched
    intent             TEXT,                -- 'buying_signal', 'networking', 'job_seeking', 'spam', 'partnership', 'unknown'
    matched_icp_id     TEXT,
    confidence         REAL DEFAULT 0.0,
    recommended_action TEXT,                -- 'engage_immediately', 'ask_purpose', 'accept_and_monitor', 'ignore'
    reasoning          TEXT,
    status             TEXT DEFAULT 'new',  -- 'new', 'qualified', 'engaged', 'converted', 'dismissed'
    campaign_id        TEXT,
    created_at         INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    qualified_at       INTEGER,
    FOREIGN KEY (matched_icp_id) REFERENCES icps(id)
);

-- Published posts (for comment monitoring)
CREATE TABLE IF NOT EXISTS published_posts (
    id           TEXT PRIMARY KEY,
    post_id      TEXT NOT NULL,              -- LinkedIn post ID/URN
    text         TEXT,                       -- post content snapshot
    topic        TEXT,
    published_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    last_checked INTEGER,                    -- last time we checked for comments
    comment_count INTEGER DEFAULT 0
);

-- Proactive signals (buying intent from prospect behavior)
CREATE TABLE IF NOT EXISTS signals (
    id             TEXT PRIMARY KEY,
    signal_type    TEXT NOT NULL,             -- 'keyword_mention', 'prospect_post', 'job_change',
                                             -- 'competitor_mention', 'hiring_surge', 'funding_event',
                                             -- 'news_event', 'profile_view', 'post_engagement',
                                             -- 'commenter_match'
    source         TEXT NOT NULL,             -- 'keyword_search', 'prospect_scan', 'profile_scan',
                                             -- 'job_search', 'news_search', 'comment_xref'
    prospect_id    TEXT,                      -- FK to contacts.id (NULL if new prospect)
    prospect_name  TEXT,
    prospect_title TEXT,
    linkedin_id    TEXT,                      -- LinkedIn provider_id (for dedup + linking)
    campaign_id    TEXT,                      -- FK to campaigns.id (if linked to campaign)

    -- Signal content
    content        TEXT,                      -- Post text, job description, news snippet
    post_id        TEXT,                      -- LinkedIn post ID (if post-related)
    metadata_json  TEXT,                      -- Flexible: {keywords_matched, company, old_title, new_title, etc.}

    -- Classification
    intent         TEXT,                      -- 'buying_signal', 'pain_point', 'competitor_eval',
                                             -- 'job_seeking', 'thought_leadership', 'unknown'
    confidence     REAL DEFAULT 0.0,          -- 0.0-1.0 (from classifier)
    signal_score   REAL DEFAULT 0.0,          -- 0.0-1.0 (composite weighted score)
    reasoning      TEXT,

    -- State
    status         TEXT DEFAULT 'new',        -- 'new', 'classified', 'actioned', 'dismissed', 'expired'
    action_taken   TEXT,                      -- 'campaign_created', 'outreach_triggered', 'engaged', NULL
    actioned_at    INTEGER,
    expires_at     INTEGER,                   -- Signal freshness expiry (auto-decay)

    detected_at    INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    classified_at  INTEGER,

    FOREIGN KEY (prospect_id) REFERENCES contacts(id),
    FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
);

-- Signal watchlists (keyword monitoring configuration)
CREATE TABLE IF NOT EXISTS signal_watchlists (
    id          TEXT PRIMARY KEY,
    name        TEXT NOT NULL,                -- e.g., "Competitor mentions", "Pain point keywords"
    watch_type  TEXT NOT NULL,                -- 'keyword', 'competitor', 'company', 'person'
    keywords    TEXT NOT NULL,                -- JSON array: ["cold outreach", "SDR automation"]
    campaign_id TEXT,                         -- Optional: link signals to specific campaign
    is_active   INTEGER DEFAULT 1,
    last_polled_at INTEGER,                   -- Last time this watchlist was polled
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
);

-- Intent events (compound intent from stacked signals)
CREATE TABLE IF NOT EXISTS intent_events (
    id              TEXT PRIMARY KEY,
    linkedin_id     TEXT NOT NULL,
    company         TEXT,
    event_type      TEXT NOT NULL,             -- 'new_leader_building', 'active_evaluation',
                                               -- 'growth_mode', 'engaged_thought_leader',
                                               -- 'warm_inbound', 'multi_signal_hot'
    signal_ids      TEXT NOT NULL,             -- JSON array of contributing signal IDs
    signal_types    TEXT NOT NULL,             -- JSON array of contributing signal types
    composite_score REAL NOT NULL,
    action_taken    TEXT,                       -- 'outreach_created', 'priority_boosted', NULL
    detected_at     INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    expires_at      INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_intent_events_linkedin ON intent_events(linkedin_id);
CREATE INDEX IF NOT EXISTS idx_intent_events_type ON intent_events(event_type);
CREATE INDEX IF NOT EXISTS idx_intent_events_score ON intent_events(composite_score DESC);

-- Signal accounts (aggregated signal scores per prospect)
CREATE TABLE IF NOT EXISTS signal_accounts (
    linkedin_id     TEXT PRIMARY KEY,
    prospect_name   TEXT,
    company         TEXT,
    total_signals   INTEGER DEFAULT 0,
    composite_score REAL DEFAULT 0.0,
    top_signal_type TEXT,                     -- Most impactful signal type
    last_signal_at  INTEGER,
    updated_at      INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- Strategy patterns (cross-campaign learned patterns)
CREATE TABLE IF NOT EXISTS strategy_patterns (
    id                      TEXT PRIMARY KEY,
    pattern_type            TEXT NOT NULL,
    pattern_key             TEXT NOT NULL,
    description             TEXT,
    evidence_json           TEXT NOT NULL,
    confidence              REAL DEFAULT 0.0,
    estimated_revenue_impact REAL DEFAULT 0.0,
    acceptance_rate         REAL,
    reply_rate              REAL,
    conversion_rate         REAL,
    sample_size             INTEGER DEFAULT 0,
    status                  TEXT DEFAULT 'active',
    discovered_at           INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    last_validated          INTEGER,
    created_at              INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- Strategy actions (audit trail of autonomous actions)
CREATE TABLE IF NOT EXISTS strategy_actions (
    id              TEXT PRIMARY KEY,
    action_type     TEXT NOT NULL,
    campaign_id     TEXT,
    pattern_id      TEXT,
    details_json    TEXT NOT NULL,
    outcome_json    TEXT,
    status          TEXT DEFAULT 'applied',
    created_at      INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    measured_at     INTEGER,
    FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
    FOREIGN KEY (pattern_id) REFERENCES strategy_patterns(id)
);

-- Indexes
CREATE INDEX IF NOT EXISTS idx_experiments_created ON experiments(created_at);
CREATE INDEX IF NOT EXISTS idx_ab_tests_campaign ON ab_tests(campaign_id);
CREATE INDEX IF NOT EXISTS idx_ab_tests_status ON ab_tests(status);
CREATE INDEX IF NOT EXISTS idx_scheduler_jobs_status ON scheduler_jobs(status, scheduled_at);
CREATE INDEX IF NOT EXISTS idx_scheduler_jobs_campaign ON scheduler_jobs(campaign_id);
CREATE INDEX IF NOT EXISTS idx_contacts_campaign ON contacts(campaign_id);
CREATE INDEX IF NOT EXISTS idx_outreaches_campaign ON outreaches(campaign_id);
CREATE INDEX IF NOT EXISTS idx_outreaches_campaign_status ON outreaches(campaign_id, status);
CREATE INDEX IF NOT EXISTS idx_outreaches_contact ON outreaches(contact_id);
CREATE INDEX IF NOT EXISTS idx_outreaches_status ON outreaches(status);
CREATE INDEX IF NOT EXISTS idx_messages_outreach ON messages(outreach_id);
CREATE INDEX IF NOT EXISTS idx_actions_log_outreach ON actions_log(outreach_id);
CREATE INDEX IF NOT EXISTS idx_rate_limits_date ON rate_limits(date);
CREATE INDEX IF NOT EXISTS idx_icps_status ON icps(status);
CREATE INDEX IF NOT EXISTS idx_icp_sources_icp ON icp_sources(icp_id);
CREATE INDEX IF NOT EXISTS idx_icp_chunks_source ON icp_chunks(source_id);
CREATE INDEX IF NOT EXISTS idx_engagements_outreach ON engagements(outreach_id);
CREATE INDEX IF NOT EXISTS idx_engagements_created ON engagements(created_at);
CREATE INDEX IF NOT EXISTS idx_campaigns_status ON campaigns(status);
CREATE INDEX IF NOT EXISTS idx_inbound_signals_status ON inbound_signals(status);
CREATE INDEX IF NOT EXISTS idx_inbound_signals_type ON inbound_signals(signal_type);
CREATE INDEX IF NOT EXISTS idx_inbound_signals_sender ON inbound_signals(sender_id);
CREATE INDEX IF NOT EXISTS idx_published_posts_post ON published_posts(post_id);
CREATE INDEX IF NOT EXISTS idx_signals_status ON signals(status);
CREATE INDEX IF NOT EXISTS idx_signals_type ON signals(signal_type);
CREATE INDEX IF NOT EXISTS idx_signals_prospect ON signals(linkedin_id);
CREATE INDEX IF NOT EXISTS idx_signals_detected ON signals(detected_at);
CREATE INDEX IF NOT EXISTS idx_signals_score ON signals(signal_score DESC);
CREATE INDEX IF NOT EXISTS idx_signal_watchlists_active ON signal_watchlists(is_active);
CREATE INDEX IF NOT EXISTS idx_signal_watchlists_campaign ON signal_watchlists(campaign_id);

CREATE INDEX IF NOT EXISTS idx_strategy_patterns_type ON strategy_patterns(pattern_type);
CREATE INDEX IF NOT EXISTS idx_strategy_patterns_status ON strategy_patterns(status);
CREATE INDEX IF NOT EXISTS idx_strategy_patterns_revenue ON strategy_patterns(estimated_revenue_impact);
CREATE INDEX IF NOT EXISTS idx_strategy_actions_campaign ON strategy_actions(campaign_id);
CREATE INDEX IF NOT EXISTS idx_strategy_actions_type ON strategy_actions(action_type);
CREATE INDEX IF NOT EXISTS idx_strategy_actions_status ON strategy_actions(status);
"""


def get_db() -> sqlite3.Connection:
    """Return a singleton SQLite connection with WAL mode enabled.

    Re-uses the same connection across the process lifetime to avoid
    "database is locked" errors from multiple open connections competing
    for the write lock.
    """
    global _conn
    if _conn is not None:
        return _conn

    config.ensure_dirs()
    path = config.db_path()
    is_new = not path.exists()

    conn = sqlite3.connect(str(path), timeout=60)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA busy_timeout=60000")
    conn.execute("PRAGMA foreign_keys=ON")

    if is_new:
        logger.info(f"Creating new database at {path}")
        conn.executescript(_SCHEMA_SQL)
        conn.commit()
        _run_migrations(conn)
    else:
        _run_migrations(conn)

    _conn = _UnclosableConnection(conn)
    return _conn


def _run_migrations(conn: sqlite3.Connection) -> None:
    """Apply schema migrations to existing databases."""
    # Sprint 2: Add followup_count to outreaches
    try:
        conn.execute("ALTER TABLE outreaches ADD COLUMN followup_count INTEGER NOT NULL DEFAULT 0")
        conn.commit()
        logger.info("Migration: added followup_count to outreaches")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Sprint 3: Add engagements table (for existing DBs created before Sprint 3)
    try:
        conn.execute("SELECT 1 FROM engagements LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS engagements (
                id            TEXT PRIMARY KEY,
                outreach_id   TEXT,
                action_type   TEXT NOT NULL,
                post_id       TEXT NOT NULL,
                post_text     TEXT,
                text          TEXT,
                reaction_type TEXT,
                status        TEXT NOT NULL DEFAULT 'sent',
                reasoning     TEXT,
                created_at    INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                FOREIGN KEY (outreach_id) REFERENCES outreaches(id)
            );
            CREATE INDEX IF NOT EXISTS idx_engagements_outreach ON engagements(outreach_id);
            CREATE INDEX IF NOT EXISTS idx_engagements_created ON engagements(created_at);
        """)
        conn.commit()
        logger.info("Migration: added engagements table")

    # Sprint 3: Add engagements_sent to usage
    try:
        conn.execute("ALTER TABLE usage ADD COLUMN engagements_sent INTEGER NOT NULL DEFAULT 0")
        conn.commit()
        logger.info("Migration: added engagements_sent to usage")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Sprint 12: Add outcome_json to outreaches (close reason, notes, booking)
    try:
        conn.execute("ALTER TABLE outreaches ADD COLUMN outcome_json TEXT")
        conn.commit()
        logger.info("Migration: added outcome_json to outreaches")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Sprint 14: Add context_json to campaigns (offerings, case_studies, social_proofs, preferences)
    try:
        conn.execute("ALTER TABLE campaigns ADD COLUMN context_json TEXT")
        conn.commit()
        logger.info("Migration: added context_json to campaigns")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Sprint 16: Add memory_json to outreaches (structured follow-up decisions)
    try:
        conn.execute("ALTER TABLE outreaches ADD COLUMN memory_json TEXT")
        conn.commit()
        logger.info("Migration: added memory_json to outreaches")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Sprint 21: Add event timestamps to outreaches
    for col in ("invited_at", "accepted_at", "first_reply_at"):
        try:
            conn.execute(f"ALTER TABLE outreaches ADD COLUMN {col} INTEGER")
            conn.commit()
            logger.info(f"Migration: added {col} to outreaches")
        except sqlite3.OperationalError:
            pass  # Column already exists

    # Sprint 17: Add scheduler_jobs table (for existing DBs created before Sprint 17)
    try:
        conn.execute("SELECT 1 FROM scheduler_jobs LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS scheduler_jobs (
                id           TEXT PRIMARY KEY,
                campaign_id  TEXT NOT NULL,
                outreach_id  TEXT,
                job_type     TEXT NOT NULL,
                status       TEXT NOT NULL DEFAULT 'pending',
                scheduled_at INTEGER NOT NULL,
                started_at   INTEGER,
                completed_at INTEGER,
                retry_count  INTEGER NOT NULL DEFAULT 0,
                error        TEXT,
                created_at   INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
                FOREIGN KEY (outreach_id) REFERENCES outreaches(id)
            );
            CREATE INDEX IF NOT EXISTS idx_scheduler_jobs_status ON scheduler_jobs(status, scheduled_at);
            CREATE INDEX IF NOT EXISTS idx_scheduler_jobs_campaign ON scheduler_jobs(campaign_id);
        """)
        conn.commit()
        logger.info("Migration: added scheduler_jobs table")

    # Feature 1.4: Add read_at to messages (read receipt tracking)
    try:
        conn.execute("ALTER TABLE messages ADD COLUMN read_at INTEGER")
        conn.commit()
        logger.info("Migration: added read_at to messages")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Sprint 22: Add experiments table
    try:
        conn.execute("SELECT 1 FROM experiments LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS experiments (
                id TEXT PRIMARY KEY, snapshot TEXT NOT NULL, result_json TEXT NOT NULL,
                campaign_ids TEXT, status TEXT NOT NULL DEFAULT 'pending',
                created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
            );
            CREATE INDEX IF NOT EXISTS idx_experiments_created ON experiments(created_at);
        """)
        conn.commit()
        logger.info("Migration: added experiments table")


    # Feature 3.2: Add variant column to outreaches (A/B testing)
    try:
        conn.execute("ALTER TABLE outreaches ADD COLUMN variant TEXT")
        conn.commit()
        logger.info("Migration: added variant to outreaches")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Feature 3.2: Add ab_tests table
    try:
        conn.execute("SELECT 1 FROM ab_tests LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS ab_tests (
                id           TEXT PRIMARY KEY,
                campaign_id  TEXT NOT NULL,
                name         TEXT NOT NULL,
                hypothesis   TEXT,
                variant_a    TEXT NOT NULL,
                variant_b    TEXT NOT NULL,
                status       TEXT NOT NULL DEFAULT 'running',
                winner       TEXT,
                result_json  TEXT,
                created_at   INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                completed_at INTEGER,
                FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
            );
            CREATE INDEX IF NOT EXISTS idx_ab_tests_campaign ON ab_tests(campaign_id);
            CREATE INDEX IF NOT EXISTS idx_ab_tests_status ON ab_tests(status);
        """)
        conn.commit()
        logger.info("Migration: added ab_tests table")


    # Feature 3.5: Add channel column to outreaches (linkedin/email)
    try:
        conn.execute("ALTER TABLE outreaches ADD COLUMN channel TEXT DEFAULT 'linkedin'")
        conn.commit()
        logger.info("Migration: added channel to outreaches")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Feature 3.5: Add channel column to messages
    try:
        conn.execute("ALTER TABLE messages ADD COLUMN channel TEXT DEFAULT 'linkedin'")
        conn.commit()
        logger.info("Migration: added channel to messages")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Feature 4.1: Add source column to contacts (search/import/discovery)
    try:
        conn.execute("ALTER TABLE contacts ADD COLUMN source TEXT DEFAULT 'search'")
        conn.commit()
        logger.info("Migration: added source to contacts")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Feature 4.4: CRM mappings table for HubSpot integration
    conn.execute("""
        CREATE TABLE IF NOT EXISTS crm_mappings (
            id              TEXT PRIMARY KEY,
            contact_id      TEXT NOT NULL,
            crm_type        TEXT NOT NULL DEFAULT 'hubspot',
            crm_contact_id  TEXT,
            crm_deal_id     TEXT,
            synced_at       INTEGER,
            created_at      INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
            FOREIGN KEY (contact_id) REFERENCES contacts(id)
        )
    """)
    conn.execute("CREATE INDEX IF NOT EXISTS idx_crm_mappings_contact ON crm_mappings(contact_id)")
    conn.commit()

    # Inbound pipeline: inbound_signals table
    try:
        conn.execute("SELECT 1 FROM inbound_signals LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS inbound_signals (
                id                 TEXT PRIMARY KEY,
                signal_type        TEXT NOT NULL,
                sender_name        TEXT,
                sender_id          TEXT,
                sender_headline    TEXT,
                sender_company     TEXT,
                sender_url         TEXT,
                content            TEXT,
                post_id            TEXT,
                profile_json       TEXT,
                intent             TEXT,
                matched_icp_id     TEXT,
                confidence         REAL DEFAULT 0.0,
                recommended_action TEXT,
                reasoning          TEXT,
                status             TEXT DEFAULT 'new',
                campaign_id        TEXT,
                created_at         INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                qualified_at       INTEGER,
                FOREIGN KEY (matched_icp_id) REFERENCES icps(id)
            );
            CREATE INDEX IF NOT EXISTS idx_inbound_signals_status ON inbound_signals(status);
            CREATE INDEX IF NOT EXISTS idx_inbound_signals_type ON inbound_signals(signal_type);
            CREATE INDEX IF NOT EXISTS idx_inbound_signals_sender ON inbound_signals(sender_id);
        """)
        conn.commit()
        logger.info("Migration: added inbound_signals table")

    # Inbound pipeline: published_posts table
    try:
        conn.execute("SELECT 1 FROM published_posts LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS published_posts (
                id           TEXT PRIMARY KEY,
                post_id      TEXT NOT NULL,
                text         TEXT,
                topic        TEXT,
                published_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                last_checked INTEGER,
                comment_count INTEGER DEFAULT 0
            );
            CREATE INDEX IF NOT EXISTS idx_published_posts_post ON published_posts(post_id);
        """)
        conn.commit()
        logger.info("Migration: added published_posts table")

    # Signal-based selling: signals table (v1.0)
    try:
        conn.execute("SELECT 1 FROM signals LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS signals (
                id             TEXT PRIMARY KEY,
                signal_type    TEXT NOT NULL,
                source         TEXT NOT NULL,
                prospect_id    TEXT,
                prospect_name  TEXT,
                prospect_title TEXT,
                linkedin_id    TEXT,
                campaign_id    TEXT,
                content        TEXT,
                post_id        TEXT,
                metadata_json  TEXT,
                intent         TEXT,
                confidence     REAL DEFAULT 0.0,
                signal_score   REAL DEFAULT 0.0,
                reasoning      TEXT,
                status         TEXT DEFAULT 'new',
                action_taken   TEXT,
                actioned_at    INTEGER,
                expires_at     INTEGER,
                detected_at    INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                classified_at  INTEGER,
                FOREIGN KEY (prospect_id) REFERENCES contacts(id),
                FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
            );
            CREATE INDEX IF NOT EXISTS idx_signals_status ON signals(status);
            CREATE INDEX IF NOT EXISTS idx_signals_type ON signals(signal_type);
            CREATE INDEX IF NOT EXISTS idx_signals_prospect ON signals(linkedin_id);
            CREATE INDEX IF NOT EXISTS idx_signals_detected ON signals(detected_at);
            CREATE INDEX IF NOT EXISTS idx_signals_score ON signals(signal_score DESC);
        """)
        conn.commit()
        logger.info("Migration: added signals table")

    # Signal-based selling: signal_watchlists table (v1.0)
    try:
        conn.execute("SELECT 1 FROM signal_watchlists LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS signal_watchlists (
                id          TEXT PRIMARY KEY,
                name        TEXT NOT NULL,
                watch_type  TEXT NOT NULL,
                keywords    TEXT NOT NULL,
                campaign_id TEXT,
                is_active   INTEGER DEFAULT 1,
                last_polled_at INTEGER,
                created_at  INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
            );
            CREATE INDEX IF NOT EXISTS idx_signal_watchlists_active ON signal_watchlists(is_active);
            CREATE INDEX IF NOT EXISTS idx_signal_watchlists_campaign ON signal_watchlists(campaign_id);
        """)
        conn.commit()
        logger.info("Migration: added signal_watchlists table")

    # Signal-based selling: signal_accounts table (v1.0)
    try:
        conn.execute("SELECT 1 FROM signal_accounts LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS signal_accounts (
                linkedin_id     TEXT PRIMARY KEY,
                prospect_name   TEXT,
                company         TEXT,
                total_signals   INTEGER DEFAULT 0,
                composite_score REAL DEFAULT 0.0,
                top_signal_type TEXT,
                last_signal_at  INTEGER,
                updated_at      INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
            );
        """)
        conn.commit()
        logger.info("Migration: added signal_accounts table")

    # Signal-based selling: last_scanned_at on contacts (v1.0)
    try:
        conn.execute("ALTER TABLE contacts ADD COLUMN last_scanned_at INTEGER")
        conn.commit()
        logger.info("Migration: added last_scanned_at to contacts")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Signal activation: signal_id on outreaches (v1.1)
    try:
        conn.execute("ALTER TABLE outreaches ADD COLUMN signal_id TEXT")
        conn.commit()
        logger.info("Migration: added signal_id to outreaches")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Strategy engine: estimated_revenue on contacts
    try:
        conn.execute("ALTER TABLE contacts ADD COLUMN estimated_revenue REAL DEFAULT 0.0")
        conn.commit()
        logger.info("Migration: added estimated_revenue to contacts")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Strategy engine: strategy_json on outreaches
    try:
        conn.execute("ALTER TABLE outreaches ADD COLUMN strategy_json TEXT")
        conn.commit()
        logger.info("Migration: added strategy_json to outreaches")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Strategy engine: spawned_by on campaigns
    try:
        conn.execute("ALTER TABLE campaigns ADD COLUMN spawned_by TEXT")
        conn.commit()
        logger.info("Migration: added spawned_by to campaigns")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Strategy engine: strategy_patterns table
    try:
        conn.execute("SELECT 1 FROM strategy_patterns LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS strategy_patterns (
                id                      TEXT PRIMARY KEY,
                pattern_type            TEXT NOT NULL,
                pattern_key             TEXT NOT NULL,
                description             TEXT,
                evidence_json           TEXT NOT NULL,
                confidence              REAL DEFAULT 0.0,
                estimated_revenue_impact REAL DEFAULT 0.0,
                acceptance_rate         REAL,
                reply_rate              REAL,
                conversion_rate         REAL,
                sample_size             INTEGER DEFAULT 0,
                status                  TEXT DEFAULT 'active',
                discovered_at           INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                last_validated          INTEGER,
                created_at              INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
            );
            CREATE INDEX IF NOT EXISTS idx_strategy_patterns_type ON strategy_patterns(pattern_type);
            CREATE INDEX IF NOT EXISTS idx_strategy_patterns_status ON strategy_patterns(status);
            CREATE INDEX IF NOT EXISTS idx_strategy_patterns_revenue ON strategy_patterns(estimated_revenue_impact);
        """)
        conn.commit()
        logger.info("Migration: added strategy_patterns table")

    # Strategy engine: strategy_actions table
    try:
        conn.execute("SELECT 1 FROM strategy_actions LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS strategy_actions (
                id              TEXT PRIMARY KEY,
                action_type     TEXT NOT NULL,
                campaign_id     TEXT,
                pattern_id      TEXT,
                details_json    TEXT NOT NULL,
                outcome_json    TEXT,
                status          TEXT DEFAULT 'applied',
                created_at      INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                measured_at     INTEGER,
                FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
                FOREIGN KEY (pattern_id) REFERENCES strategy_patterns(id)
            );
            CREATE INDEX IF NOT EXISTS idx_strategy_actions_campaign ON strategy_actions(campaign_id);
            CREATE INDEX IF NOT EXISTS idx_strategy_actions_type ON strategy_actions(action_type);
            CREATE INDEX IF NOT EXISTS idx_strategy_actions_status ON strategy_actions(status);
        """)
        conn.commit()
        logger.info("Migration: added strategy_actions table")


    # Tracking fix: add outreach_id to inbound_signals for message tracking
    try:
        conn.execute("ALTER TABLE inbound_signals ADD COLUMN outreach_id TEXT")
        conn.commit()
        logger.info("Migration: added outreach_id to inbound_signals")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Tracking fix: invite attempt tracking on outreaches
    for col, ctype in [("invite_attempts", "INTEGER NOT NULL DEFAULT 0"),
                        ("last_attempt_error", "TEXT")]:
        try:
            conn.execute(f"ALTER TABLE outreaches ADD COLUMN {col} {ctype}")
            conn.commit()
            logger.info("Migration: added %s to outreaches", col)
        except sqlite3.OperationalError:
            pass  # Column already exists

    # Tracking fix: campaign_id on engagements for manual engagement linking
    try:
        conn.execute("ALTER TABLE engagements ADD COLUMN campaign_id TEXT")
        conn.commit()
        logger.info("Migration: added campaign_id to engagements")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Compound intent events table (Week 10)
    try:
        conn.execute("SELECT 1 FROM intent_events LIMIT 0")
    except sqlite3.OperationalError:
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS intent_events (
                id              TEXT PRIMARY KEY,
                linkedin_id     TEXT NOT NULL,
                company         TEXT,
                event_type      TEXT NOT NULL,
                signal_ids      TEXT NOT NULL,
                signal_types    TEXT NOT NULL,
                composite_score REAL NOT NULL,
                action_taken    TEXT,
                detected_at     INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
                expires_at      INTEGER NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_intent_events_linkedin ON intent_events(linkedin_id);
            CREATE INDEX IF NOT EXISTS idx_intent_events_type ON intent_events(event_type);
            CREATE INDEX IF NOT EXISTS idx_intent_events_score ON intent_events(composite_score DESC);
        """)
        conn.commit()
        logger.info("Migration: added intent_events table")


    # Voice memo support: format column on messages (voice memo sprint)
    try:
        conn.execute("ALTER TABLE messages ADD COLUMN format TEXT DEFAULT 'text'")
        conn.commit()
        logger.info("Migration: added format column to messages")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Fix: add updated_at to contacts (was missing from original schema)
    try:
        conn.execute("ALTER TABLE contacts ADD COLUMN updated_at INTEGER")
        conn.commit()
        logger.info("Migration: added updated_at to contacts")
    except sqlite3.OperationalError:
        pass  # Column already exists

    # Fix: convert empty-string campaign_id to NULL in scheduler_jobs
    # (global jobs used "" which violated FK constraint)
    try:
        updated = conn.execute(
            "UPDATE scheduler_jobs SET campaign_id = NULL WHERE campaign_id = ''"
        ).rowcount
        if updated:
            conn.commit()
            logger.info("Migration: converted %d scheduler_jobs campaign_id '' → NULL", updated)
    except sqlite3.OperationalError:
        pass


def reset_db() -> None:
    """Delete the database file entirely."""
    path = config.db_path()
    if path.exists():
        path.unlink()
        logger.info("Database deleted")
